@fuzdev/fuz_app 0.113.0 → 0.115.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.
- package/dist/actions/CLAUDE.md +46 -20
- package/dist/actions/action_dispatcher.d.ts +1 -1
- package/dist/actions/action_dispatcher.d.ts.map +1 -1
- package/dist/actions/action_dispatcher.js +22 -16
- package/dist/actions/action_event.d.ts +18 -2
- package/dist/actions/action_event.d.ts.map +1 -1
- package/dist/actions/action_event.js +63 -19
- package/dist/actions/action_event_types.d.ts +22 -0
- package/dist/actions/action_event_types.d.ts.map +1 -1
- package/dist/actions/action_event_types.js +19 -0
- package/dist/actions/compile_action_registry.d.ts +4 -2
- package/dist/actions/compile_action_registry.d.ts.map +1 -1
- package/dist/actions/compile_action_registry.js +8 -3
- package/dist/actions/frontend_rpc_client.d.ts +6 -0
- package/dist/actions/frontend_rpc_client.d.ts.map +1 -1
- package/dist/actions/frontend_rpc_client.js +2 -1
- package/dist/actions/heartbeat.d.ts +8 -4
- package/dist/actions/heartbeat.d.ts.map +1 -1
- package/dist/actions/heartbeat.js +8 -4
- package/dist/actions/peer_ping.d.ts.map +1 -1
- package/dist/actions/peer_ping.js +3 -1
- package/dist/actions/perform_action.d.ts +1 -1
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +6 -4
- package/dist/actions/register_action_ws.d.ts +13 -0
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +23 -2
- package/dist/actions/rpc_client.d.ts +1 -1
- package/dist/actions/rpc_client.d.ts.map +1 -1
- package/dist/actions/rpc_client.js +2 -1
- package/dist/actions/socket.svelte.d.ts +53 -10
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +84 -23
- package/dist/actions/transports.d.ts +24 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +30 -0
- package/dist/actions/transports_http.d.ts +11 -4
- package/dist/actions/transports_http.d.ts.map +1 -1
- package/dist/actions/transports_http.js +63 -18
- package/dist/actions/transports_ws.d.ts +28 -3
- package/dist/actions/transports_ws.d.ts.map +1 -1
- package/dist/actions/transports_ws.js +33 -4
- package/dist/actions/ws_endpoint_spec.d.ts +7 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +52 -8
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +4 -3
- package/dist/auth/audit_log_schema.d.ts +3 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +13 -3
- package/dist/auth/cell_action_specs.js +1 -1
- package/dist/auth/cell_actions.d.ts.map +1 -1
- package/dist/auth/cell_actions.js +33 -4
- package/dist/auth/cell_authorize.d.ts +3 -0
- package/dist/auth/cell_authorize.d.ts.map +1 -1
- package/dist/auth/cell_authorize.js +7 -4
- package/dist/auth/request_context.d.ts +12 -3
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +12 -3
- package/dist/auth/role_grant_offer_action_specs.d.ts +30 -5
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +53 -17
- package/dist/auth/role_grant_offer_actions.d.ts +23 -12
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +132 -38
- package/dist/auth/role_grant_offer_queries.d.ts +36 -0
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +76 -0
- package/dist/auth/role_schema.d.ts +12 -0
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +12 -0
- package/dist/auth/self_service_role_actions.d.ts +14 -0
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +25 -1
- package/dist/http/CLAUDE.md +7 -3
- package/dist/http/jsonrpc_helpers.d.ts +0 -5
- package/dist/http/jsonrpc_helpers.d.ts.map +1 -1
- package/dist/http/jsonrpc_helpers.js +0 -13
- package/dist/http/schema_helpers.d.ts +56 -0
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +91 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +1 -0
- package/dist/server/serve_fact_route.d.ts +3 -1
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +7 -4
- package/dist/testing/CLAUDE.md +29 -16
- package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_gated_create.js +23 -1
- package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/peer_ping_ws.js +18 -0
- package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_participation.js +34 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +12 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +13 -1
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +9 -7
- package/dist/testing/cross_backend/ws_round_trip.d.ts +11 -3
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +74 -12
- package/dist/testing/entities.d.ts +6 -3
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +4 -1
- package/dist/testing/transports/ws_client.d.ts +6 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +6 -1
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +5 -0
- package/dist/ui/BootstrapForm.svelte +14 -10
- package/dist/ui/BootstrapForm.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +19 -7
- package/dist/ui/LoginForm.svelte +8 -4
- package/dist/ui/LoginForm.svelte.d.ts.map +1 -1
- package/dist/ui/RoleGrantOfferForm.svelte +29 -14
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts +2 -3
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -1
- package/dist/ui/SignupForm.svelte +14 -13
- package/dist/ui/form_state.svelte.d.ts +24 -11
- package/dist/ui/form_state.svelte.d.ts.map +1 -1
- package/dist/ui/form_state.svelte.js +38 -10
- package/dist/ui/role_grant_offers_state.svelte.d.ts +25 -15
- package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
- package/dist/ui/role_grant_offers_state.svelte.js +7 -1
- package/package.json +2 -22
- package/dist/actions/request_tracker.svelte.d.ts +0 -77
- package/dist/actions/request_tracker.svelte.d.ts.map +0 -1
- package/dist/actions/request_tracker.svelte.js +0 -169
package/dist/actions/CLAUDE.md
CHANGED
|
@@ -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 `
|
|
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`
|
|
362
|
-
`
|
|
363
|
-
|
|
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`,
|
|
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** —
|
|
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
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
136
|
+
this.#log?.warn(`[peer] receive notification: method not found:`, notification.method);
|
|
131
137
|
return;
|
|
132
138
|
}
|
|
133
|
-
this
|
|
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
|
|
147
|
+
this.#log?.error(`[peer] receive notification failed:`, notification.method, event.data.error);
|
|
142
148
|
}
|
|
143
149
|
}
|
|
144
150
|
catch (error) {
|
|
145
|
-
this
|
|
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
|
|
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;
|
|
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 {
|
|
12
|
-
import {
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
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
|
}
|