@fuzdev/fuz_app 0.113.0 → 0.114.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.
Files changed (72) hide show
  1. package/dist/actions/CLAUDE.md +46 -20
  2. package/dist/actions/action_dispatcher.d.ts +1 -1
  3. package/dist/actions/action_dispatcher.d.ts.map +1 -1
  4. package/dist/actions/action_dispatcher.js +22 -16
  5. package/dist/actions/action_event.d.ts +18 -2
  6. package/dist/actions/action_event.d.ts.map +1 -1
  7. package/dist/actions/action_event.js +63 -19
  8. package/dist/actions/action_event_types.d.ts +22 -0
  9. package/dist/actions/action_event_types.d.ts.map +1 -1
  10. package/dist/actions/action_event_types.js +19 -0
  11. package/dist/actions/compile_action_registry.d.ts +4 -2
  12. package/dist/actions/compile_action_registry.d.ts.map +1 -1
  13. package/dist/actions/compile_action_registry.js +8 -3
  14. package/dist/actions/frontend_rpc_client.d.ts +6 -0
  15. package/dist/actions/frontend_rpc_client.d.ts.map +1 -1
  16. package/dist/actions/frontend_rpc_client.js +2 -1
  17. package/dist/actions/heartbeat.d.ts +8 -4
  18. package/dist/actions/heartbeat.d.ts.map +1 -1
  19. package/dist/actions/heartbeat.js +8 -4
  20. package/dist/actions/peer_ping.d.ts.map +1 -1
  21. package/dist/actions/peer_ping.js +3 -1
  22. package/dist/actions/perform_action.d.ts +1 -1
  23. package/dist/actions/perform_action.d.ts.map +1 -1
  24. package/dist/actions/perform_action.js +6 -4
  25. package/dist/actions/register_action_ws.d.ts +13 -0
  26. package/dist/actions/register_action_ws.d.ts.map +1 -1
  27. package/dist/actions/register_action_ws.js +23 -2
  28. package/dist/actions/rpc_client.d.ts +1 -1
  29. package/dist/actions/rpc_client.d.ts.map +1 -1
  30. package/dist/actions/rpc_client.js +2 -1
  31. package/dist/actions/socket.svelte.d.ts +53 -10
  32. package/dist/actions/socket.svelte.d.ts.map +1 -1
  33. package/dist/actions/socket.svelte.js +84 -23
  34. package/dist/actions/transports.d.ts +24 -0
  35. package/dist/actions/transports.d.ts.map +1 -1
  36. package/dist/actions/transports.js +30 -0
  37. package/dist/actions/transports_http.d.ts +11 -4
  38. package/dist/actions/transports_http.d.ts.map +1 -1
  39. package/dist/actions/transports_http.js +63 -18
  40. package/dist/actions/transports_ws.d.ts +28 -3
  41. package/dist/actions/transports_ws.d.ts.map +1 -1
  42. package/dist/actions/transports_ws.js +33 -4
  43. package/dist/actions/ws_endpoint_spec.d.ts +7 -0
  44. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  45. package/dist/http/CLAUDE.md +7 -3
  46. package/dist/http/jsonrpc_helpers.d.ts +0 -5
  47. package/dist/http/jsonrpc_helpers.d.ts.map +1 -1
  48. package/dist/http/jsonrpc_helpers.js +0 -13
  49. package/dist/http/schema_helpers.d.ts +56 -0
  50. package/dist/http/schema_helpers.d.ts.map +1 -1
  51. package/dist/http/schema_helpers.js +91 -0
  52. package/dist/server/app_server.d.ts.map +1 -1
  53. package/dist/server/app_server.js +1 -0
  54. package/dist/testing/CLAUDE.md +17 -12
  55. package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
  56. package/dist/testing/cross_backend/peer_ping_ws.js +18 -0
  57. package/dist/testing/cross_backend/ws_round_trip.d.ts +11 -3
  58. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  59. package/dist/testing/cross_backend/ws_round_trip.js +74 -12
  60. package/dist/testing/transports/ws_client.d.ts +6 -0
  61. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  62. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  63. package/dist/testing/transports/ws_transport.js +6 -1
  64. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  65. package/dist/testing/ws_round_trip.js +5 -0
  66. package/dist/ui/form_state.svelte.d.ts +9 -4
  67. package/dist/ui/form_state.svelte.d.ts.map +1 -1
  68. package/dist/ui/form_state.svelte.js +28 -8
  69. package/package.json +2 -2
  70. package/dist/actions/request_tracker.svelte.d.ts +0 -77
  71. package/dist/actions/request_tracker.svelte.d.ts.map +0 -1
  72. package/dist/actions/request_tracker.svelte.js +0 -169
@@ -82,7 +82,7 @@ order and returns the `Map<method, RpcAction>` the dispatchers use:
82
82
  - **Auth-shape biconditional** — `actor !== 'none' ⟺ input declares acting?: ActingActor` (via `assert_route_auth_acting_biconditional`).
83
83
  - **No `auth.required_scope`** — the slot is route-spec-only; nothing mounts a guard from it on an action, so declaring it would be a control that silently does nothing. Narrowed tokens are gated per method by the scope check in `perform_action`, which derives the `rpc:<method>` capability from `spec.method` rather than trusting a hand-written one.
84
84
  - **Rate-limit account axis** — `rate_limit: 'account' | 'both'` requires `auth.account === 'required'`.
85
- - **JSON-RPC §4.2 wire validity** — `request_response` specs with a handler may not use `z.null()` for input (use `z.void()` for nullary).
85
+ - **JSON-RPC §4.2 wire validity** — `request_response` specs with a handler may not use `z.null()` for input (use `z.void()` for nullary), nor `z.void()` for output (a response always carries a `result`, which clients validate against `spec.output`; use `z.null()` when there's nothing to return).
86
86
  - **Unique method names** across the array.
87
87
 
88
88
  Only `request_response` specs with a handler reach the dispatch map;
@@ -296,7 +296,7 @@ Pipeline (401 → authz → 403 → 429 → 400 → handler):
296
296
  2. Authorization phase — `apply_authorization_phase` against `account_id` + `read_acting(auth, raw_params)`, which takes the selector off the raw params (malformed reads as omitted) since validation runs later. Test escape hatch via `preset.request_context`
297
297
  3. Post-authorization auth (403) — credential-type gate, then token scope, then role
298
298
  4. Rate limit (429) — throttle-requests semantics, ahead of validation so malformed params charge the budget and a throttled caller pays no schema work
299
- 5. Validate params (400) — `spec.input.safeParse` with `z.void()` / `?? {}` rules
299
+ 5. Validate params (400) — `spec.input.safeParse` with `z.void()` / `?? {}` rules; a `z.void()` input reads absent, `null` (GET `?params=null`), and `{}` alike as the no-arg call (`to_input_params` in `http/schema_helpers.ts`, via `to_void_params`, the twin of the Rust `require_void_params`; the peer receive path in `ActionDispatcher` runs the same normalizer) and refuses any other shape
300
300
  6. Dispatch + DEV output validation + error normalization — `spec.side_effects` picks transaction vs pool. `ThrownJsonrpcError` preserves code + data; generic throws become `internal_error`
301
301
 
302
302
  `PerformActionInput` carries `account_id`, `credential_type`, `client_ip`,
@@ -349,8 +349,9 @@ and `allow_fallback: boolean` (default `true`). Explicit
349
349
  ### WS close codes (`actions/transports.ts`)
350
350
 
351
351
  - `WS_CLOSE_SESSION_REVOKED = 4001` — server revoked auth; client enters permanent `revoked` state, no reconnect.
352
- - `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`.
352
+ - `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past its receive timeout (`resolve_heartbeat_receive_timeout`).
353
353
  - `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
354
+ - `WS_CLOSE_MESSAGE_TOO_BIG = 1009` — server received a message over its `max_message_bytes` cap (RFC 6455 "Message Too Big").
354
355
 
355
356
  ### Transport modules
356
357
 
@@ -358,15 +359,35 @@ and `allow_fallback: boolean` (default `true`). Explicit
358
359
  - `actions/transports_ws.ts` — `frontend_websocket_rpc`; thin adapter over `WebsocketRpcConnection` (default impl: `FrontendWebsocketClient`). Answers inbound server→client requests: a built-in `peer_ping_responder` for `peer/ping` (zero-wiring liveness), any other request routed through `peer.receive` with the response sent back over the socket (the frontend half of the ActionPeer receive loop).
359
360
  - `actions/transports_ws_backend.ts` — `backend_websocket_rpc`; server-side WS with session tracking; satisfies `FilterableBroadcastTransport`. Server→client requests via `request_connection` (correlation registry in `actions/peer_request.ts`).
360
361
 
361
- `FrontendHttpTransport` synthesizes a JSON-RPC error envelope via
362
- `http_status_to_jsonrpc_error_code` on non-OK HTTP; DEV warns on drift
363
- between JSON-RPC error code and declared HTTP status.
362
+ `FrontendHttpTransport` on non-OK HTTP returns the body as-is when it's a
363
+ JSON-RPC error response for the request (same `id` compared as strings since GET sends it as a query param, or `null`) — the
364
+ status → code map is lossy (`queue_overflow` and `rate_limited` share 429), so
365
+ the server's code, message, and data must survive. Any other body gets an
366
+ envelope synthesized via `http_status_to_jsonrpc_error_code`. DEV warns when
367
+ `jsonrpc_error_code_to_http_status(code)` disagrees with the status, or when an
368
+ error envelope arrives with a 2xx.
364
369
 
365
370
  `FrontendWebsocketTransport` notification sends fail-fast when disconnected
366
371
  regardless of `queue` — `connection.send()` has no queue semantic, so
367
372
  buffering would masquerade as success at the rpc_client layer. Requests
368
373
  route via `queue`.
369
374
 
375
+ **WS message cap.** `DEFAULT_WS_MAX_MESSAGE_BYTES` (1 MiB, in
376
+ `actions/transports.ts`, equal to `DEFAULT_MAX_BODY_SIZE` and the twin of the
377
+ Rust spine's constant) bounds one message both ways. `register_action_ws`
378
+ (`max_message_bytes`, also on `WsEndpointSpec`) closes the socket with
379
+ `WS_CLOSE_MESSAGE_TOO_BIG` on a larger inbound message, before parsing — no
380
+ per-message reply, like the Rust spine. The check runs after the runtime
381
+ adapter has buffered the message, so it bounds dispatch work, not memory.
382
+ `FrontendWebsocketTransport`'s optional third argument
383
+ (`FrontendWebsocketTransportOptions.max_message_bytes`, same default) answers
384
+ an outbound request or notification whose JSON encoding exceeds it (in UTF-8
385
+ bytes) locally with an `invalid_request` error carrying
386
+ `data.reason: 'payload_too_large'` (`create_message_too_large_error`) and never
387
+ sends it — otherwise the close would fail every request in flight on the
388
+ socket. A consumer that raises the server cap passes the same value to the
389
+ client; `null` skips the client-side measurement.
390
+
370
391
  ### `BackendWebsocketTransport` — server-side WS state
371
392
 
372
393
  Three aligned maps keyed by `connection_id` (branded `Uuid`):
@@ -487,7 +508,7 @@ but `upgradeWebSocket` is missing. A factory returning `[]` does NOT trip
487
508
  the check, so feature-flag gated WS surfaces stay safe.
488
509
 
489
510
  `WsEndpointSpec` fields: `path`, `allowed_origins`, `actions`,
490
- `required_roles?`, `transport?`, `heartbeat?`, `artificial_delay?`,
511
+ `required_roles?`, `transport?`, `heartbeat?`, `artificial_delay?`, `max_message_bytes?`,
491
512
  `on_socket_open?`, `on_socket_close?`, `auth_guard?` (default `true`,
492
513
  deduped by reference identity via `WeakSet<BackendWebsocketTransport>`),
493
514
  `extra_audit_handlers?`.
@@ -604,7 +625,7 @@ individual protocol actions without an opt-out flag.
604
625
 
605
626
  ### Individual actions
606
627
 
607
- - **`heartbeat_action`** — `request_response`, `initiator: 'frontend'`, `auth: 'authenticated'`, `side_effects: false`, nullary input/output (`z.strictObject({})`). Handler is a stateless no-op echo. Client's activity-aware heartbeat timer fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; server's `register_action_ws` heartbeat tracker counts the incoming message as activity.
628
+ - **`heartbeat_action`** — `request_response`, `initiator: 'frontend'`, `auth: 'authenticated'`, `side_effects: false`, parameterless `z.void()` input (absent / `null` / `{}` params — the Rust `require_void_params` shapes), empty-object output. Handler is a stateless no-op echo. Client's activity-aware heartbeat timer fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; server's `register_action_ws` heartbeat tracker counts the incoming message as activity.
608
629
  - **`cancel_action`** — `remote_notification`, `initiator: 'frontend'`, `auth: null`, `side_effects: true`. Params: `CancelNotificationParams = z.strictObject({request_id: JsonrpcRequestId})`. **Handler is an empty stub** — cancel semantics are dispatcher-owned (`register_action_ws` has the `{request_id → AbortController}` map). Wire format is snake_case `cancel` + `{request_id}`, not MCP's `$/cancelRequest` + `{requestId}` — MCP adoption would happen at an MCP adapter's translation layer, not in the base transport.
609
630
  - **`peer_ping_action`** — `request_response`, `initiator: 'both'`, `auth: public`, `side_effects: false`. Input `PingActionInput = {nonce?, timeout_ms?}` (`.default({})`); output `PingResponse = {nonce, protocol_version}`. The client→server invocation drives the **server→client** direction: the handler calls `ctx.request_client('peer/ping', {nonce})`, awaits the echo, validates it against `PingResponse` + checks the nonce, and returns it. Over HTTP RPC (`ctx.request_client` absent) it refuses with `peer_no_transport`, so it's mounted on both the WS endpoint (via this bundle) and the HTTP RPC endpoint (the spine full mount). `timeout_ms` is clamped shorten-only. `data.reason` codes (`peer_timeout` / `peer_connection_gone` / `peer_no_transport` / `peer_too_many_in_flight` / `peer_ping_invalid_reply` / `peer_ping_nonce_mismatch`) + the `PingResponse` shape (whose `protocol_version` is `PEER_PROTOCOL_VERSION`) are the cross-impl wire contract (twins of the Rust `REASON_PEER_*`). The frontend half — `peer_ping_responder` (the echo a client sends back, wired into `FrontendWebsocketTransport`) — lives here too. `peer_ping_action` is a plain `RpcAction` literal (not `rpc_action(...)`), so the module stays free of the runtime `action_rpc.ts` import and `protocol_action_specs` doesn't drag the dispatch core into frontend bundles — same discipline as `heartbeat`/`cancel`. See `actions/peer_ping.ts`.
610
631
 
@@ -619,14 +640,23 @@ high-level shapes that span modules:
619
640
  - **39-variant discriminated union** — `ActionEventDataUnion<TMethod, TInput, TOutput>` across `kind` + `phase` + `step` (28 for `request_response`, 6 for `remote_notification`, 5 for `local_call`). Narrows `input` / `output` / `error` / `request` / `response` / `notification` / `progress` at each lifecycle point.
620
641
  - **Step transitions** — `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal. `validate_step_transition(from, to)` throws on illegal moves.
621
642
  - **Phase transitions** — chained: `send_request → receive_response`, `receive_request → send_response`; everything else terminal. `validate_phase_for_kind` + `validate_phase_transition` enforce.
622
- - **`ActionEvent.parse()`** — `initial → parsed` via `spec.input.safeParse`. Input validation failures **fail immediately** without routing through an error phase (client-side programming errors, not runtime conditions with handlers). Handler errors DO route through `send_error` / `receive_error`. On `receive_response` with error response, transitions to `receive_error` instead of failing.
643
+ - **`ActionEvent.parse()`** — `initial → parsed` via `spec.input.safeParse`. Input validation failures **fail immediately** without routing through an error phase (client-side programming errors, not runtime conditions with handlers). Handler errors DO route through `send_error` / `receive_error`. On `receive_response` with error response, transitions to `receive_error` instead of failing; a success response's `result` is validated against `spec.output` (always, not DEV-only — it's inbound data; undeclared keys are dropped rather than refused, so an older client survives an added response field) and `output` becomes the parsed value, or the event moves to `receive_error` with an `internal_error` carrying `data: {reason: ERROR_RESPONSE_OUTPUT_INVALID, validation_errors}` — a remote contract break, deliberately distinct from the `invalid_params` / `validation_error` of bad caller input.
644
+ - **Handler throws** — `ThrownJsonrpcError` keeps code/message/data; any other throw becomes `internal_error` carrying the thrown message. When the error answers a remote caller (`receive_request` / `send_response`), the raw message goes through `dev_only` like the server dispatch core.
623
645
  - **Protocol message creation is automatic** — transitioning `parsed → handling` on `send_request` materializes the outgoing `JsonrpcRequest` with a fresh `create_uuid()` id; on `send` (notification) it materializes the `JsonrpcNotification`.
624
646
 
625
647
  `ActionDispatcher` (`actions/action_dispatcher.ts`) is symmetric send + receive
626
648
  over a `Transports` registry and `ActionEventEnvironment`. `default_send_options`
627
649
  excludes `signal` deliberately — a shared signal would abort every subsequent
628
650
  call after the first trip. `transport_name` and `queue` can be defaulted here
629
- once to flip the dispatcher into client-authoritative mode.
651
+ once to flip the dispatcher into client-authoritative mode. Diagnostics (failed
652
+ sends, inbound notifications that fail to parse or whose handler throws, unknown
653
+ methods) go to `environment.log`; unset falls back to `console_action_log`
654
+ (warn/error to `console`, debug dropped) so failures are never silent, and
655
+ `log: null` opts out (`resolve_action_log`). An inbound request or notification
656
+ reads its params through the server dispatch core's normalizer
657
+ (`to_input_params`): for a `z.void()` input, absent, `null`, and `{}` alike are
658
+ the no-arg call; for any other input, absent or `null` params are `{}` (so an
659
+ all-optional object input accepts a call that omits them).
630
660
 
631
661
  **Naming.** `ActionDispatcher` is the (frontend) send/receive coordinator class.
632
662
  "ActionPeer" — unbackticked throughout the peer/ping + `request_client` docs — is
@@ -647,7 +677,11 @@ Ships three correctness primitives default-on:
647
677
 
648
678
  1. **Promise-based `request`** — auto-assigned monotonic id; pending map keyed by id; resolved via intercept on the message path. Rejects `ThrownJsonrpcError` with specific codes (`unauthenticated`, `request_cancelled`, `queue_overflow`, `service_unavailable`, `internal_error`, or the server's wire code verbatim). The transport catch block preserves `.code` exactly so `FrontendWebsocketTransport` never collapses to `internal_error`.
649
679
  2. **Durable queue** — `request()` calls while disconnected buffer up to `DEFAULT_QUEUE_MAX_SIZE = 100` and flush on reopen. Overflow rejects `queue_overflow`. Pass `{queue: false}` to reject immediately (used internally by the heartbeat — it must not fight the queue for the disconnect-detection slot). Raw `send(data)` is **drop-on-disconnect** by design (fire-and-forget notifications want that).
650
- 3. **Activity-aware heartbeat** — idles past `DEFAULT_HEARTBEAT_INTERVAL = 30_000` fire the shared `heartbeat` request. Receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000` closes with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT`. Tick runs at `interval / 2` so event-loop blockage pauses the timer itself.
680
+ 3. **Activity-aware heartbeat** — the shared `heartbeat` request fires when either direction has been idle past `DEFAULT_HEARTBEAT_INTERVAL = 30_000` (send-only silence would trip the server's detector, receive-only silence this client's). Receive-silence past the receive timeout closes with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT`. Tick runs at `interval / 2` so event-loop blockage pauses the timer itself — so a heartbeat can leave up to 1.5 × `interval` after the last receive, and `resolve_heartbeat_receive_timeout` raises the receive timeout (`DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000` unless given) to at least `HEARTBEAT_RECEIVE_TIMEOUT_MIN_FACTOR` (2) × `interval`. A long `interval` alone is therefore safe. At most one heartbeat is outstanding — an unanswered one is left to the receive timeout rather than repeated.
681
+
682
+ `last_send_time` / `last_receive_time` are reactive epoch-ms stamps of the most
683
+ recent frame each way — every frame, including heartbeats and the responses
684
+ `request` consumes — for diagnostics UI.
651
685
 
652
686
  Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`,
653
687
  capped at `DEFAULT_RECONNECT_DELAY_MAX`. `WS_CLOSE_SESSION_REVOKED` is
@@ -661,14 +695,6 @@ fields fall back to defaults, not "keep current"): `set_reconnect`,
661
695
  `socket_status_to_async_status(status, revoked)` collapses to fuz_util's
662
696
  4-way `AsyncStatus`.
663
697
 
664
- ### `RequestTracker` (`actions/request_tracker.svelte.ts`)
665
-
666
- Public utility — reactive pending-request state with timeouts.
667
- `SvelteMap` keyed by `JsonrpcRequestId`, default `request_timeout_ms = 120_000`.
668
- Used by transports that don't delegate pending correlation to a
669
- `WebsocketRpcConnection` (`FrontendWebsocketTransport` delegates to
670
- `FrontendWebsocketClient`'s own `#pending` map).
671
-
672
698
  ## RPC client (`actions/rpc_client.ts`)
673
699
 
674
700
  `create_rpc_client({peer, environment, actions?, transport_for_method?})` —
@@ -715,7 +741,7 @@ doesn't get probed as a thenable by `await`.
715
741
 
716
742
  ### Frontend factory (`actions/frontend_rpc_client.ts`)
717
743
 
718
- `create_frontend_rpc_client<TApi>({specs, path?, transports?, transport_for_method?, on_action_event?})`
744
+ `create_frontend_rpc_client<TApi>({specs, path?, transports?, transport_for_method?, on_action_event?, lookup_action_handler?, log?})`
719
745
  bundles `ActionRegistry + ActionEventEnvironment + Transports + ActionDispatcher +
720
746
  create_rpc_client + create_throwing_api` boilerplate every consumer
721
747
  repeats — plus the `lookup_action_handler: () => undefined` stub (frontend
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { JsonrpcMessageFromServerToClient, JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
10
10
  import { Transports, type TransportName, type TransportSendOptions } from './transports.js';
11
- import type { ActionEventEnvironment } from './action_event_types.js';
11
+ import { type ActionEventEnvironment } from './action_event_types.js';
12
12
  /**
13
13
  * Per-call options for `ActionDispatcher.send`. Extends `TransportSendOptions`
14
14
  * with `transport_name` for per-call transport selection. The peer-wide
@@ -1 +1 @@
1
- {"version":3,"file":"action_dispatcher.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAEN,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAU5B,OAAO,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,KAAK,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5F,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAOtE;;;;;;;GAOG;AACH,MAAM,WAAW,2BAA4B,SAAQ,oBAAoB;IACxE,cAAc,CAAC,EAAE,aAAa,CAAC;CAC/B;AAED,MAAM,WAAW,uBAAuB;IACvC,WAAW,EAAE,sBAAsB,CAAC;IAGpC,UAAU,CAAC,EAAE,UAAU,CAAC;IAKxB,oBAAoB,CAAC,EAAE,IAAI,CAAC,2BAA2B,EAAE,QAAQ,CAAC,CAAC;CACnE;AAED,qBAAa,gBAAgB;;IAC5B,QAAQ,CAAC,WAAW,EAAE,sBAAsB,CAAC;IAC7C,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAMhC,oBAAoB,EAAE,IAAI,CAAC,2BAA2B,EAAE,QAAQ,CAAC,CAAC;gBAEtD,OAAO,EAAE,uBAAuB;IAM5C;;;;;;;;OAQG;IAEG,IAAI,CACT,OAAO,EAAE,cAAc,EACvB,OAAO,CAAC,EAAE,2BAA2B,GACnC,OAAO,CAAC,sBAAsB,CAAC;IAC5B,IAAI,CACT,OAAO,EAAE,mBAAmB,EAC5B,OAAO,CAAC,EAAE,2BAA2B,GACnC,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA8CvC;;;;;;;OAOG;IACG,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,gCAAgC,GAAG,IAAI,CAAC;CAgIjF"}
1
+ {"version":3,"file":"action_dispatcher.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_dispatcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAEN,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAW5B,OAAO,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,KAAK,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5F,OAAO,EAEN,KAAK,sBAAsB,EAE3B,MAAM,yBAAyB,CAAC;AAOjC;;;;;;;GAOG;AACH,MAAM,WAAW,2BAA4B,SAAQ,oBAAoB;IACxE,cAAc,CAAC,EAAE,aAAa,CAAC;CAC/B;AAED,MAAM,WAAW,uBAAuB;IACvC,WAAW,EAAE,sBAAsB,CAAC;IAGpC,UAAU,CAAC,EAAE,UAAU,CAAC;IAKxB,oBAAoB,CAAC,EAAE,IAAI,CAAC,2BAA2B,EAAE,QAAQ,CAAC,CAAC;CACnE;AAED,qBAAa,gBAAgB;;IAC5B,QAAQ,CAAC,WAAW,EAAE,sBAAsB,CAAC;IAC7C,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAMhC,oBAAoB,EAAE,IAAI,CAAC,2BAA2B,EAAE,QAAQ,CAAC,CAAC;gBAEtD,OAAO,EAAE,uBAAuB;IAW5C;;;;;;;;OAQG;IAEG,IAAI,CACT,OAAO,EAAE,cAAc,EACvB,OAAO,CAAC,EAAE,2BAA2B,GACnC,OAAO,CAAC,sBAAsB,CAAC;IAC5B,IAAI,CACT,OAAO,EAAE,mBAAmB,EAC5B,OAAO,CAAC,EAAE,2BAA2B,GACnC,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA8CvC;;;;;;;OAOG;IACG,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,gCAAgC,GAAG,IAAI,CAAC;CA2HjF"}
@@ -9,8 +9,10 @@
9
9
  import { JsonrpcMessageFromClientToServer, JsonrpcMessageFromServerToClient, JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
10
10
  import { create_jsonrpc_error_response, create_jsonrpc_error_response_from_thrown, to_jsonrpc_message_id, is_jsonrpc_request, is_jsonrpc_notification } from '../http/jsonrpc_helpers.js';
11
11
  import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
12
+ import { to_input_params } from '../http/schema_helpers.js';
12
13
  import { create_action_event } from './action_event.js';
13
14
  import { Transports } from './transports.js';
15
+ import { resolve_action_log } from './action_event_types.js';
14
16
  export class ActionDispatcher {
15
17
  environment;
16
18
  transports;
@@ -24,27 +26,31 @@ export class ActionDispatcher {
24
26
  this.transports = options.transports ?? new Transports();
25
27
  this.default_send_options = options.default_send_options ?? {};
26
28
  }
29
+ /** Diagnostics sink — see `resolve_action_log`. */
30
+ get #log() {
31
+ return resolve_action_log(this.environment);
32
+ }
27
33
  async send(message, options) {
28
34
  try {
29
35
  const transport = this.transports.get_transport(options?.transport_name ?? this.default_send_options.transport_name);
30
36
  if (!transport) {
31
- this.environment.log?.error('[peer] send failed: no transport available');
37
+ this.#log?.error('[peer] send failed: no transport available');
32
38
  return create_jsonrpc_error_response(to_jsonrpc_message_id(message), jsonrpc_error_messages.service_unavailable('no transport available'));
33
39
  }
34
40
  const message_type = is_jsonrpc_request(message) ? 'request' : 'notification';
35
- this.environment.log?.debug(`[peer] send ${message_type}:`, message.method, `via ${transport.transport_name}`);
41
+ this.#log?.debug(`[peer] send ${message_type}:`, message.method, `via ${transport.transport_name}`);
36
42
  const result = await transport.send(message, {
37
43
  signal: options?.signal,
38
44
  queue: options?.queue ?? this.default_send_options.queue
39
45
  });
40
46
  if (result && 'error' in result) {
41
- this.environment.log?.error(`[peer] send ${message_type} failed:`, message.method, result.error.message);
47
+ this.#log?.error(`[peer] send ${message_type} failed:`, message.method, result.error.message);
42
48
  }
43
49
  return result;
44
50
  }
45
51
  catch (error) {
46
52
  // TODO add retry handling here?
47
- this.environment.log?.error('[peer] send unexpected error:', error);
53
+ this.#log?.error('[peer] send unexpected error:', error);
48
54
  return create_jsonrpc_error_response_from_thrown(to_jsonrpc_message_id(message), error);
49
55
  } // TODO finally?
50
56
  }
@@ -62,7 +68,7 @@ export class ActionDispatcher {
62
68
  return result;
63
69
  }
64
70
  catch (error) {
65
- this.environment.log?.error('[peer] receive unexpected error:', error);
71
+ this.#log?.error('[peer] receive unexpected error:', error);
66
72
  // Return appropriate error response based on the message
67
73
  return create_jsonrpc_error_response_from_thrown(to_jsonrpc_message_id(message), error);
68
74
  } // TODO finally?
@@ -82,13 +88,13 @@ export class ActionDispatcher {
82
88
  async #receive_request(request) {
83
89
  const spec = this.environment.lookup_action_spec(request.method);
84
90
  if (!spec) {
85
- this.environment.log?.warn(`[peer] receive request: method not found:`, request.method);
91
+ this.#log?.warn(`[peer] receive request: method not found:`, request.method);
86
92
  return create_jsonrpc_error_response(request.id, jsonrpc_error_messages.method_not_found(request.method));
87
93
  }
88
- this.environment.log?.debug(`[peer] receive request:`, request.method);
94
+ this.#log?.debug(`[peer] receive request:`, request.method);
89
95
  try {
90
96
  // Create action event in receive_request phase
91
- const event = create_action_event(this.environment, spec, request.params, 'receive_request');
97
+ const event = create_action_event(this.environment, spec, to_input_params(spec.input, request.params), 'receive_request');
92
98
  event.set_request(request);
93
99
  // Parse and handle
94
100
  await event.parse().handle_async();
@@ -105,7 +111,7 @@ export class ActionDispatcher {
105
111
  }
106
112
  // Check for terminal failure
107
113
  if (event.data.step === 'failed') {
108
- this.environment.log?.error(`[peer] receive request failed:`, request.method, event.data.error);
114
+ this.#log?.error(`[peer] receive request failed:`, request.method, event.data.error);
109
115
  return create_jsonrpc_error_response(request.id, event.data.error);
110
116
  }
111
117
  // Check if transitioned to error phase (send_error)
@@ -116,33 +122,33 @@ export class ActionDispatcher {
116
122
  return create_jsonrpc_error_response(request.id, event.data.error);
117
123
  }
118
124
  // Fallback for unexpected states
119
- this.environment.log?.error(`[peer] receive request: unexpected state:`, request.method, event.data);
125
+ this.#log?.error(`[peer] receive request: unexpected state:`, request.method, event.data);
120
126
  return create_jsonrpc_error_response(request.id, jsonrpc_error_messages.internal_error('unknown error'));
121
127
  }
122
128
  catch (error) {
123
- this.environment.log?.error(`[peer] receive request exception:`, request.method, error);
129
+ this.#log?.error(`[peer] receive request exception:`, request.method, error);
124
130
  return create_jsonrpc_error_response_from_thrown(request.id, error);
125
131
  }
126
132
  }
127
133
  async #receive_notification(notification) {
128
134
  const spec = this.environment.lookup_action_spec(notification.method);
129
135
  if (!spec) {
130
- this.environment.log?.warn(`[peer] receive notification: method not found:`, notification.method);
136
+ this.#log?.warn(`[peer] receive notification: method not found:`, notification.method);
131
137
  return;
132
138
  }
133
- this.environment.log?.debug(`[peer] receive notification:`, notification.method);
139
+ this.#log?.debug(`[peer] receive notification:`, notification.method);
134
140
  try {
135
141
  // Create action event in receive phase
136
- const event = create_action_event(this.environment, spec, notification.params, 'receive');
142
+ const event = create_action_event(this.environment, spec, to_input_params(spec.input, notification.params), 'receive');
137
143
  event.set_notification(notification);
138
144
  // Parse and handle
139
145
  await event.parse().handle_async();
140
146
  if (event.data.step === 'failed') {
141
- this.environment.log?.error(`[peer] receive notification failed:`, notification.method, event.data.error);
147
+ this.#log?.error(`[peer] receive notification failed:`, notification.method, event.data.error);
142
148
  }
143
149
  }
144
150
  catch (error) {
145
- this.environment.log?.error(`[peer] receive notification exception:`, notification.method, error);
151
+ this.#log?.error(`[peer] receive notification exception:`, notification.method, error);
146
152
  }
147
153
  }
148
154
  }
@@ -10,6 +10,12 @@ import type { ActionEventPhase, ActionSpecUnion } from './action_spec.js';
10
10
  import type { JsonrpcRequest, JsonrpcResponseOrError, JsonrpcNotification } from '../http/jsonrpc.js';
11
11
  import type { ActionEventEnvironment, ActionEventStep } from './action_event_types.js';
12
12
  import { type ActionEventDataUnion } from './action_event_data.js';
13
+ /**
14
+ * `error.data.reason` on the `internal_error` a client reports when a
15
+ * response's `result` fails `spec.output` — a remote bug, distinct from the
16
+ * `invalid_params` / `validation_error` a caller's own bad input produces.
17
+ */
18
+ export declare const ERROR_RESPONSE_OUTPUT_INVALID: "response_output_invalid";
13
19
  export type ActionEventChangeObserver<TMethod extends string = string> = (new_data: ActionEventDataUnion<TMethod>, old_data: ActionEventDataUnion<TMethod>, event: ActionEvent<TMethod>) => void;
14
20
  /**
15
21
  * Action event that manages the lifecycle of an action through its state machine.
@@ -48,7 +54,17 @@ export declare class ActionEvent<TMethod extends string = string, TPhase extends
48
54
  */
49
55
  set_data(new_data: ActionEventDataUnion<TMethod>): void;
50
56
  /**
51
- * Parse input data according to the action's schema.
57
+ * Parse the event's payload against the action's schemas — `spec.input`
58
+ * for outgoing and incoming calls, `spec.output` for a received response.
59
+ *
60
+ * A received response's `result` is validated like any other inbound data,
61
+ * except that keys `spec.output` doesn't declare are dropped rather than
62
+ * refused (`safe_parse_dropping_unknown_keys`). On success `output` becomes
63
+ * the parsed value (brands, defaults, and transforms applied); on any
64
+ * other mismatch the event moves to `receive_error` with an
65
+ * `internal_error` whose `data.reason` is `ERROR_RESPONSE_OUTPUT_INVALID`
66
+ * (the remote broke its contract — not the caller's input), the same path
67
+ * an error response takes.
52
68
  *
53
69
  * @returns `this` for chaining with `handle_async` / `handle_sync`
54
70
  * @mutates this - transitions step from `initial` to `parsed` (or to
@@ -65,7 +81,7 @@ export declare class ActionEvent<TMethod extends string = string, TPhase extends
65
81
  * @throws Error if called from a step other than `parsed` (or `failed`,
66
82
  * which no-ops). Handler-thrown `ThrownJsonrpcError` is caught and
67
83
  * routed through error phases; other throws are wrapped as
68
- * `internal_error`.
84
+ * `internal_error` carrying the thrown message (see `#to_handler_error`).
69
85
  */
70
86
  handle_async(): Promise<void>;
71
87
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"action_event.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_event.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAc,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAWtF,OAAO,KAAK,EACX,cAAc,EACd,sBAAsB,EACtB,mBAAmB,EAEnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AACvF,OAAO,EAAmB,KAAK,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAcpF,MAAM,MAAM,yBAAyB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,IAAI,CACxE,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,KAAK,EAAE,WAAW,CAAC,OAAO,CAAC,KACvB,IAAI,CAAC;AAEV;;GAEG;AACH,qBAAa,WAAW,CACvB,OAAO,SAAS,MAAM,GAAG,MAAM,EAC/B,MAAM,SAAS,gBAAgB,GAAG,gBAAgB,EAClD,KAAK,SAAS,eAAe,GAAG,eAAe;;IAK/C,QAAQ,CAAC,WAAW,EAAE,sBAAsB,CAAC;IAC7C;;;;;;OAMG;IACH,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC;IAErD,IAAI,IAAI,IAAI,oBAAoB,CAAC,OAAO,CAAC,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,KAAK,CAAA;KAAE,CAEzE;gBAGA,WAAW,EAAE,sBAAsB,EACnC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,oBAAoB,CAAC,OAAO,CAAC;IAOpC,MAAM,IAAI,oBAAoB,CAAC,OAAO,CAAC;IAMvC;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,EAAE,yBAAyB,CAAC,OAAO,CAAC,GAAG,MAAM,IAAI;IAKjE;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,GAAG,IAAI;IAUvD;;;;;;;OAOG;IACH,KAAK,IAAI,IAAI;IA8Cb;;;;;;;;;;OAUG;IAGG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IA0CnC;;;;;;;OAOG;IACH,WAAW,IAAI,IAAI;IAkCnB;;;;;;;;;;OAUG;IACH,UAAU,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI;IAezC,WAAW,IAAI,OAAO;IAItB,eAAe,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI;IAIxC,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI;IAQ1C,YAAY,CAAC,QAAQ,EAAE,sBAAsB,GAAG,IAAI;IAUpD,gBAAgB,CAAC,YAAY,EAAE,mBAAmB,GAAG,IAAI;CAyKzD;AAGD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAClE,aAAa,sBAAsB,EACnC,MAAM,eAAe,EACrB,OAAO,OAAO,EACd,gBAAgB,gBAAgB,KAC9B,WAAW,CAAC,OAAO,CAmBrB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAC5E,MAAM,oBAAoB,CAAC,OAAO,CAAC,EACnC,aAAa,sBAAsB,KACjC,WAAW,CAAC,OAAO,CAOrB,CAAC;AAIF,eAAO,MAAM,kBAAkB,GAC9B,UAAU,OAAO,EACjB,aAAa,sBAAsB,KACjC,WAGF,CAAC"}
1
+ {"version":3,"file":"action_event.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_event.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAAE,gBAAgB,EAAc,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAgBtF,OAAO,KAAK,EACX,cAAc,EACd,sBAAsB,EACtB,mBAAmB,EAGnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AACvF,OAAO,EAAmB,KAAK,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAapF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAC;AAGhF,MAAM,MAAM,yBAAyB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,IAAI,CACxE,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,KAAK,EAAE,WAAW,CAAC,OAAO,CAAC,KACvB,IAAI,CAAC;AAEV;;GAEG;AACH,qBAAa,WAAW,CACvB,OAAO,SAAS,MAAM,GAAG,MAAM,EAC/B,MAAM,SAAS,gBAAgB,GAAG,gBAAgB,EAClD,KAAK,SAAS,eAAe,GAAG,eAAe;;IAK/C,QAAQ,CAAC,WAAW,EAAE,sBAAsB,CAAC;IAC7C;;;;;;OAMG;IACH,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC;IAErD,IAAI,IAAI,IAAI,oBAAoB,CAAC,OAAO,CAAC,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,KAAK,CAAA;KAAE,CAEzE;gBAGA,WAAW,EAAE,sBAAsB,EACnC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,oBAAoB,CAAC,OAAO,CAAC;IAOpC,MAAM,IAAI,oBAAoB,CAAC,OAAO,CAAC;IAMvC;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,EAAE,yBAAyB,CAAC,OAAO,CAAC,GAAG,MAAM,IAAI;IAKjE;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,GAAG,IAAI;IAUvD;;;;;;;;;;;;;;;;;OAiBG;IACH,KAAK,IAAI,IAAI;IAiEb;;;;;;;;;;OAUG;IAGG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAsCnC;;;;;;;OAOG;IACH,WAAW,IAAI,IAAI;IA8BnB;;;;;;;;;;OAUG;IACH,UAAU,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI;IAezC,WAAW,IAAI,OAAO;IAItB,eAAe,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI;IAIxC,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI;IAQ1C,YAAY,CAAC,QAAQ,EAAE,sBAAsB,GAAG,IAAI;IAUpD,gBAAgB,CAAC,YAAY,EAAE,mBAAmB,GAAG,IAAI;CAgMzD;AAGD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAClE,aAAa,sBAAsB,EACnC,MAAM,eAAe,EACrB,OAAO,OAAO,EACd,gBAAgB,gBAAgB,KAC9B,WAAW,CAAC,OAAO,CAmBrB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAC5E,MAAM,oBAAoB,CAAC,OAAO,CAAC,EACnC,aAAa,sBAAsB,KACjC,WAAW,CAAC,OAAO,CAOrB,CAAC;AAIF,eAAO,MAAM,kBAAkB,GAC9B,UAAU,OAAO,EACjB,aAAa,sBAAsB,KACjC,WAGF,CAAC"}
@@ -8,10 +8,18 @@
8
8
  */
9
9
  import { z } from 'zod';
10
10
  import { create_uuid } from '@fuzdev/fuz_util/id.ts';
11
- import { create_jsonrpc_request, create_jsonrpc_response, create_jsonrpc_error_response, create_jsonrpc_notification, to_jsonrpc_params, to_jsonrpc_result, is_jsonrpc_error_response } from '../http/jsonrpc_helpers.js';
12
- import { jsonrpc_error_messages, ThrownJsonrpcError } from '../http/jsonrpc_errors.js';
11
+ import { to_error_message } from '@fuzdev/fuz_util/error.ts';
12
+ import { create_jsonrpc_request, create_jsonrpc_response, create_jsonrpc_error_response, create_jsonrpc_notification, to_jsonrpc_params, is_jsonrpc_error_response } from '../http/jsonrpc_helpers.js';
13
+ import { dev_only, jsonrpc_error_messages, ThrownJsonrpcError, UNKNOWN_ERROR_MESSAGE } from '../http/jsonrpc_errors.js';
14
+ import { safe_parse_dropping_unknown_keys } from '../http/schema_helpers.js';
13
15
  import { ActionEventData } from './action_event_data.js';
14
16
  import { validate_step_transition, validate_phase_transition, should_validate_output, is_action_complete, create_initial_data, get_initial_phase, is_request_response, is_send_request_with_parsed_input, is_notification_send_with_parsed_input } from './action_event_helpers.js';
17
+ /**
18
+ * `error.data.reason` on the `internal_error` a client reports when a
19
+ * response's `result` fails `spec.output` — a remote bug, distinct from the
20
+ * `invalid_params` / `validation_error` a caller's own bad input produces.
21
+ */
22
+ export const ERROR_RESPONSE_OUTPUT_INVALID = 'response_output_invalid';
15
23
  /**
16
24
  * Action event that manages the lifecycle of an action through its state machine.
17
25
  */
@@ -65,7 +73,17 @@ export class ActionEvent {
65
73
  }
66
74
  }
67
75
  /**
68
- * Parse input data according to the action's schema.
76
+ * Parse the event's payload against the action's schemas — `spec.input`
77
+ * for outgoing and incoming calls, `spec.output` for a received response.
78
+ *
79
+ * A received response's `result` is validated like any other inbound data,
80
+ * except that keys `spec.output` doesn't declare are dropped rather than
81
+ * refused (`safe_parse_dropping_unknown_keys`). On success `output` becomes
82
+ * the parsed value (brands, defaults, and transforms applied); on any
83
+ * other mismatch the event moves to `receive_error` with an
84
+ * `internal_error` whose `data.reason` is `ERROR_RESPONSE_OUTPUT_INVALID`
85
+ * (the remote broke its contract — not the caller's input), the same path
86
+ * an error response takes.
69
87
  *
70
88
  * @returns `this` for chaining with `handle_async` / `handle_sync`
71
89
  * @mutates this - transitions step from `initial` to `parsed` (or to
@@ -87,11 +105,25 @@ export class ActionEvent {
87
105
  this.#fail(this.#data.response.error);
88
106
  return this;
89
107
  }
90
- // Input already validated in predecessor phase — skip re-parsing
91
- if (this.#data.kind === 'request_response' &&
92
- (this.#data.phase === 'receive_response' || this.#data.phase === 'send_response')) {
93
- this.#transition_step('parsed');
94
- return this;
108
+ if (this.#data.kind === 'request_response') {
109
+ // The remote's result is untrusted — validate it like inbound input,
110
+ // tolerating keys a newer remote added (`safe_parse_dropping_unknown_keys`).
111
+ // Input was already validated in the predecessor phase.
112
+ if (this.#data.phase === 'receive_response') {
113
+ const parsed = safe_parse_dropping_unknown_keys(this.spec.output, this.#data.output);
114
+ if (parsed.success) {
115
+ this.#transition_step('parsed', { output: parsed.data });
116
+ }
117
+ else {
118
+ this.#transition_to_error_phase('receive_error', jsonrpc_error_messages.internal_error(`response failed output validation for ${this.spec.method}: ${z.prettifyError(parsed.error)}`, { reason: ERROR_RESPONSE_OUTPUT_INVALID, validation_errors: parsed.error.issues }));
119
+ }
120
+ return this;
121
+ }
122
+ // Output was validated when the handler completed in `receive_request`.
123
+ if (this.#data.phase === 'send_response') {
124
+ this.#transition_step('parsed');
125
+ return this;
126
+ }
95
127
  }
96
128
  const parsed = this.spec.input.safeParse(this.#data.input);
97
129
  if (parsed.success) {
@@ -117,7 +149,7 @@ export class ActionEvent {
117
149
  * @throws Error if called from a step other than `parsed` (or `failed`,
118
150
  * which no-ops). Handler-thrown `ThrownJsonrpcError` is caught and
119
151
  * routed through error phases; other throws are wrapped as
120
- * `internal_error`.
152
+ * `internal_error` carrying the thrown message (see `#to_handler_error`).
121
153
  */
122
154
  // TODO add timeout support
123
155
  // TODO add cancellation support
@@ -139,10 +171,7 @@ export class ActionEvent {
139
171
  this.#complete_handling(result);
140
172
  }
141
173
  catch (error) {
142
- // Preserve ThrownJsonrpcError structure, wrap others as internal_error
143
- const error_json = error instanceof ThrownJsonrpcError
144
- ? { code: error.code, message: error.message, data: error.data }
145
- : jsonrpc_error_messages.internal_error('unknown error');
174
+ const error_json = this.#to_handler_error(error);
146
175
  // If we're already in an error phase, transition to failed
147
176
  // Otherwise, transition to appropriate error phase
148
177
  if (this.#data.phase === 'send_error' || this.#data.phase === 'receive_error') {
@@ -189,10 +218,7 @@ export class ActionEvent {
189
218
  this.#complete_handling(result);
190
219
  }
191
220
  catch (error) {
192
- // Preserve ThrownJsonrpcError structure, wrap others as internal_error
193
- const error_json = error instanceof ThrownJsonrpcError
194
- ? { code: error.code, message: error.message, data: error.data }
195
- : jsonrpc_error_messages.internal_error('unknown error');
221
+ const error_json = this.#to_handler_error(error);
196
222
  this.#fail(error_json);
197
223
  }
198
224
  }
@@ -260,6 +286,23 @@ export class ActionEvent {
260
286
  #fail(error) {
261
287
  this.#transition_step('failed', { error });
262
288
  }
289
+ /**
290
+ * Normalize a handler throw. `ThrownJsonrpcError` keeps its code, message,
291
+ * and data; anything else becomes `internal_error` with the thrown message.
292
+ * When the error answers a remote caller (`receive_request` /
293
+ * `send_response`) the raw message is kept only in development — the same
294
+ * `dev_only` gate the server dispatch uses, so paths or secrets in an
295
+ * exception message never cross the wire in production.
296
+ */
297
+ #to_handler_error(error) {
298
+ if (error instanceof ThrownJsonrpcError) {
299
+ return { code: error.code, message: error.message, data: error.data };
300
+ }
301
+ const message = to_error_message(error) || UNKNOWN_ERROR_MESSAGE;
302
+ const answers_remote = this.#data.kind === 'request_response' &&
303
+ (this.#data.phase === 'receive_request' || this.#data.phase === 'send_response');
304
+ return jsonrpc_error_messages.internal_error(answers_remote ? dev_only(message) : message);
305
+ }
263
306
  /**
264
307
  * Determine which error phase to transition to based on current phase.
265
308
  */
@@ -371,8 +414,9 @@ export class ActionEvent {
371
414
  if (this.#data.error) {
372
415
  return create_jsonrpc_error_response(this.#data.request.id, this.#data.error);
373
416
  }
374
- const result = to_jsonrpc_result(this.#data.output);
375
- return create_jsonrpc_response(this.#data.request.id, result);
417
+ // any JSON value is a valid result; `undefined` (a handler that returned
418
+ // nothing) has no JSON form, so it goes out as `null`
419
+ return create_jsonrpc_response(this.#data.request.id, (this.#data.output ?? null));
376
420
  }
377
421
  }
378
422
  // TODO not sure about this helper's design/location (should it be internal to the class constructor? a static method?)
@@ -26,6 +26,28 @@ export interface ActionEventEnvironment {
26
26
  readonly executor: ActionExecutor;
27
27
  lookup_action_handler: (method: string, phase: ActionEventPhase) => ((event: any) => any) | undefined;
28
28
  lookup_action_spec: (method: string) => ActionSpecUnion | undefined;
29
+ /**
30
+ * Where dispatch diagnostics go — failed sends, rejected or failing inbound
31
+ * notifications and requests. Unset falls back to `console_action_log`;
32
+ * `null` silences them.
33
+ */
29
34
  readonly log?: Logger | null;
30
35
  }
36
+ /** The logging surface the action dispatch path uses. */
37
+ export type ActionLog = Pick<Logger, 'error' | 'warn' | 'debug'>;
38
+ /**
39
+ * Fallback log for environments that leave `log` unset: warnings and errors
40
+ * go to `console`, so a notification that fails to parse or a handler that
41
+ * throws is visible rather than silently dropped; per-message debug chatter
42
+ * is discarded. A plain `console` adapter rather than a `Logger` so it's safe
43
+ * in any runtime, the browser included.
44
+ */
45
+ export declare const console_action_log: ActionLog;
46
+ /**
47
+ * Resolve the log an environment's dispatch diagnostics go to.
48
+ *
49
+ * @returns the environment's `log`, `console_action_log` when it's unset, or
50
+ * `null` when it's explicitly `null`
51
+ */
52
+ export declare const resolve_action_log: (environment: ActionEventEnvironment) => ActionLog | null;
31
53
  //# sourceMappingURL=action_event_types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"action_event_types.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_event_types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEtF,eAAO,MAAM,cAAc;;;EAAkC,CAAC;AAC9D,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,eAAO,MAAM,eAAe;;;;;;EAAiE,CAAC;AAC9F,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAS9D,eAAO,MAAM,6BAA6B,EAAE,MAAM,CACjD,eAAe,EACf,aAAa,CAAC,eAAe,CAAC,CAO9B,CAAC;AAEF,eAAO,MAAM,0BAA0B,EAAE,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,gBAAgB,CAAC,CAW1F,CAAC;AAEF,eAAO,MAAM,8BAA8B,EAAE,MAAM,CAAC,gBAAgB,EAAE,gBAAgB,GAAG,IAAI,CAU5F,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;IAClC,qBAAqB,EAAE,CACtB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,gBAAgB,KACnB,CAAC,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAC,GAAG,SAAS,CAAC;IACvC,kBAAkB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,eAAe,GAAG,SAAS,CAAC;IACpE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B"}
1
+ {"version":3,"file":"action_event_types.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_event_types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEtF,eAAO,MAAM,cAAc;;;EAAkC,CAAC;AAC9D,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,eAAO,MAAM,eAAe;;;;;;EAAiE,CAAC;AAC9F,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAS9D,eAAO,MAAM,6BAA6B,EAAE,MAAM,CACjD,eAAe,EACf,aAAa,CAAC,eAAe,CAAC,CAO9B,CAAC;AAEF,eAAO,MAAM,0BAA0B,EAAE,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,gBAAgB,CAAC,CAW1F,CAAC;AAEF,eAAO,MAAM,8BAA8B,EAAE,MAAM,CAAC,gBAAgB,EAAE,gBAAgB,GAAG,IAAI,CAU5F,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;IAClC,qBAAqB,EAAE,CACtB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,gBAAgB,KACnB,CAAC,CAAC,KAAK,EAAE,GAAG,KAAK,GAAG,CAAC,GAAG,SAAS,CAAC;IACvC,kBAAkB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,eAAe,GAAG,SAAS,CAAC;IACpE;;;;OAIG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,yDAAyD;AACzD,MAAM,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;AAEjE;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,EAAE,SAIhC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,aAAa,sBAAsB,KAAG,SAAS,GAAG,IAChB,CAAC"}
@@ -42,3 +42,22 @@ export const action_event_phase_transitions = {
42
42
  receive: null,
43
43
  execute: null
44
44
  };
45
+ /**
46
+ * Fallback log for environments that leave `log` unset: warnings and errors
47
+ * go to `console`, so a notification that fails to parse or a handler that
48
+ * throws is visible rather than silently dropped; per-message debug chatter
49
+ * is discarded. A plain `console` adapter rather than a `Logger` so it's safe
50
+ * in any runtime, the browser included.
51
+ */
52
+ export const console_action_log = {
53
+ error: (...args) => console.error(...args),
54
+ warn: (...args) => console.warn(...args),
55
+ debug: () => { }
56
+ };
57
+ /**
58
+ * Resolve the log an environment's dispatch diagnostics go to.
59
+ *
60
+ * @returns the environment's `log`, `console_action_log` when it's unset, or
61
+ * `null` when it's explicitly `null`
62
+ */
63
+ export const resolve_action_log = (environment) => environment.log === undefined ? console_action_log : environment.log;
@@ -26,7 +26,9 @@
26
26
  * - **JSON-RPC §4.2 wire validity** — `request_response` specs whose
27
27
  * handler will reach the dispatch map must not use `z.null()` for
28
28
  * input (the wire format forbids `"params": null`; use `z.void()`
29
- * for parameterless methods).
29
+ * for parameterless methods), nor `z.void()` for output (a response
30
+ * always carries a `result`, which clients validate against
31
+ * `spec.output`; use `z.null()` for methods with nothing to return).
30
32
  * - **Duplicate method names** — JSON-RPC keys on `method`, so every
31
33
  * spec in the array must declare a unique `method` regardless of
32
34
  * kind / handler presence.
@@ -56,7 +58,7 @@ export interface ActionRegistryCompileResult {
56
58
  *
57
59
  * @param actions - polymorphic action array; HTTP RPC passes `RpcAction[]` (narrower), WebSocket passes `Action[]` (kind-polymorphic — handler-less notification specs are accepted)
58
60
  * @param ctx_label - per-spec error-message prefix, e.g. `'RPC action'` or `'WS action'`. Combined with the spec method as `${ctx_label} "${method}"`.
59
- * @throws Error on biconditional violation, rate-limit/account-axis mismatch, JSON-RPC null-input, or duplicate method.
61
+ * @throws Error on biconditional violation, rate-limit/account-axis mismatch, JSON-RPC null-input or void-output, or duplicate method.
60
62
  */
61
63
  export declare const compile_action_registry: (actions: ReadonlyArray<Action>, ctx_label: string) => ActionRegistryCompileResult;
62
64
  //# sourceMappingURL=compile_action_registry.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"compile_action_registry.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/compile_action_registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAIjD,oDAAoD;AACpD,MAAM,WAAW,2BAA2B;IAC3C;;;;;OAKG;IACH,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;CACnC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GACnC,SAAS,aAAa,CAAC,MAAM,CAAC,EAC9B,WAAW,MAAM,KACf,2BA6CF,CAAC"}
1
+ {"version":3,"file":"compile_action_registry.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/compile_action_registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAIjD,oDAAoD;AACpD,MAAM,WAAW,2BAA2B;IAC3C;;;;;OAKG;IACH,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;CACnC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GACnC,SAAS,aAAa,CAAC,MAAM,CAAC,EAC9B,WAAW,MAAM,KACf,2BAoDF,CAAC"}
@@ -26,7 +26,9 @@
26
26
  * - **JSON-RPC §4.2 wire validity** — `request_response` specs whose
27
27
  * handler will reach the dispatch map must not use `z.null()` for
28
28
  * input (the wire format forbids `"params": null`; use `z.void()`
29
- * for parameterless methods).
29
+ * for parameterless methods), nor `z.void()` for output (a response
30
+ * always carries a `result`, which clients validate against
31
+ * `spec.output`; use `z.null()` for methods with nothing to return).
30
32
  * - **Duplicate method names** — JSON-RPC keys on `method`, so every
31
33
  * spec in the array must declare a unique `method` regardless of
32
34
  * kind / handler presence.
@@ -39,14 +41,14 @@
39
41
  * @module
40
42
  */
41
43
  import { assert_route_auth_acting_biconditional } from '../http/auth_shape.js';
42
- import { is_null_schema } from '../http/schema_helpers.js';
44
+ import { is_null_schema, is_void_schema } from '../http/schema_helpers.js';
43
45
  /**
44
46
  * Validate registry-time invariants and build the dispatcher's
45
47
  * method → action lookup.
46
48
  *
47
49
  * @param actions - polymorphic action array; HTTP RPC passes `RpcAction[]` (narrower), WebSocket passes `Action[]` (kind-polymorphic — handler-less notification specs are accepted)
48
50
  * @param ctx_label - per-spec error-message prefix, e.g. `'RPC action'` or `'WS action'`. Combined with the spec method as `${ctx_label} "${method}"`.
49
- * @throws Error on biconditional violation, rate-limit/account-axis mismatch, JSON-RPC null-input, or duplicate method.
51
+ * @throws Error on biconditional violation, rate-limit/account-axis mismatch, JSON-RPC null-input or void-output, or duplicate method.
50
52
  */
51
53
  export const compile_action_registry = (actions, ctx_label) => {
52
54
  const action_map = new Map();
@@ -77,6 +79,9 @@ export const compile_action_registry = (actions, ctx_label) => {
77
79
  if (is_null_schema(spec.input)) {
78
80
  throw new Error(`${ctx} uses z.null() for input — JSON-RPC 2.0 §4.2 forbids "params": null on the wire. Use z.void() for parameterless methods.`);
79
81
  }
82
+ if (is_void_schema(spec.output)) {
83
+ throw new Error(`${ctx} uses z.void() for output — a JSON-RPC response always carries a result, and clients validate it against spec.output, so no response could match. Use z.null() for methods with nothing to return.`);
84
+ }
80
85
  action_map.set(spec.method, { spec, handler: action.handler });
81
86
  }
82
87
  }