@fuzdev/fuz_app 0.94.0 → 0.95.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.
@@ -894,7 +894,10 @@ source of truth for wire-shape conformance.
894
894
  - `testing/cross_backend/capabilities.ts` — `BackendCapabilities` vocabulary
895
895
  (the **gating** flags `ws` / `sse` / `cell_crud` / `cell_relations` /
896
896
  `account_lifecycle` / `fact_serving` / `ready` / `account_status` /
897
- `oversized_reject_closes_connection` — each has a `test_if` reader),
897
+ `oversized_reject_closes_connection` / `peer_request` — each has a `test_if`
898
+ reader; `peer_request` (server-initiated requests — the `ActionPeer`
899
+ `peer/ping` round-trip) is `true` only for the Rust spine and `false` for the
900
+ TS family, whose server request transport is deferred),
898
901
  `test_if(cond, name, fn)`
899
902
  for capability-gated cases, and `in_process_capabilities` preset. The
900
903
  non-gating wiring facts (`bearer_auth` / `trusted_proxy` / `login_rate_limit`,
@@ -1076,6 +1079,30 @@ where one exists (forge) and be offered the role; harmless on the auth-only
1076
1079
  spine. Gated on `capabilities.ws`; cross-process only. fuz_app's own wiring is
1077
1080
  `src/test/cross_backend/role_grant_offer_notification_ws.cross.test.ts`.
1078
1081
 
1082
+ ### `cross_backend/peer_ping_ws.ts` — `describe_peer_ping_ws_tests`
1083
+
1084
+ The server→client **request/response** sibling of the notification suite above —
1085
+ the machinery proof for `ActionPeer`. `peer/ping` is `initiator: both`; the
1086
+ client→server direction already exists as `heartbeat`, this exercises the new
1087
+ server→client one. The observable trigger is a client invoking the `peer/ping`
1088
+ **action** over its own socket: the handler initiates a `peer/ping` request back
1089
+ to that socket (`ctx.request_client`), awaits the client's echo, validates it
1090
+ against `PingResponse`, and returns the validated shape — so one client RPC
1091
+ drives the whole round-trip and every outcome (success / `Timeout` / wrong-shape
1092
+ / client-error) surfaces as that RPC's wire response. The client attaches an
1093
+ `on_request` responder **at construction** (the `create_ws_transport` seam) so
1094
+ the inbound server-initiated request is answered as soon as it arrives; the
1095
+ security negatives use the raw `WsClient.send` to inject unsolicited /
1096
+ cross-connection frames. Seven cases: the positive round-trip plus
1097
+ unsolicited-response rejection, per-connection id isolation, never-reply
1098
+ `Timeout`, wrong-shape rejection, client-error forwarding, and the HTTP
1099
+ no-transport path. Gated on `capabilities.peer_request` — `true` only on the
1100
+ Rust spine (server-initiated requests are Rust-first canonical), so it `.skip`s
1101
+ on the TS spines whose server request transport is the deferred convergence
1102
+ item; `peer/ping` is a protocol action (manifest-excluded), so parity is
1103
+ behavioral here, not via the manifest gate. Cross-process only; fuz_app's own
1104
+ wiring is `src/test/cross_backend/peer_ping_ws.cross.test.ts`.
1105
+
1079
1106
  ### `cross_backend/sse_round_trip.ts` — `describe_cross_process_sse_tests`
1080
1107
 
1081
1108
  Cross-process counterpart to the in-process `sse_round_trip.ts` harness —
@@ -1177,12 +1204,19 @@ in-process legs (plain `gro test`) are `src/test/auth/cell_crud_parity.db.test.t
1177
1204
  wire-frame types, and
1178
1205
  predicates (`is_notification`, `is_response_for`, ...). Both in-process
1179
1206
  (`ws_round_trip.ts`) and cross-process (`ws_transport.ts`) impls satisfy
1180
- this interface.
1181
- - `testing/transports/ws_transport.ts` — `create_ws_transport({base_url, ws_path, cookies, origin?})`
1207
+ this interface. Also owns `deliver_inbound` — the shared
1208
+ parse→(respond|push)→resolve receive core both impls call (so the responder
1209
+ branch is written once) — plus the `WsRequestResponder` / `WsResponderOutcome`
1210
+ types for the **`on_request`** seam: a responder answers a server-initiated
1211
+ request (`{result}` / `{error}` / `undefined` to swallow), the path
1212
+ `describe_peer_ping_ws_tests` drives. With no `on_request` the seam is inert
1213
+ (a server-initiated request flows to `messages` as before).
1214
+ - `testing/transports/ws_transport.ts` — `create_ws_transport({base_url, ws_path, cookies, origin?, on_request?})`
1182
1215
  builds a real-upgrade WS client using the `ws` npm package (optional
1183
1216
  peerDep; consumers wiring cross-process tests `npm install --save-dev ws`).
1184
1217
  Threads the keeper cookie onto the upgrade so per-action auth succeeds on
1185
- the first message.
1218
+ the first message. `on_request` (attached at construction, before the upgrade
1219
+ completes) answers server-initiated requests via the shared `deliver_inbound`.
1186
1220
  - `testing/transports/sse_frame_reader.ts` — `create_sse_frame_reader(reader, default_timeout_ms?)`,
1187
1221
  the transport-agnostic SSE framing core over a
1188
1222
  `ReadableStreamDefaultReader<Uint8Array>`: `\n\n` framing, per-read timeout,
@@ -139,12 +139,33 @@ export interface BackendCapabilities {
139
139
  * smuggle detector. See `docs/security.md` §"Body Size Limiting".
140
140
  */
141
141
  readonly oversized_reject_closes_connection: boolean;
142
+ /**
143
+ * The backend can **initiate** a JSON-RPC request to a connected client
144
+ * and await its typed reply (the server→client request/response direction
145
+ * `ActionPeer` adds). Gates `describe_peer_ping_ws_tests` — the on-demand
146
+ * `peer/ping` round-trip plus its security negatives (unsolicited-response
147
+ * rejection, per-connection id isolation, never-replying `Timeout`,
148
+ * wrong-shape reply rejection).
149
+ *
150
+ * `true` for the Rust spine (server-initiated requests landed Rust-first
151
+ * canonical); `false` for the TS family — the TS server
152
+ * `BackendWebsocketTransport.send()` request path is the deferred
153
+ * twin-impl convergence item, so a TS backend can't yet drive the
154
+ * round-trip. Like ws/sse/cells, `peer/ping` stays off the standard
155
+ * declared surface (it's a protocol action, manifest-excluded), so this
156
+ * flag is the only opt-in into the peer parity coverage.
157
+ */
158
+ readonly peer_request: boolean;
142
159
  }
143
160
  /**
144
- * Capability declarations for the in-process Hono transport. Every flag
145
- * is `true` because in-process testing exercises the full backend with
146
- * no missing optional behaviors. Cross-process consumers
147
- * declare each flag explicitly per backend.
161
+ * Capability declarations for the in-process Hono transport. Nearly every
162
+ * flag is `true` because in-process testing exercises the full backend
163
+ * with no missing optional behaviors. The one exception is `peer_request`:
164
+ * the in-process driver runs the **TS** server (`register_action_ws`),
165
+ * whose server-initiated request transport is deferred, so it can't drive
166
+ * a `peer/ping` round-trip — and `describe_peer_ping_ws_tests` is
167
+ * cross-process-only regardless. Cross-process consumers declare each flag
168
+ * explicitly per backend.
148
169
  */
149
170
  export declare const in_process_capabilities: BackendCapabilities;
150
171
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwC9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,kCAAkC,EAAE,OAAO,CAAC;CACrD;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAUpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAG,IAMrF,CAAC"}
1
+ {"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwC9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,kCAAkC,EAAE,OAAO,CAAC;IACrD;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAC/B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAWpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAG,IAMrF,CAAC"}
@@ -36,10 +36,14 @@ import '../assert_dev_env.js';
36
36
  */
37
37
  import { test } from 'vitest';
38
38
  /**
39
- * Capability declarations for the in-process Hono transport. Every flag
40
- * is `true` because in-process testing exercises the full backend with
41
- * no missing optional behaviors. Cross-process consumers
42
- * declare each flag explicitly per backend.
39
+ * Capability declarations for the in-process Hono transport. Nearly every
40
+ * flag is `true` because in-process testing exercises the full backend
41
+ * with no missing optional behaviors. The one exception is `peer_request`:
42
+ * the in-process driver runs the **TS** server (`register_action_ws`),
43
+ * whose server-initiated request transport is deferred, so it can't drive
44
+ * a `peer/ping` round-trip — and `describe_peer_ping_ws_tests` is
45
+ * cross-process-only regardless. Cross-process consumers declare each flag
46
+ * explicitly per backend.
43
47
  */
44
48
  export const in_process_capabilities = Object.freeze({
45
49
  ws: true,
@@ -51,6 +55,7 @@ export const in_process_capabilities = Object.freeze({
51
55
  ready: true,
52
56
  account_status: true,
53
57
  oversized_reject_closes_connection: true,
58
+ peer_request: false,
54
59
  });
55
60
  /**
56
61
  * Shape notes for the in-process Hono transport — every wiring fact present
@@ -1 +1 @@
1
- {"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAiBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAgBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
1
+ {"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAqBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAmBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
@@ -35,6 +35,10 @@ export const ts_default_capabilities = Object.freeze({
35
35
  // posture). A Bun-served consumer overrides to `false` (see the bun spine
36
36
  // config) — fail-loud rather than silently skipping the smuggle detector.
37
37
  oversized_reject_closes_connection: true,
38
+ // The TS server can't yet initiate server→client requests (the
39
+ // `BackendWebsocketTransport.send()` request path is the deferred
40
+ // twin-impl convergence), so the TS family skips the peer/ping suite.
41
+ peer_request: false,
38
42
  });
39
43
  /**
40
44
  * Capabilities for the Rust family. The non-gating wiring facts live in
@@ -57,6 +61,9 @@ export const rust_default_capabilities = Object.freeze({
57
61
  // hyper sends an RST on the oversized-body reject — the connection closes
58
62
  // and the pipelined request is never reached.
59
63
  oversized_reject_closes_connection: true,
64
+ // Server-initiated requests landed Rust-first canonical — the Rust spine
65
+ // drives the `peer/ping` round-trip, so the family opts into the suite.
66
+ peer_request: true,
60
67
  });
61
68
  /**
62
69
  * Shape notes for TS-family backends — wiring facts, not gating flags.
@@ -0,0 +1,23 @@
1
+ import '../assert_dev_env.js';
2
+ import { type BackendCapabilities } from './capabilities.js';
3
+ import type { SetupTest } from './setup.js';
4
+ /** Configuration for {@link describe_peer_ping_ws_tests}. */
5
+ export interface PeerPingWsTestOptions {
6
+ /** Per-test fixture producer (`default_cross_process_setup(handle, ...)`). */
7
+ readonly setup_test: SetupTest;
8
+ /** Backend capability flags; every case gates on `capabilities.peer_request`. */
9
+ readonly capabilities: BackendCapabilities;
10
+ /** Base URL the backend is reachable at (e.g. `http://localhost:1177`). */
11
+ readonly base_url: string;
12
+ /** WebSocket endpoint path on the backend (e.g. `/api/ws`). */
13
+ readonly ws_path: string;
14
+ }
15
+ /**
16
+ * Register the server-initiated `peer/ping` suite — a positive round-trip
17
+ * plus the security negatives the design doc's §Security surface mandates
18
+ * (unsolicited-response rejection, per-connection id isolation, never-reply
19
+ * `Timeout`, wrong-shape reply rejection, plus client-error forwarding and
20
+ * the HTTP no-transport path). Gated on `capabilities.peer_request`.
21
+ */
22
+ export declare const describe_peer_ping_ws_tests: (options: PeerPingWsTestOptions) => void;
23
+ //# sourceMappingURL=peer_ping_ws.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"peer_ping_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/peer_ping_ws.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAkD9B,OAAO,EAAC,KAAK,mBAAmB,EAAU,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAkB1C,6DAA6D;AAC7D,MAAM,WAAW,qBAAqB;IACrC,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,GAAI,SAAS,qBAAqB,KAAG,IAmN5E,CAAC"}
@@ -0,0 +1,209 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-process **server-initiated `peer/ping`** suite — the machinery
4
+ * proof for `ActionPeer` (a backend initiating a JSON-RPC request to a
5
+ * connected client and awaiting its typed reply). The sibling of the
6
+ * one-way notification suite (`role_grant_offer_notification_ws.ts`),
7
+ * extended from server→client *notifications* to server→client
8
+ * *request/response*.
9
+ *
10
+ * `peer/ping` is `initiator: both`: the client→server direction already
11
+ * exists as `heartbeat`; the new server→client direction is what this
12
+ * exercises. The observable trigger is the client invoking the `peer/ping`
13
+ * **action** over its own socket — the handler turns around and *initiates*
14
+ * a `peer/ping` request back to that socket, awaits the client's echo,
15
+ * validates it against `PingResponse`, and returns the validated shape. So
16
+ * one client RPC drives the whole round-trip, and every outcome
17
+ * (success / `Timeout` / wrong-shape / client-error) surfaces as that RPC's
18
+ * wire response — directly assertable.
19
+ *
20
+ * The client side attaches an `on_request` responder **at construction**
21
+ * (via `create_ws_transport`'s seam) so the server-initiated request is
22
+ * answered as soon as it arrives. Security negatives use the raw
23
+ * `WsClient.send` to inject unsolicited / cross-connection frames.
24
+ *
25
+ * **Per-spec auth.** `peer/ping` is `auth: public` (a liveness echo is
26
+ * non-sensitive — see the design doc); the WS upgrade itself still
27
+ * authenticates, so the suite drives it over the keeper's session.
28
+ *
29
+ * Gated on `capabilities.peer_request` — `true` only for the Rust spine
30
+ * (server-initiated requests landed Rust-first canonical); the TS family
31
+ * skips until its server transport's request path lands (deferred twin-impl
32
+ * convergence). Cross-process only: `create_ws_transport` needs a real bound
33
+ * socket, so wire it from a `*.cross.test.ts`.
34
+ *
35
+ * @module
36
+ */
37
+ import { assert, describe } from 'vitest';
38
+ import { rpc_call } from '../rpc_helpers.js';
39
+ import { create_ws_transport } from '../transports/ws_transport.js';
40
+ import { is_response_for, } from '../transports/ws_client.js';
41
+ import { create_jsonrpc_request, is_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
42
+ import { test_if } from './capabilities.js';
43
+ /** JSON-RPC endpoint path — matches the spine's `/api/rpc` (and the forge's). */
44
+ const RPC_PATH = '/api/rpc';
45
+ /** Wire method (both directions). Mirrors the Rust `PEER_PING_METHOD`. */
46
+ const PEER_PING_METHOD = 'peer/ping';
47
+ // Stable `data.reason` discriminators — the cross-impl wire contract, kept
48
+ // as an independent TS-side copy of the Rust `REASON_PEER_*` constants
49
+ // (parity by test, not codegen).
50
+ const REASON_PEER_TIMEOUT = 'peer_timeout';
51
+ const REASON_PEER_INVALID_REPLY = 'peer_ping_invalid_reply';
52
+ const REASON_PEER_NO_TRANSPORT = 'peer_no_transport';
53
+ /** A reply payload that satisfies the Rust `PingResponse` shape. */
54
+ const valid_reply = (nonce) => ({ nonce, protocol_version: 1 });
55
+ /**
56
+ * Register the server-initiated `peer/ping` suite — a positive round-trip
57
+ * plus the security negatives the design doc's §Security surface mandates
58
+ * (unsolicited-response rejection, per-connection id isolation, never-reply
59
+ * `Timeout`, wrong-shape reply rejection, plus client-error forwarding and
60
+ * the HTTP no-transport path). Gated on `capabilities.peer_request`.
61
+ */
62
+ export const describe_peer_ping_ws_tests = (options) => {
63
+ const { setup_test, capabilities, base_url, ws_path } = options;
64
+ // -- shared helpers -------------------------------------------------------
65
+ /** Open a WS transport for a session cookie, with an optional responder. */
66
+ const open_ws = (cookie, on_request) => {
67
+ assert.ok(cookie, 'expected a session cookie for the WS upgrade');
68
+ return create_ws_transport({
69
+ base_url,
70
+ ws_path,
71
+ cookies: [cookie],
72
+ origin: base_url,
73
+ on_request,
74
+ });
75
+ };
76
+ /** A responder that echoes a valid `PingResponse` for every `peer/ping`. */
77
+ const echo_responder = (req) => {
78
+ const nonce = req.params?.nonce ?? 0;
79
+ return { result: valid_reply(nonce) };
80
+ };
81
+ /**
82
+ * Invoke `peer/ping` over the socket and return the **raw** response
83
+ * frame (success or error) so the negatives can assert on
84
+ * `error.data.reason`. The client wait is generous (longer than any
85
+ * server-side `timeout_ms`) so the server's own outcome is what we read.
86
+ */
87
+ const ping_raw = (ws, id, params) => ws
88
+ .send(create_jsonrpc_request(PEER_PING_METHOD, params, id))
89
+ .then(() => ws.wait_for(is_response_for(id), 8000));
90
+ const error_reason = (frame) => frame.error.data?.reason;
91
+ // -- tests ----------------------------------------------------------------
92
+ describe('peer/ping server-initiated request (cross-process)', () => {
93
+ test_if(capabilities.peer_request, 'a client peer/ping round-trips: server pings back, validates the echo, returns it', async () => {
94
+ const fixture = await setup_test();
95
+ const ws = await open_ws(fixture.create_session_headers().cookie, echo_responder);
96
+ try {
97
+ const result = await ws.request(1, PEER_PING_METHOD, { nonce: 42 }, 8000);
98
+ assert.strictEqual(result.nonce, 42, 'the server echoes the issued nonce back');
99
+ assert.strictEqual(typeof result.protocol_version, 'number');
100
+ }
101
+ finally {
102
+ await ws.close();
103
+ }
104
+ });
105
+ test_if(capabilities.peer_request, 'a never-replying client yields a server-side Timeout (the liveness signal)', async () => {
106
+ const fixture = await setup_test();
107
+ // Responder swallows the ping (returns nothing) — the half-open peer.
108
+ const ws = await open_ws(fixture.create_session_headers().cookie, () => undefined);
109
+ try {
110
+ // Short server-side deadline so the negative runs fast.
111
+ const frame = await ping_raw(ws, 2, { nonce: 1, timeout_ms: 300 });
112
+ assert.ok('error' in frame, 'a swallowed ping must error, not succeed');
113
+ assert.strictEqual(error_reason(frame), REASON_PEER_TIMEOUT);
114
+ }
115
+ finally {
116
+ await ws.close();
117
+ }
118
+ });
119
+ test_if(capabilities.peer_request, 'a wrong-shape reply is rejected at the validation boundary, never handed to the caller', async () => {
120
+ const fixture = await setup_test();
121
+ // Reply that fails `PingResponse` validation (nonce wrong type,
122
+ // protocol_version missing).
123
+ const ws = await open_ws(fixture.create_session_headers().cookie, () => ({
124
+ result: { nonce: 'not-a-number' },
125
+ }));
126
+ try {
127
+ const frame = await ping_raw(ws, 3, { nonce: 1, timeout_ms: 2000 });
128
+ assert.ok('error' in frame, 'a malformed reply must error, not return garbage');
129
+ assert.strictEqual(error_reason(frame), REASON_PEER_INVALID_REPLY);
130
+ }
131
+ finally {
132
+ await ws.close();
133
+ }
134
+ });
135
+ test_if(capabilities.peer_request, 'a client JSON-RPC error reply surfaces as the action error (ClientError forwarded)', async () => {
136
+ const fixture = await setup_test();
137
+ const ws = await open_ws(fixture.create_session_headers().cookie, () => ({
138
+ error: { code: -32603, message: 'client refused', data: { reason: 'client_says_no' } },
139
+ }));
140
+ try {
141
+ const frame = await ping_raw(ws, 4, { nonce: 1, timeout_ms: 2000 });
142
+ assert.ok('error' in frame, 'a client error reply must surface as an error');
143
+ assert.strictEqual(frame.error.code, -32603);
144
+ assert.strictEqual(error_reason(frame), 'client_says_no');
145
+ }
146
+ finally {
147
+ await ws.close();
148
+ }
149
+ });
150
+ test_if(capabilities.peer_request, 'an unsolicited response is dropped: it resolves nothing and the socket survives', async () => {
151
+ const fixture = await setup_test();
152
+ const ws = await open_ws(fixture.create_session_headers().cookie, echo_responder);
153
+ try {
154
+ // Inject a reply for an id the server never issued on this socket.
155
+ await ws.send({ jsonrpc: '2.0', id: 's999999', result: valid_reply(7) });
156
+ // A legitimate round-trip still works → the junk frame neither
157
+ // crashed the read loop nor corrupted the pending registry.
158
+ const result = await ws.request(5, PEER_PING_METHOD, { nonce: 99 }, 8000);
159
+ assert.strictEqual(result.nonce, 99);
160
+ }
161
+ finally {
162
+ await ws.close();
163
+ }
164
+ });
165
+ test_if(capabilities.peer_request, 'a reply on socket B never resolves a request issued on socket A (per-connection isolation)', async () => {
166
+ const fixture = await setup_test();
167
+ // A has NO responder, so the inbound server-initiated request is
168
+ // surfaced as a message A can observe (to learn its server id);
169
+ // A never answers it.
170
+ const ws_a = await open_ws(fixture.create_session_headers().cookie);
171
+ const ws_b = await open_ws(fixture.create_session_headers().cookie, echo_responder);
172
+ try {
173
+ // A invokes peer/ping with a short server-side deadline; don't
174
+ // await yet — observe the server→client request first.
175
+ const ping_promise = ping_raw(ws_a, 6, { nonce: 1, timeout_ms: 1200 });
176
+ // The server-initiated request A received (id "sN", nonce 1).
177
+ const inbound = await ws_a.wait_for((m) => is_jsonrpc_request(m) && m.method === PEER_PING_METHOD, 5000);
178
+ // B replies for A's id — a cross-connection echo. Correct
179
+ // isolation drops it (not pending on B), so A still times out.
180
+ await ws_b.send({
181
+ jsonrpc: '2.0',
182
+ id: inbound.id,
183
+ result: valid_reply(inbound.params.nonce),
184
+ });
185
+ const frame = await ping_promise;
186
+ assert.ok('error' in frame, "B's cross-connection reply must not resolve A's request");
187
+ assert.strictEqual(error_reason(frame), REASON_PEER_TIMEOUT);
188
+ }
189
+ finally {
190
+ await ws_a.close();
191
+ await ws_b.close();
192
+ }
193
+ });
194
+ test_if(capabilities.peer_request, 'peer/ping over HTTP (no return socket) is rejected as no-transport', async () => {
195
+ const fixture = await setup_test();
196
+ const res = await rpc_call({
197
+ app: fixture.transport,
198
+ path: RPC_PATH,
199
+ method: PEER_PING_METHOD,
200
+ params: { nonce: 1 },
201
+ headers: fixture.create_session_headers(),
202
+ });
203
+ assert.isFalse(res.ok, 'HTTP peer/ping has no socket to ping');
204
+ if (!res.ok) {
205
+ assert.strictEqual(res.error.data?.reason, REASON_PEER_NO_TRANSPORT);
206
+ }
207
+ });
208
+ });
209
+ };
@@ -1,5 +1,5 @@
1
1
  import '../assert_dev_env.js';
2
- import { JSONRPC_VERSION } from '../../http/jsonrpc.js';
2
+ import { JSONRPC_VERSION, type JsonrpcRequest } from '../../http/jsonrpc.js';
3
3
  export interface JsonrpcNotificationFrame<P = unknown> {
4
4
  jsonrpc: typeof JSONRPC_VERSION;
5
5
  method: string;
@@ -38,6 +38,45 @@ export declare const is_notification: (method: string) => (msg: unknown) => bool
38
38
  export declare const is_notification_with: <P>(method: string, match: (params: P) => boolean) => (msg: unknown) => msg is JsonrpcNotificationFrame<P>;
39
39
  /** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
40
40
  export declare const is_response_for: (id: number | string) => (msg: unknown) => boolean;
41
+ /** A pending `wait_for` entry — predicate + its resolver. */
42
+ export interface WsWaiter {
43
+ predicate: (msg: unknown) => boolean;
44
+ resolve: (msg: unknown) => void;
45
+ }
46
+ /**
47
+ * What a `WsRequestResponder` returns for a server-initiated
48
+ * request:
49
+ * - `{result}` → reply with a JSON-RPC success
50
+ * - `{error}` → reply with a JSON-RPC error envelope
51
+ * - `undefined` → send nothing (models a never-replying peer — exercises
52
+ * the server-side `Timeout`)
53
+ */
54
+ export type WsResponderOutcome = {
55
+ result: unknown;
56
+ } | {
57
+ error: JsonrpcErrorResponseFrame['error'];
58
+ } | undefined;
59
+ /**
60
+ * Answers a **server-initiated** request (the server→client direction
61
+ * `ActionPeer` adds). Passed at client construction so it's attached
62
+ * before the upgrade completes — otherwise it races a connect-time ping.
63
+ * Receives the parsed request frame and returns the reply outcome (or a
64
+ * promise of it).
65
+ */
66
+ export type WsRequestResponder = (request: JsonrpcRequest) => WsResponderOutcome | Promise<WsResponderOutcome>;
67
+ /**
68
+ * Deliver one parsed inbound frame to a client's receive sink — the
69
+ * shared parse→(respond|push)→resolve core.
70
+ *
71
+ * A server-initiated **request** (`{method, id}`) is answered by
72
+ * `on_request` when supplied (replying via `reply`) and is **not**
73
+ * surfaced as a normal message; everything else — notifications, replies
74
+ * to the client's own requests, and non-JSON frames — is pushed onto
75
+ * `received` and resolves any matching `waiters`, exactly as before. With
76
+ * no `on_request`, every frame (including a server-initiated request)
77
+ * flows to the sink unchanged, so the seam is purely additive.
78
+ */
79
+ export declare const deliver_inbound: (parsed: unknown, received: Array<unknown>, waiters: Array<WsWaiter>, on_request: WsRequestResponder | undefined, reply: (message: unknown) => void) => void;
41
80
  /**
42
81
  * Default wait-for timeout shared across in-process + cross-process
43
82
  * impls. Tunable per-call via the `timeout_ms` parameter.
@@ -1 +1 @@
1
- {"version":3,"file":"ws_client.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_client.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2B9B,OAAO,EAAC,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAatD,MAAM,WAAW,wBAAwB,CAAC,CAAC,GAAG,OAAO;IACpD,OAAO,EAAE,OAAO,eAAe,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,CAAC,CAAC;CACV;AAED,MAAM,WAAW,2BAA2B,CAAC,CAAC,GAAG,OAAO;IACvD,OAAO,EAAE,OAAO,eAAe,CAAC;IAChC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;IACpB,MAAM,EAAE,CAAC,CAAC;CACV;AAED,MAAM,WAAW,yBAAyB,CAAC,CAAC,GAAG,OAAO;IACrD,OAAO,EAAE,OAAO,eAAe,CAAC;IAChC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;IACpB,KAAK,EAAE;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,CAAC,CAAA;KAAC,CAAC;CACjD;AAQD,6EAA6E;AAC7E,eAAO,MAAM,eAAe,GAC1B,QAAQ,MAAM,MACd,KAAK,OAAO,KAAG,OACsC,CAAC;AAExD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,oBAAoB,GAC/B,CAAC,EAAE,QAAQ,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,KAAK,OAAO,MAChD,KAAK,OAAO,KAAG,GAAG,IAAI,wBAAwB,CAAC,CAAC,CAGE,CAAC;AAErD,gGAAgG;AAChG,eAAO,MAAM,eAAe,GAC1B,IAAI,MAAM,GAAG,MAAM,MACnB,KAAK,OAAO,KAAG,OAC8D,CAAC;AAMhF;;;GAGG;AACH,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,0EAA0E;AAC1E,MAAM,WAAW,QAAQ;IACxB;;;;;;OAMG;IACH,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C;;;;;;;;;;;OAWG;IACH,OAAO,EAAE,CAAC,CAAC,GAAG,OAAO,EACpB,EAAE,EAAE,MAAM,GAAG,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,OAAO,EACf,UAAU,CAAC,EAAE,MAAM,KACf,OAAO,CAAC,CAAC,CAAC,CAAC;IAChB;;;;;OAKG;IACH,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD;;;;;;;;;;OAUG;IACH,cAAc,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IAC1C;;;;;;;;;;;;OAYG;IACH,QAAQ,EAAE;QACT,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,GAAG,IAAI,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;QAE5E,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;KACrF,CAAC;CACF"}
1
+ {"version":3,"file":"ws_client.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_client.ts"],"names":[],"mappings":"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"}
@@ -18,7 +18,7 @@ import '../assert_dev_env.js';
18
18
  *
19
19
  * @module
20
20
  */
21
- import { is_jsonrpc_error_response, is_jsonrpc_notification, is_jsonrpc_response, } from '../../http/jsonrpc_helpers.js';
21
+ import { create_jsonrpc_error_response, create_jsonrpc_response, is_jsonrpc_error_response, is_jsonrpc_notification, is_jsonrpc_request, is_jsonrpc_response, } from '../../http/jsonrpc_helpers.js';
22
22
  import { JSONRPC_VERSION } from '../../http/jsonrpc.js';
23
23
  // ---------------------------------------------------------------------
24
24
  // Predicates — compose with `WsClient.wait_for` and
@@ -46,6 +46,51 @@ export const is_notification_with = (method, match) => (msg) => is_jsonrpc_notif
46
46
  match(msg.params);
47
47
  /** Predicate matching a JSON-RPC response frame (success or error) for the given request id. */
48
48
  export const is_response_for = (id) => (msg) => (is_jsonrpc_response(msg) || is_jsonrpc_error_response(msg)) && msg.id === id;
49
+ /**
50
+ * Deliver one parsed inbound frame to a client's receive sink — the
51
+ * shared parse→(respond|push)→resolve core.
52
+ *
53
+ * A server-initiated **request** (`{method, id}`) is answered by
54
+ * `on_request` when supplied (replying via `reply`) and is **not**
55
+ * surfaced as a normal message; everything else — notifications, replies
56
+ * to the client's own requests, and non-JSON frames — is pushed onto
57
+ * `received` and resolves any matching `waiters`, exactly as before. With
58
+ * no `on_request`, every frame (including a server-initiated request)
59
+ * flows to the sink unchanged, so the seam is purely additive.
60
+ */
61
+ export const deliver_inbound = (parsed, received, waiters, on_request, reply) => {
62
+ if (on_request && is_jsonrpc_request(parsed)) {
63
+ void answer_request(parsed, on_request, reply);
64
+ return;
65
+ }
66
+ received.push(parsed);
67
+ for (let i = waiters.length - 1; i >= 0; i--) {
68
+ const waiter = waiters[i];
69
+ if (waiter.predicate(parsed)) {
70
+ waiter.resolve(parsed);
71
+ waiters.splice(i, 1);
72
+ }
73
+ }
74
+ };
75
+ const answer_request = async (request, on_request, reply) => {
76
+ let outcome;
77
+ try {
78
+ outcome = await on_request(request);
79
+ }
80
+ catch {
81
+ // A throwing responder models a peer that fails to answer — swallow so
82
+ // the test client surfaces no unhandled rejection; the server then sees
83
+ // a never-reply (→ `Timeout`).
84
+ return;
85
+ }
86
+ if (outcome === undefined)
87
+ return; // swallow — never-replying peer
88
+ reply('error' in outcome
89
+ ? // Test wire-frames keep `code` a plain `number` (not the branded
90
+ // Zod `JsonrpcServerErrorCode`), so cast at the builder boundary.
91
+ create_jsonrpc_error_response(request.id, outcome.error)
92
+ : create_jsonrpc_response(request.id, outcome.result));
93
+ };
49
94
  // ---------------------------------------------------------------------
50
95
  // WsClient — the test-driver surface every impl implements.
51
96
  // ---------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  import '../assert_dev_env.js';
2
- import { type WsClient } from './ws_client.js';
2
+ import { type WsClient, type WsRequestResponder } from './ws_client.js';
3
3
  /** Construction options for `create_ws_transport`. */
4
4
  export interface WsTransportOptions {
5
5
  /** Base URL the binary is reachable at — e.g. `http://localhost:8788`. Converted to `ws://` for the upgrade. */
@@ -25,6 +25,15 @@ export interface WsTransportOptions {
25
25
  * `WS_CLIENT_DEFAULT_TIMEOUT_MS` if omitted.
26
26
  */
27
27
  readonly default_timeout_ms?: number;
28
+ /**
29
+ * Optional responder for **server-initiated** requests (the
30
+ * server→client direction `ActionPeer` adds). Attached before the
31
+ * upgrade so a connect-time or on-demand `peer/ping` is answered as
32
+ * soon as it arrives. Omit for the default observe-only client — a
33
+ * server-initiated request is then surfaced as a normal message (today's
34
+ * behavior).
35
+ */
36
+ readonly on_request?: WsRequestResponder;
28
37
  }
29
38
  /**
30
39
  * Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
@@ -1 +1 @@
1
- {"version":3,"file":"ws_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,EAKN,KAAK,QAAQ,EACb,MAAM,gBAAgB,CAAC;AAGxB,sDAAsD;AACtD,MAAM,WAAW,kBAAkB;IAClC,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CACrC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,GAAU,SAAS,kBAAkB,KAAG,OAAO,CAAC,QAAQ,CAsJvF,CAAC"}
1
+ {"version":3,"file":"ws_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,EAMN,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAEvB,MAAM,gBAAgB,CAAC;AAGxB,sDAAsD;AACtD,MAAM,WAAW,kBAAkB;IAClC,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAC;CACzC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,GAAU,SAAS,kBAAkB,KAAG,OAAO,CAAC,QAAQ,CA8IvF,CAAC"}
@@ -18,7 +18,7 @@ import '../assert_dev_env.js';
18
18
  * @module
19
19
  */
20
20
  import { WebSocket } from 'ws';
21
- import { WS_CLIENT_DEFAULT_TIMEOUT_MS, is_response_for, } from './ws_client.js';
21
+ import { WS_CLIENT_DEFAULT_TIMEOUT_MS, deliver_inbound, is_response_for, } from './ws_client.js';
22
22
  import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
23
23
  /**
24
24
  * Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
@@ -34,7 +34,7 @@ import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
34
34
  * cause rather than hanging.
35
35
  */
36
36
  export const create_ws_transport = async (options) => {
37
- const { base_url, ws_path, cookies, origin, default_timeout_ms } = options;
37
+ const { base_url, ws_path, cookies, origin, default_timeout_ms, on_request } = options;
38
38
  const default_timeout = default_timeout_ms ?? WS_CLIENT_DEFAULT_TIMEOUT_MS;
39
39
  const ws_url = `${base_url.replace(/^http/i, 'ws')}${ws_path}`;
40
40
  const headers = {};
@@ -64,14 +64,10 @@ export const create_ws_transport = async (options) => {
64
64
  // it still see the payload.
65
65
  parsed = text;
66
66
  }
67
- received.push(parsed);
68
- for (let i = waiters.length - 1; i >= 0; i--) {
69
- const waiter = waiters[i];
70
- if (waiter.predicate(parsed)) {
71
- waiter.resolve(parsed);
72
- waiters.splice(i, 1);
73
- }
74
- }
67
+ deliver_inbound(parsed, received, waiters, on_request, (message) => {
68
+ if (socket.readyState === WebSocket.OPEN)
69
+ socket.send(JSON.stringify(message));
70
+ });
75
71
  });
76
72
  socket.on('close', () => {
77
73
  for (const resolve of close_resolvers)
@@ -48,7 +48,7 @@ import { type RegisterActionWsOptions } from '../actions/register_action_ws.js';
48
48
  import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
49
49
  import { type RequestContext } from '../auth/request_context.js';
50
50
  import { type CredentialType } from '../hono_context.js';
51
- import { type WsClient } from './transports/ws_client.js';
51
+ import { type WsClient, type WsRequestResponder } from './transports/ws_client.js';
52
52
  /**
53
53
  * A `WSContext` paired with capture arrays. Use `sends` to assert on
54
54
  * outgoing frames; use `closes` to assert on revocation / close.
@@ -151,6 +151,16 @@ export interface CreateWsTestHarnessOptions {
151
151
  on_socket_open?: RegisterActionWsOptions['on_socket_open'];
152
152
  /** Threaded straight through to `register_action_ws`. */
153
153
  on_socket_close?: RegisterActionWsOptions['on_socket_close'];
154
+ /**
155
+ * Optional responder for **server-initiated** requests, applied to every
156
+ * `connect()`. Purely additive (and unexercised until the TS server
157
+ * transport gains server-initiated requests — the deferred
158
+ * `BackendWebsocketTransport.send()` convergence): with it set, an
159
+ * inbound server→client request is answered rather than surfaced as a
160
+ * message; without it, behavior is unchanged. Mirrors the cross-process
161
+ * `create_ws_transport` seam so suite bodies share one shape.
162
+ */
163
+ on_request?: WsRequestResponder;
154
164
  }
155
165
  /** A harness instance — transport handle + connection factory. */
156
166
  export interface WsTestHarness {
@@ -1 +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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"}
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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"}
@@ -12,7 +12,7 @@ import { ROLE_KEEPER } from '../auth/role_schema.js';
12
12
  import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY, } from '../hono_context.js';
13
13
  import { create_jsonrpc_request } from '../http/jsonrpc_helpers.js';
14
14
  import { create_test_account, create_test_actor, create_test_role_grant } from './entities.js';
15
- import { WS_CLIENT_DEFAULT_TIMEOUT_MS, is_response_for, } from './transports/ws_client.js';
15
+ import { WS_CLIENT_DEFAULT_TIMEOUT_MS, deliver_inbound, is_response_for, } from './transports/ws_client.js';
16
16
  /**
17
17
  * Build a real `WSContext` backed by in-memory `send`/`close` capture.
18
18
  * Parsing of outgoing frames is left to the caller — `sends` holds the
@@ -169,7 +169,7 @@ const build_simple_request_context = (role) => ({
169
169
  * `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
170
170
  */
171
171
  export const create_ws_test_harness = (options) => {
172
- const { actions, transport = new BackendWebsocketTransport(), heartbeat = false, log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, } = options;
172
+ const { actions, transport = new BackendWebsocketTransport(), heartbeat = false, log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, on_request, } = options;
173
173
  const stub = create_stub_upgrade();
174
174
  // Minimal Hono stub — `register_action_ws` only needs `.get(path, handler)`.
175
175
  const stub_app = { get: () => stub_app };
@@ -211,6 +211,12 @@ export const create_ws_test_harness = (options) => {
211
211
  };
212
212
  const received = [];
213
213
  const waiters = [];
214
+ // Reply path for a server-initiated request — assigned to the
215
+ // client→server send below once `send_impl` exists. A `let` (not a
216
+ // direct `send_impl` reference) keeps the `WSContext.send` closure
217
+ // TDZ-safe; it stays a no-op until a responder + server-initiated
218
+ // request are both in play (deferred for the in-process TS server).
219
+ let reply_to_server = () => { };
214
220
  // Resolvers awaiting a server-initiated close — fired by the
215
221
  // `WSContext.close` callback below so `wait_for_close` resolves
216
222
  // whether the dispatcher (e.g. an auth-guard revocation) or the
@@ -228,14 +234,7 @@ export const create_ws_test_harness = (options) => {
228
234
  if (is_closed)
229
235
  return;
230
236
  const parsed = typeof data === 'string' ? JSON.parse(data) : data;
231
- received.push(parsed);
232
- for (let i = waiters.length - 1; i >= 0; i--) {
233
- const waiter = waiters[i];
234
- if (waiter.predicate(parsed)) {
235
- waiter.resolve(parsed);
236
- waiters.splice(i, 1);
237
- }
238
- }
237
+ deliver_inbound(parsed, received, waiters, on_request, reply_to_server);
239
238
  },
240
239
  close: (code, reason) => {
241
240
  if (is_closed)
@@ -302,6 +301,11 @@ export const create_ws_test_harness = (options) => {
302
301
  // handler, send).
303
302
  await events.onMessage?.(message_event, ws);
304
303
  };
304
+ // A responder's reply to a server-initiated request goes back to the
305
+ // server over the same client→server path as any other send.
306
+ reply_to_server = (message) => {
307
+ void send_impl(message);
308
+ };
305
309
  return {
306
310
  get messages() {
307
311
  return received;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.94.0",
3
+ "version": "0.95.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",