@fuzdev/fuz_app 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared type surface for the action system — context, handler, composable Action tuple.
3
+ *
4
+ * These types sit above `action_spec.ts` (pure Zod schemas) and below the
5
+ * dispatchers (`register_action_ws.ts`, `action_rpc.ts`). Extracted so the
6
+ * shared composable fuz_app actions (e.g. `heartbeat_action`) can name them
7
+ * without pulling in server-only modules.
8
+ *
9
+ * @module
10
+ */
11
+ import type { JsonrpcRequestId } from '../http/jsonrpc.js';
12
+ import type { ActionSpecUnion } from './action_spec.js';
13
+ /**
14
+ * Minimum per-request context every server-side WS handler receives.
15
+ *
16
+ * Consumers extend this with domain-specific fields via the dispatcher's
17
+ * `extend_context` option. Mirrors the HTTP-side `ActionContext` and Rust's
18
+ * `Ctx<'a>` shape (`request_id` + `NotifyFn` + `CancellationToken`).
19
+ */
20
+ export interface BaseHandlerContext {
21
+ /** JSON-RPC envelope request id — echoed back on the response. */
22
+ request_id: JsonrpcRequestId;
23
+ /**
24
+ * Send a request-scoped JSON-RPC notification to the originating socket.
25
+ * Not a broadcast — the message only reaches the client whose request
26
+ * triggered this handler.
27
+ */
28
+ notify: (method: string, params: unknown) => void;
29
+ /** Fires on socket close; streaming handlers poll for early termination. */
30
+ signal: AbortSignal;
31
+ }
32
+ /**
33
+ * Handler signature — receives validated input and per-request context.
34
+ *
35
+ * Named to disambiguate from `actions/action_rpc.ts`'s `ActionHandler`
36
+ * (HTTP-side, `ActionContext` + two generic slots). The WS variant is
37
+ * single-slotted on the context and returns `unknown`.
38
+ */
39
+ export type WsActionHandler<TCtx extends BaseHandlerContext = BaseHandlerContext> = (input: unknown, ctx: TCtx) => unknown;
40
+ /**
41
+ * A spec paired with its optional handler — the composable unit passed to
42
+ * {@link register_action_ws} and {@link create_rpc_client}. The server uses
43
+ * both fields; the client reads only {@link spec} (the {@link handler} is
44
+ * ignored, harmless). Shared fuz_app primitives (e.g. `heartbeat_action`)
45
+ * export a complete tuple so consumers spread them into both sides'
46
+ * `actions` array without inventing per-repo ping plumbing.
47
+ *
48
+ * Left open for future fields (`rate_limit`, ACL, middleware hooks) so
49
+ * additions attach to the action itself instead of scattering across
50
+ * parallel arrays.
51
+ */
52
+ export interface Action<TCtx extends BaseHandlerContext = BaseHandlerContext> {
53
+ spec: ActionSpecUnion;
54
+ /** Server-side handler. Ignored by the client. Omit for client-only specs. */
55
+ handler?: WsActionHandler<TCtx>;
56
+ }
57
+ //# sourceMappingURL=action_types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"action_types.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,kBAAkB,CAAC;AAEtD;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IAClC,kEAAkE;IAClE,UAAU,EAAE,gBAAgB,CAAC;IAC7B;;;;OAIG;IACH,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD,4EAA4E;IAC5E,MAAM,EAAE,WAAW,CAAC;CACpB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,CAAC,IAAI,SAAS,kBAAkB,GAAG,kBAAkB,IAAI,CACnF,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,IAAI,KACL,OAAO,CAAC;AAEb;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,MAAM,CAAC,IAAI,SAAS,kBAAkB,GAAG,kBAAkB;IAC3E,IAAI,EAAE,eAAe,CAAC;IACtB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,eAAe,CAAC,IAAI,CAAC,CAAC;CAChC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared type surface for the action system — context, handler, composable Action tuple.
3
+ *
4
+ * These types sit above `action_spec.ts` (pure Zod schemas) and below the
5
+ * dispatchers (`register_action_ws.ts`, `action_rpc.ts`). Extracted so the
6
+ * shared composable fuz_app actions (e.g. `heartbeat_action`) can name them
7
+ * without pulling in server-only modules.
8
+ *
9
+ * @module
10
+ */
11
+ export {};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared heartbeat action — the first composable fuz_app primitive carrying
3
+ * both a spec and a handler in one tuple. Consumers spread
4
+ * {@link heartbeat_action} into both the server's and the client's `actions`
5
+ * array so disconnect detection works identically across every repo without
6
+ * per-consumer ping plumbing.
7
+ *
8
+ * The client's activity-aware heartbeat timer (in
9
+ * `FrontendWebsocketClient`) issues a `heartbeat` request whenever the
10
+ * connection has been idle for its configured interval; server-side the
11
+ * dispatcher tracks receive time, so incoming heartbeats keep the socket
12
+ * alive without any handler-level state.
13
+ *
14
+ * Nullary input/output today. `{client_ts, server_ts}` fields can be added
15
+ * later if clock-skew telemetry ever matters — the {@link Action} container
16
+ * is open for additions without churning consumer call sites.
17
+ *
18
+ * @module
19
+ */
20
+ import { z } from 'zod';
21
+ import type { Action } from './action_types.js';
22
+ /** Method name on the wire — shared across every fuz_app consumer. */
23
+ export declare const HEARTBEAT_METHOD = "heartbeat";
24
+ /**
25
+ * `ActionSpec` for the shared heartbeat. `authenticated` auth — upgrade-time
26
+ * auth has already admitted the socket; heartbeats don't need role gating.
27
+ * `side_effects: false` keeps it orthogonal to state changes.
28
+ */
29
+ export declare const heartbeat_action_spec: {
30
+ method: string;
31
+ initiator: "both" | "frontend" | "backend";
32
+ side_effects: boolean;
33
+ input: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
34
+ output: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
35
+ description: string;
36
+ kind: "request_response";
37
+ auth: "authenticated" | "keeper" | "public" | {
38
+ role: string;
39
+ };
40
+ async: true;
41
+ streams?: string | undefined;
42
+ };
43
+ /** Handler — nullary echo. Stateless, suitable for high-frequency pings. */
44
+ export declare const heartbeat_handler: () => Record<string, never>;
45
+ /**
46
+ * Composable tuple — spread into the server's `actions` array for dispatch
47
+ * and into the client's `actions` array so `create_rpc_client` types
48
+ * `app.api.heartbeat()` against the shared spec.
49
+ */
50
+ export declare const heartbeat_action: Action;
51
+ //# sourceMappingURL=heartbeat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"heartbeat.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/heartbeat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAGtB,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,mBAAmB,CAAC;AAE9C,sEAAsE;AACtE,eAAO,MAAM,gBAAgB,cAAc,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;CAUhC,CAAC;AAEH,4EAA4E;AAC5E,eAAO,MAAM,iBAAiB,QAAO,MAAM,CAAC,MAAM,EAAE,KAAK,CAAS,CAAC;AAEnE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAG9B,CAAC"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared heartbeat action — the first composable fuz_app primitive carrying
3
+ * both a spec and a handler in one tuple. Consumers spread
4
+ * {@link heartbeat_action} into both the server's and the client's `actions`
5
+ * array so disconnect detection works identically across every repo without
6
+ * per-consumer ping plumbing.
7
+ *
8
+ * The client's activity-aware heartbeat timer (in
9
+ * `FrontendWebsocketClient`) issues a `heartbeat` request whenever the
10
+ * connection has been idle for its configured interval; server-side the
11
+ * dispatcher tracks receive time, so incoming heartbeats keep the socket
12
+ * alive without any handler-level state.
13
+ *
14
+ * Nullary input/output today. `{client_ts, server_ts}` fields can be added
15
+ * later if clock-skew telemetry ever matters — the {@link Action} container
16
+ * is open for additions without churning consumer call sites.
17
+ *
18
+ * @module
19
+ */
20
+ import { z } from 'zod';
21
+ import { RequestResponseActionSpec } from './action_spec.js';
22
+ /** Method name on the wire — shared across every fuz_app consumer. */
23
+ export const HEARTBEAT_METHOD = 'heartbeat';
24
+ /**
25
+ * `ActionSpec` for the shared heartbeat. `authenticated` auth — upgrade-time
26
+ * auth has already admitted the socket; heartbeats don't need role gating.
27
+ * `side_effects: false` keeps it orthogonal to state changes.
28
+ */
29
+ export const heartbeat_action_spec = RequestResponseActionSpec.parse({
30
+ method: HEARTBEAT_METHOD,
31
+ kind: 'request_response',
32
+ initiator: 'frontend',
33
+ auth: 'authenticated',
34
+ side_effects: false,
35
+ input: z.strictObject({}),
36
+ output: z.strictObject({}),
37
+ async: true,
38
+ description: 'Shared activity ping — keeps the socket alive and exercises the dispatch path.',
39
+ });
40
+ /** Handler — nullary echo. Stateless, suitable for high-frequency pings. */
41
+ export const heartbeat_handler = () => ({});
42
+ /**
43
+ * Composable tuple — spread into the server's `actions` array for dispatch
44
+ * and into the client's `actions` array so `create_rpc_client` types
45
+ * `app.api.heartbeat()` against the shared spec.
46
+ */
47
+ export const heartbeat_action = {
48
+ spec: heartbeat_action_spec,
49
+ handler: heartbeat_handler,
50
+ };
@@ -24,34 +24,12 @@
24
24
  import type { Context, Hono } from 'hono';
25
25
  import type { UpgradeWebSocket, WSContext } from 'hono/ws';
26
26
  import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.js';
27
- import { type JsonrpcRequestId } from '../http/jsonrpc.js';
28
27
  import type { Uuid } from '../uuid.js';
29
- import type { ActionSpecUnion } from './action_spec.js';
28
+ import { type Action, type BaseHandlerContext, type WsActionHandler } from './action_types.js';
30
29
  import { BackendWebsocketTransport, type ConnectionIdentity } from './transports_ws_backend.js';
31
- /**
32
- * Minimum per-request context every handler receives.
33
- *
34
- * Consumers extend this with domain-specific fields via
35
- * `RegisterActionWsOptions.extend_context` (e.g., a `backend` singleton
36
- * or the authenticated `RequestContext`). Keeping the base minimal matches
37
- * the HTTP-side `ActionContext` (from `actions/action_rpc.ts`) and mirrors
38
- * Rust's `Ctx<'a>` shape (`request_id` + `NotifyFn` + `CancellationToken`).
39
- */
40
- export interface BaseHandlerContext {
41
- /** JSON-RPC envelope request id — echoed back on the response. */
42
- request_id: JsonrpcRequestId;
43
- /**
44
- * Send a request-scoped JSON-RPC notification to the originating socket.
45
- * Not a broadcast — the message only reaches the client whose request
46
- * triggered this handler. Streaming handlers (e.g. `completion_progress`)
47
- * route chunks through this.
48
- */
49
- notify: (method: string, params: unknown) => void;
50
- /** Fires on socket close; streaming handlers poll for early termination. */
51
- signal: AbortSignal;
52
- }
53
- /** Handler signature — receives validated input and per-request context. */
54
- export type WsActionHandler<TCtx extends BaseHandlerContext> = (input: unknown, ctx: TCtx) => unknown;
30
+ export type { Action, BaseHandlerContext, WsActionHandler };
31
+ /** Default inactivity window before the server closes a silent socket. */
32
+ export declare const DEFAULT_SERVER_HEARTBEAT_TIMEOUT = 60000;
55
33
  /**
56
34
  * Context passed to the `on_socket_open` hook.
57
35
  *
@@ -91,6 +69,15 @@ export interface SocketCloseContext {
91
69
  /** Auth identity captured at open time — still valid even if the transport already cleaned up. */
92
70
  identity: ConnectionIdentity;
93
71
  }
72
+ export interface ServerHeartbeatOptions {
73
+ /**
74
+ * Receive-silence (ms) past which the server closes the socket with
75
+ * {@link WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT}. Any incoming message resets
76
+ * the counter — chatty clients never trip it. First {@link timeout}
77
+ * window after socket open is exempt (cold-start grace).
78
+ */
79
+ timeout?: number;
80
+ }
94
81
  /** Options for `register_action_ws`. */
95
82
  export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
96
83
  /** Mount path (e.g., `/api/ws`). */
@@ -99,10 +86,14 @@ export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
99
86
  app: Hono;
100
87
  /** Hono's `upgradeWebSocket` helper from the runtime adapter. */
101
88
  upgradeWebSocket: UpgradeWebSocket;
102
- /** Action specs — drives method lookup, per-action auth, and input/output validation. */
103
- specs: ReadonlyArray<ActionSpecUnion>;
104
- /** Handler map keyed by `spec.method`. */
105
- handlers: Record<string, WsActionHandler<TCtx>>;
89
+ /**
90
+ * The actions registered on this endpoint — each carries a spec (drives
91
+ * method lookup, per-action auth, input/output validation) and an
92
+ * optional handler (omit for client-only specs like inbound
93
+ * notifications). Include the shared {@link heartbeat_action} here to
94
+ * complete the disconnect-detection pairing with the frontend client.
95
+ */
96
+ actions: ReadonlyArray<Action<TCtx>>;
106
97
  /**
107
98
  * Build the per-request context from the base and the upgrade-time Hono
108
99
  * context. Called once per incoming message. Consumers use this to attach
@@ -116,6 +107,13 @@ export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
116
107
  * handle for `create_ws_auth_guard` and `send_to`/`broadcast`.
117
108
  */
118
109
  transport?: BackendWebsocketTransport;
110
+ /**
111
+ * Server-side heartbeat policy. Default-on (receive-silence detection,
112
+ * 60s timeout). `false` disables the timer entirely — only do this if
113
+ * the upstream stack (TCP keepalive, Cloudflare idle timeout, etc.)
114
+ * already owns disconnect detection. Pass an object to tune the timeout.
115
+ */
116
+ heartbeat?: boolean | ServerHeartbeatOptions;
119
117
  /** Optional per-message delay for testing loading states. Ignored when `0`. */
120
118
  artificial_delay?: number;
121
119
  /** Optional logger; defaults to `[ws]` namespace. */
@@ -1 +1 @@
1
- {"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAC,OAAO,EAAE,IAAI,EAAC,MAAM,MAAM,CAAC;AACxC,OAAO,KAAK,EAAC,gBAAgB,EAAE,SAAS,EAAC,MAAM,SAAS,CAAC;AAEzD,OAAO,EAAS,KAAK,MAAM,IAAI,UAAU,EAAC,MAAM,yBAAyB,CAAC;AAgB1E,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,YAAY,CAAC;AAErC,OAAO,EAAC,KAAK,MAAM,EAAE,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAC,MAAM,mBAAmB,CAAC;AAE7F,OAAO,EAAC,yBAAyB,EAAE,KAAK,kBAAkB,EAAC,MAAM,4BAA4B,CAAC;AAE9F,YAAY,EAAC,MAAM,EAAE,kBAAkB,EAAE,eAAe,EAAC,CAAC;AAE1D,0EAA0E;AAC1E,eAAO,MAAM,gCAAgC,QAAS,CAAC;AAEvD;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IACjC,qFAAqF;IACrF,EAAE,EAAE,SAAS,CAAC;IACd,4EAA4E;IAC5E,aAAa,EAAE,IAAI,CAAC;IACpB,oDAAoD;IACpD,QAAQ,EAAE,kBAAkB,CAAC;IAC7B;;;OAGG;IACH,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD,wFAAwF;IACxF,MAAM,EAAE,WAAW,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IAClC,+CAA+C;IAC/C,EAAE,EAAE,SAAS,CAAC;IACd,2CAA2C;IAC3C,aAAa,EAAE,IAAI,CAAC;IACpB,kGAAkG;IAClG,QAAQ,EAAE,kBAAkB,CAAC;CAC7B;AAED,MAAM,WAAW,sBAAsB;IACtC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wCAAwC;AACxC,MAAM,WAAW,uBAAuB,CAAC,IAAI,SAAS,kBAAkB;IACvE,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,GAAG,EAAE,IAAI,CAAC;IACV,iEAAiE;IACjE,gBAAgB,EAAE,gBAAgB,CAAC;IACnC;;;;;;OAMG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;IACrC;;;;;OAKG;IACH,cAAc,EAAE,CAAC,IAAI,EAAE,kBAAkB,EAAE,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAC/D;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,+EAA+E;IAC/E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,qDAAqD;IACrD,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpE;AAED,sCAAsC;AACtC,MAAM,WAAW,sBAAsB;IACtC,yEAAyE;IACzE,SAAS,EAAE,yBAAyB,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,kBAAkB,GAAI,IAAI,SAAS,kBAAkB,EACjE,SAAS,uBAAuB,CAAC,IAAI,CAAC,KACpC,sBA0UF,CAAC"}
@@ -31,7 +31,11 @@ import { JSONRPC_VERSION } from '../http/jsonrpc.js';
31
31
  import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
32
32
  import { create_jsonrpc_error_response, create_jsonrpc_error_response_from_thrown, create_jsonrpc_notification, to_jsonrpc_message_id, to_jsonrpc_params, is_jsonrpc_request, } from '../http/jsonrpc_helpers.js';
33
33
  import { CREDENTIAL_TYPE_KEY, AUTH_API_TOKEN_ID_KEY } from '../hono_context.js';
34
+ import {} from './action_types.js';
35
+ import { WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT } from './transports.js';
34
36
  import { BackendWebsocketTransport } from './transports_ws_backend.js';
37
+ /** Default inactivity window before the server closes a silent socket. */
38
+ export const DEFAULT_SERVER_HEARTBEAT_TIMEOUT = 60_000;
35
39
  /**
36
40
  * Mount a JSON-RPC WebSocket endpoint that dispatches to the supplied handler
37
41
  * map. Per-request context is built from the base + consumer-provided
@@ -51,9 +55,24 @@ import { BackendWebsocketTransport } from './transports_ws_backend.js';
51
55
  * `create_ws_auth_guard` or broadcast on audit events.
52
56
  */
53
57
  export const register_action_ws = (options) => {
54
- const { path, app, upgradeWebSocket, specs, handlers, extend_context, transport = new BackendWebsocketTransport(), artificial_delay = 0, log = new Logger('[ws]'), on_socket_open, on_socket_close, } = options;
55
- // Build spec lookup for per-action auth and input validation.
56
- const spec_by_method = new Map(specs.map((spec) => [spec.method, spec]));
58
+ const { path, app, upgradeWebSocket, actions, extend_context, transport = new BackendWebsocketTransport(), heartbeat = true, artificial_delay = 0, log = new Logger('[ws]'), on_socket_open, on_socket_close, } = options;
59
+ // Fan the unified actions array into the two lookups the dispatcher
60
+ // consults at message time. Keeping them internal means the composable
61
+ // `{spec, handler}` tuple remains the only shape consumers name.
62
+ const spec_by_method = new Map();
63
+ const handlers = {};
64
+ for (const action of actions) {
65
+ spec_by_method.set(action.spec.method, action.spec);
66
+ if (action.handler)
67
+ handlers[action.spec.method] = action.handler;
68
+ }
69
+ const heartbeat_enabled = heartbeat !== false;
70
+ const heartbeat_config = typeof heartbeat === 'object' ? heartbeat : {};
71
+ const heartbeat_timeout = heartbeat_config.timeout ?? DEFAULT_SERVER_HEARTBEAT_TIMEOUT;
72
+ // Run the checker on timeout/2 so event-loop blockage pauses the timer
73
+ // itself — a dead-because-blocked socket is close enough to
74
+ // dead-because-unresponsive that closing is arguably correct.
75
+ const heartbeat_tick_interval = Math.max(100, Math.floor(heartbeat_timeout / 2));
57
76
  app.get(path, upgradeWebSocket((c) => {
58
77
  // Upgrade-time auth extraction — `require_auth` middleware has already
59
78
  // rejected unauthenticated requests, so request_context is guaranteed
@@ -83,6 +102,18 @@ export const register_action_ws = (options) => {
83
102
  // Captured on open, consumed on close. Null before onOpen fires or
84
103
  // when a consumer never opens (e.g. immediate disconnect).
85
104
  let captured_connection_id = null;
105
+ // Receive-silence watchdog. Seeded to open-time so the first window is
106
+ // exempt (cold-start grace — avoid killing mid-handshake sockets).
107
+ // Bumped by onMessage. Any incoming activity counts, not just
108
+ // heartbeats — chatty clients don't need to send extras.
109
+ let last_receive_time = 0;
110
+ let heartbeat_timer = null;
111
+ const stop_heartbeat_timer = () => {
112
+ if (heartbeat_timer !== null) {
113
+ clearInterval(heartbeat_timer);
114
+ heartbeat_timer = null;
115
+ }
116
+ };
86
117
  // Socket-scoped notification helper — routes to this socket only,
87
118
  // matches the `ctx.notify` semantics exposed to per-message handlers.
88
119
  const notify_socket = (ws) => (notify_method, notify_params) => {
@@ -99,6 +130,23 @@ export const register_action_ws = (options) => {
99
130
  const connection_id = transport.add_connection(ws, token_hash, account_id, api_token_id);
100
131
  captured_connection_id = connection_id;
101
132
  log.debug('ws opened', connection_id, event);
133
+ if (heartbeat_enabled) {
134
+ last_receive_time = Date.now();
135
+ heartbeat_timer = setInterval(() => {
136
+ const now = Date.now();
137
+ const silence = now - last_receive_time;
138
+ if (silence >= heartbeat_timeout) {
139
+ log.info(`heartbeat timeout (${silence}ms) — closing ${WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT}`, connection_id, identity.account_id);
140
+ stop_heartbeat_timer();
141
+ try {
142
+ ws.close(WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT, 'server heartbeat timeout');
143
+ }
144
+ catch (error) {
145
+ log.error('heartbeat timeout close failed:', error);
146
+ }
147
+ }
148
+ }, heartbeat_tick_interval);
149
+ }
102
150
  if (on_socket_open) {
103
151
  try {
104
152
  await on_socket_open({
@@ -122,6 +170,7 @@ export const register_action_ws = (options) => {
122
170
  }
123
171
  },
124
172
  onMessage: async (event, ws) => {
173
+ last_receive_time = Date.now();
125
174
  let json;
126
175
  try {
127
176
  json = JSON.parse(String(event.data)); // eslint-disable-line @typescript-eslint/no-base-to-string
@@ -220,6 +269,7 @@ export const register_action_ws = (options) => {
220
269
  }
221
270
  },
222
271
  onClose: async (event, ws) => {
272
+ stop_heartbeat_timer();
223
273
  socket_abort_controller.abort();
224
274
  if (on_socket_close && captured_connection_id) {
225
275
  try {
@@ -5,11 +5,21 @@
5
5
  * Drop into any SvelteKit frontend as the underlying connection for
6
6
  * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
7
7
  * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
8
- * server revokes auth), and exposes reactive status for UI indicators.
8
+ * server revokes auth), exposes reactive status for UI indicators, and ships
9
+ * three correctness primitives default-on:
9
10
  *
10
- * First cut: no message queue, no heartbeat. Those live in consumer-specific
11
- * wrappers today (see zzz's `Socket` Cell); extract into fuz_app when two
12
- * independent consumers motivate the shape.
11
+ * - {@link FrontendWebsocketClient.request} promise-based JSON-RPC with
12
+ * auto-assigned ids and a pending-id map. Intercepts responses on the
13
+ * message path so request/response correlation is transport-level rather
14
+ * than re-invented per consumer.
15
+ * - **Durable queue** — `request()` calls made while disconnected buffer up
16
+ * to {@link DEFAULT_QUEUE_MAX_SIZE} requests and flush on reopen. Overflow
17
+ * rejects with `queue_overflow`. Raw {@link FrontendWebsocketClient.send}
18
+ * is drop-on-disconnect (fire-and-forget notifications want that).
19
+ * - **Activity-aware heartbeat** — idles fire a shared `heartbeat` request;
20
+ * receive-silence past {@link DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT} closes
21
+ * with {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT} and lets auto-reconnect
22
+ * pick back up.
13
23
  *
14
24
  * @module
15
25
  */
@@ -23,6 +33,12 @@ export declare const DEFAULT_RECONNECT_DELAY = 1000;
23
33
  export declare const DEFAULT_RECONNECT_DELAY_MAX = 10000;
24
34
  /** Exponential backoff factor: delay = base * factor^(attempt-1). */
25
35
  export declare const DEFAULT_BACKOFF_FACTOR = 1.5;
36
+ /** Idle interval before sending a heartbeat (ms). */
37
+ export declare const DEFAULT_HEARTBEAT_INTERVAL = 30000;
38
+ /** Max receive silence before closing with {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT} (ms). */
39
+ export declare const DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60000;
40
+ /** Default bound on buffered requests while disconnected. Overflow rejects. */
41
+ export declare const DEFAULT_QUEUE_MAX_SIZE = 100;
26
42
  /**
27
43
  * Client-side WebSocket status.
28
44
  *
@@ -44,12 +60,48 @@ export interface FrontendWebsocketReconnectOptions {
44
60
  /** Exponential backoff factor. Defaults to 1.5. */
45
61
  factor?: number;
46
62
  }
63
+ export interface FrontendWebsocketHeartbeatOptions {
64
+ /**
65
+ * Idle duration (ms) after which a heartbeat is sent. Reset by any send or
66
+ * receive — chatty clients never emit extras. Defaults to
67
+ * {@link DEFAULT_HEARTBEAT_INTERVAL}.
68
+ */
69
+ interval?: number;
70
+ /**
71
+ * Receive-silence (ms) after which the client closes the socket with
72
+ * {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT}, letting auto-reconnect kick
73
+ * in. Should be a comfortable multiple of {@link interval}. Defaults to
74
+ * {@link DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT}.
75
+ */
76
+ receive_timeout?: number;
77
+ }
78
+ export interface FrontendWebsocketQueueOptions {
79
+ /**
80
+ * Maximum number of requests held while the socket is disconnected.
81
+ * Enqueue past this rejects the new call with a `queue_overflow` error.
82
+ * Defaults to {@link DEFAULT_QUEUE_MAX_SIZE}.
83
+ */
84
+ max_size?: number;
85
+ }
47
86
  export interface FrontendWebsocketClientOptions {
48
87
  /**
49
88
  * Auto-reconnect policy. `false` disables reconnect entirely; `true` or
50
89
  * omit for default timing; pass an object to customize.
51
90
  */
52
91
  reconnect?: boolean | FrontendWebsocketReconnectOptions | null;
92
+ /**
93
+ * Activity-aware heartbeat. `true` or omit for defaults; `false` disables
94
+ * the timer entirely (only do this if the server side is also running
95
+ * without heartbeat); pass an object to tune `interval` / `receive_timeout`.
96
+ */
97
+ heartbeat?: boolean | FrontendWebsocketHeartbeatOptions;
98
+ /**
99
+ * Durable queue for {@link FrontendWebsocketClient.request}. `true` or omit
100
+ * for defaults; `false` disables buffering (requests while disconnected
101
+ * reject immediately). Raw {@link FrontendWebsocketClient.send} is never
102
+ * queued — use `request()` for RPC semantics.
103
+ */
104
+ queue?: boolean | FrontendWebsocketQueueOptions;
53
105
  /** Optional logger for diagnostic messages. */
54
106
  log?: Logger | null;
55
107
  }
@@ -132,6 +184,26 @@ export declare class FrontendWebsocketClient implements WebsocketConnection, Dis
132
184
  /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
133
185
  [Symbol.dispose](): void;
134
186
  send(data: object): boolean;
187
+ /**
188
+ * Promise-based JSON-RPC over the socket. Auto-assigns a monotonic request
189
+ * id, tracks the pending promise, and resolves when the server sends a
190
+ * matching response (or rejects on error frame, socket close, or aborted
191
+ * signal).
192
+ *
193
+ * While the socket is disconnected, the request is buffered in a bounded
194
+ * queue (default-on, {@link DEFAULT_QUEUE_MAX_SIZE}) and flushed on
195
+ * reopen. Pass `{queue: false}` to reject immediately when disconnected
196
+ * — used internally by the heartbeat, which must not fight the queue for
197
+ * the disconnect-detection slot.
198
+ *
199
+ * `AbortSignal` integration today rejects the local promise; the
200
+ * server-side cancel protocol (sending a `cancel` notification to abort
201
+ * the in-flight handler) lands in Phase 3c as a follow-up PR.
202
+ */
203
+ request<R = unknown>(method: string, params?: unknown, options?: {
204
+ signal?: AbortSignal;
205
+ queue?: boolean;
206
+ }): Promise<R>;
135
207
  add_message_handler(handler: SocketMessageHandler): () => void;
136
208
  add_error_handler(handler: SocketErrorHandler): () => void;
137
209
  }
@@ -1 +1 @@
1
- {"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAGpD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,oBAAoB,CAAC;AAE5D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IAQ9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD;;;;;;;;OAQG;IACH,eAAe,EAAE,KAAK,GAAG,IAAI,CAAoB;IASjD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAWrE;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IA4CzF,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;OAIG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;OAIG;IACH,OAAO,IAAI,IAAI;IA2Bf;;;;OAIG;IACH,UAAU,CAAC,IAAI,GAAE,MAA2B,GAAG,IAAI;IAQnD,sGAAsG;IACtG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAa3B,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CAiH1D"}
1
+ {"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAKpD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,oBAAoB,CAAC;AAE5D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,qDAAqD;AACrD,eAAO,MAAM,0BAA0B,QAAS,CAAC;AACjD,8FAA8F;AAC9F,eAAO,MAAM,iCAAiC,QAAS,CAAC;AACxD,+EAA+E;AAC/E,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iCAAiC;IACjD;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,6BAA6B;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,CAAC;IACxD;;;;;OAKG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,6BAA6B,CAAC;IAChD,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAiBD;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IA0B9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD;;;;;;;;OAQG;IACH,eAAe,EAAE,KAAK,GAAG,IAAI,CAAoB;IASjD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAwBrE;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IA4CzF,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;OAIG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;OAIG;IACH,OAAO,IAAI,IAAI;IA2Bf;;;;OAIG;IACH,UAAU,CAAC,IAAI,GAAE,MAA2B,GAAG,IAAI;IAUnD,sGAAsG;IACtG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAc3B;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,CAAC,GAAG,OAAO,EAClB,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,OAAY,EACpB,OAAO,GAAE;QAAC,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAM,GACnD,OAAO,CAAC,CAAC,CAAC;IAkEb,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CAqS1D"}
@@ -5,16 +5,28 @@
5
5
  * Drop into any SvelteKit frontend as the underlying connection for
6
6
  * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
7
7
  * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
8
- * server revokes auth), and exposes reactive status for UI indicators.
8
+ * server revokes auth), exposes reactive status for UI indicators, and ships
9
+ * three correctness primitives default-on:
9
10
  *
10
- * First cut: no message queue, no heartbeat. Those live in consumer-specific
11
- * wrappers today (see zzz's `Socket` Cell); extract into fuz_app when two
12
- * independent consumers motivate the shape.
11
+ * - {@link FrontendWebsocketClient.request} promise-based JSON-RPC with
12
+ * auto-assigned ids and a pending-id map. Intercepts responses on the
13
+ * message path so request/response correlation is transport-level rather
14
+ * than re-invented per consumer.
15
+ * - **Durable queue** — `request()` calls made while disconnected buffer up
16
+ * to {@link DEFAULT_QUEUE_MAX_SIZE} requests and flush on reopen. Overflow
17
+ * rejects with `queue_overflow`. Raw {@link FrontendWebsocketClient.send}
18
+ * is drop-on-disconnect (fire-and-forget notifications want that).
19
+ * - **Activity-aware heartbeat** — idles fire a shared `heartbeat` request;
20
+ * receive-silence past {@link DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT} closes
21
+ * with {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT} and lets auto-reconnect
22
+ * pick back up.
13
23
  *
14
24
  * @module
15
25
  */
16
26
  import { BROWSER } from 'esm-env';
17
- import { WS_CLOSE_SESSION_REVOKED } from './transports.js';
27
+ import { JSONRPC_VERSION } from '../http/jsonrpc.js';
28
+ import { WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, WS_CLOSE_SESSION_REVOKED } from './transports.js';
29
+ import { HEARTBEAT_METHOD } from './heartbeat.js';
18
30
  /** Default WebSocket close code (normal closure). */
19
31
  export const DEFAULT_CLOSE_CODE = 1000;
20
32
  /** Base reconnect delay in ms. */
@@ -23,6 +35,12 @@ export const DEFAULT_RECONNECT_DELAY = 1000;
23
35
  export const DEFAULT_RECONNECT_DELAY_MAX = 10000;
24
36
  /** Exponential backoff factor: delay = base * factor^(attempt-1). */
25
37
  export const DEFAULT_BACKOFF_FACTOR = 1.5;
38
+ /** Idle interval before sending a heartbeat (ms). */
39
+ export const DEFAULT_HEARTBEAT_INTERVAL = 30_000;
40
+ /** Max receive silence before closing with {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT} (ms). */
41
+ export const DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000;
42
+ /** Default bound on buffered requests while disconnected. Overflow rejects. */
43
+ export const DEFAULT_QUEUE_MAX_SIZE = 100;
26
44
  /**
27
45
  * Reactive WebSocket client implementing `WebsocketConnection`.
28
46
  *
@@ -40,7 +58,20 @@ export class FrontendWebsocketClient {
40
58
  #reconnect_delay;
41
59
  #reconnect_delay_max;
42
60
  #backoff_factor;
61
+ #heartbeat_enabled;
62
+ #heartbeat_interval;
63
+ #heartbeat_receive_timeout;
64
+ #queue_enabled;
65
+ #queue_max_size;
43
66
  #log;
67
+ #next_request_id = 0;
68
+ #pending = new Map();
69
+ #queue = [];
70
+ #heartbeat_timer = null;
71
+ /** Epoch ms of the last outgoing send — used by the heartbeat activity check. */
72
+ #last_send_time = null;
73
+ /** Epoch ms of the last incoming message — used by the heartbeat activity check. */
74
+ #last_receive_time = null;
44
75
  ws = $state.raw(null);
45
76
  status = $state.raw('initial');
46
77
  reconnect_count = $state.raw(0);
@@ -77,6 +108,16 @@ export class FrontendWebsocketClient {
77
108
  this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
78
109
  this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
79
110
  this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
111
+ const heartbeat = options.heartbeat;
112
+ this.#heartbeat_enabled = heartbeat !== false;
113
+ const heartbeat_config = typeof heartbeat === 'object' ? heartbeat : {};
114
+ this.#heartbeat_interval = heartbeat_config.interval ?? DEFAULT_HEARTBEAT_INTERVAL;
115
+ this.#heartbeat_receive_timeout =
116
+ heartbeat_config.receive_timeout ?? DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT;
117
+ const queue = options.queue;
118
+ this.#queue_enabled = queue !== false;
119
+ const queue_config = typeof queue === 'object' ? queue : {};
120
+ this.#queue_max_size = queue_config.max_size ?? DEFAULT_QUEUE_MAX_SIZE;
80
121
  this.#log = options.log ?? null;
81
122
  }
82
123
  /**
@@ -184,10 +225,12 @@ export class FrontendWebsocketClient {
184
225
  */
185
226
  disconnect(code = DEFAULT_CLOSE_CODE) {
186
227
  this.#cancel_reconnect();
228
+ this.#cancel_heartbeat();
187
229
  this.#teardown(code);
188
230
  this.status = 'closed';
189
231
  this.reconnect_count = 0;
190
232
  this.current_reconnect_delay = 0;
233
+ this.#reject_all('client disconnected');
191
234
  }
192
235
  /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
193
236
  [Symbol.dispose]() {
@@ -199,6 +242,7 @@ export class FrontendWebsocketClient {
199
242
  try {
200
243
  this.ws.send(JSON.stringify(data));
201
244
  this.last_send_error = null;
245
+ this.#last_send_time = Date.now();
202
246
  return true;
203
247
  }
204
248
  catch (error) {
@@ -207,6 +251,81 @@ export class FrontendWebsocketClient {
207
251
  return false;
208
252
  }
209
253
  }
254
+ /**
255
+ * Promise-based JSON-RPC over the socket. Auto-assigns a monotonic request
256
+ * id, tracks the pending promise, and resolves when the server sends a
257
+ * matching response (or rejects on error frame, socket close, or aborted
258
+ * signal).
259
+ *
260
+ * While the socket is disconnected, the request is buffered in a bounded
261
+ * queue (default-on, {@link DEFAULT_QUEUE_MAX_SIZE}) and flushed on
262
+ * reopen. Pass `{queue: false}` to reject immediately when disconnected
263
+ * — used internally by the heartbeat, which must not fight the queue for
264
+ * the disconnect-detection slot.
265
+ *
266
+ * `AbortSignal` integration today rejects the local promise; the
267
+ * server-side cancel protocol (sending a `cancel` notification to abort
268
+ * the in-flight handler) lands in Phase 3c as a follow-up PR.
269
+ */
270
+ request(method, params = {}, options = {}) {
271
+ return new Promise((resolve, reject) => {
272
+ const resolve_typed = resolve;
273
+ const reject_typed = reject;
274
+ if (this.#revoked) {
275
+ reject_typed(new Error('[socket] session revoked'));
276
+ return;
277
+ }
278
+ const { signal = null } = options;
279
+ if (signal?.aborted) {
280
+ reject_typed(this.#build_abort_error(method));
281
+ return;
282
+ }
283
+ const id = ++this.#next_request_id;
284
+ const frame = { jsonrpc: JSONRPC_VERSION, id, method, params };
285
+ // Bind the signal listener up-front so `#detach_signal` can find it by
286
+ // reference regardless of which settlement path runs (inline send,
287
+ // queued flush, close-time reject).
288
+ let pending = null;
289
+ const signal_handler = signal
290
+ ? () => {
291
+ if (!pending)
292
+ return;
293
+ this.#pending.delete(id);
294
+ this.#drop_queued(id);
295
+ this.#detach_signal(pending);
296
+ pending = null;
297
+ reject_typed(this.#build_abort_error(method));
298
+ }
299
+ : null;
300
+ if (signal && signal_handler)
301
+ signal.addEventListener('abort', signal_handler);
302
+ pending = { method, resolve: resolve_typed, reject: reject_typed, signal, signal_handler };
303
+ const should_queue = options.queue !== false && this.#queue_enabled;
304
+ if (this.connected && this.ws) {
305
+ const sent = this.send(frame);
306
+ if (sent) {
307
+ this.#pending.set(id, pending);
308
+ return;
309
+ }
310
+ // Send failed mid-connected (serialization, buffer full). Requeue if
311
+ // the queue is on, otherwise reject — this socket is in an odd
312
+ // state but the caller asked for non-durable semantics.
313
+ if (should_queue) {
314
+ this.#enqueue({ ...pending, id, frame });
315
+ return;
316
+ }
317
+ this.#detach_signal(pending);
318
+ reject_typed(new Error(`[socket] send failed for ${method}`));
319
+ return;
320
+ }
321
+ if (should_queue) {
322
+ this.#enqueue({ ...pending, id, frame });
323
+ return;
324
+ }
325
+ this.#detach_signal(pending);
326
+ reject_typed(new Error(`[socket] not connected (method=${method})`));
327
+ });
328
+ }
210
329
  add_message_handler(handler) {
211
330
  this.#message_handlers.add(handler);
212
331
  return () => this.#message_handlers.delete(handler);
@@ -215,6 +334,124 @@ export class FrontendWebsocketClient {
215
334
  this.#error_handlers.add(handler);
216
335
  return () => this.#error_handlers.delete(handler);
217
336
  }
337
+ #build_abort_error(method) {
338
+ return new Error(`[socket] request aborted (method=${method})`);
339
+ }
340
+ #detach_signal(pending) {
341
+ if (pending.signal && pending.signal_handler) {
342
+ pending.signal.removeEventListener('abort', pending.signal_handler);
343
+ }
344
+ }
345
+ #enqueue(queued) {
346
+ if (this.#queue.length >= this.#queue_max_size) {
347
+ this.#detach_signal(queued);
348
+ queued.reject(new Error(`[socket] request queue overflow (method=${queued.method}, max=${this.#queue_max_size})`));
349
+ return;
350
+ }
351
+ this.#queue.push(queued);
352
+ }
353
+ #drop_queued(id) {
354
+ const index = this.#queue.findIndex((q) => q.id === id);
355
+ if (index !== -1)
356
+ this.#queue.splice(index, 1);
357
+ }
358
+ #flush_queue() {
359
+ if (!this.connected || !this.ws)
360
+ return;
361
+ const queued = this.#queue;
362
+ this.#queue = [];
363
+ for (const q of queued) {
364
+ if (q.signal?.aborted) {
365
+ this.#detach_signal(q);
366
+ q.reject(this.#build_abort_error(q.method));
367
+ continue;
368
+ }
369
+ const sent = this.send(q.frame);
370
+ if (sent) {
371
+ this.#pending.set(q.id, {
372
+ method: q.method,
373
+ resolve: q.resolve,
374
+ reject: q.reject,
375
+ signal: q.signal,
376
+ signal_handler: q.signal_handler,
377
+ });
378
+ }
379
+ else {
380
+ this.#detach_signal(q);
381
+ q.reject(new Error(`[socket] queued request send failed (method=${q.method})`));
382
+ }
383
+ }
384
+ }
385
+ #reject_all(reason) {
386
+ const pending = this.#pending;
387
+ this.#pending = new Map();
388
+ for (const [id, p] of pending) {
389
+ this.#detach_signal(p);
390
+ p.reject(new Error(`[socket] ${reason} (method=${p.method}, id=${id})`));
391
+ }
392
+ const queued = this.#queue;
393
+ this.#queue = [];
394
+ for (const q of queued) {
395
+ this.#detach_signal(q);
396
+ q.reject(new Error(`[socket] ${reason} (method=${q.method})`));
397
+ }
398
+ }
399
+ #reject_pending_only(reason) {
400
+ // Socket closed but auto-reconnect will try again — pending requests were
401
+ // in flight on the old socket so we can't correlate them after reopen;
402
+ // queued requests haven't been sent yet and stay buffered for the flush.
403
+ const pending = this.#pending;
404
+ this.#pending = new Map();
405
+ for (const [id, p] of pending) {
406
+ this.#detach_signal(p);
407
+ p.reject(new Error(`[socket] ${reason} (method=${p.method}, id=${id})`));
408
+ }
409
+ }
410
+ #start_heartbeat() {
411
+ this.#cancel_heartbeat();
412
+ if (!this.#heartbeat_enabled)
413
+ return;
414
+ const now = Date.now();
415
+ this.#last_send_time = now;
416
+ this.#last_receive_time = now;
417
+ // Run the check at half the interval so any event-loop blockage pauses
418
+ // the timer itself; a dead-because-blocked socket is close enough to
419
+ // dead-because-unresponsive that closing is arguably correct.
420
+ const tick = Math.max(100, Math.floor(this.#heartbeat_interval / 2));
421
+ this.#heartbeat_timer = setInterval(() => this.#heartbeat_tick(), tick);
422
+ }
423
+ #cancel_heartbeat() {
424
+ if (this.#heartbeat_timer !== null) {
425
+ clearInterval(this.#heartbeat_timer);
426
+ this.#heartbeat_timer = null;
427
+ }
428
+ }
429
+ #heartbeat_tick() {
430
+ if (!this.connected || !this.ws)
431
+ return;
432
+ const now = Date.now();
433
+ const last_receive = this.#last_receive_time ?? now;
434
+ if (now - last_receive >= this.#heartbeat_receive_timeout) {
435
+ this.#log?.info(`[socket] receive timeout (${now - last_receive}ms) — closing ${WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT}`);
436
+ try {
437
+ this.ws.close(WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, 'client heartbeat timeout');
438
+ }
439
+ catch (error) {
440
+ this.#log?.error('[socket] heartbeat timeout close failed:', error);
441
+ }
442
+ return;
443
+ }
444
+ const last_activity = Math.max(this.#last_send_time ?? 0, last_receive);
445
+ if (now - last_activity >= this.#heartbeat_interval) {
446
+ // Fire-and-forget the heartbeat. If it fails (network, serialization),
447
+ // receive-silence detection above will close the socket on the next
448
+ // tick. No queue — the heartbeat is the thing that tells us the
449
+ // queue needs flushing, it must not fight the queue for the slot.
450
+ void this.request(HEARTBEAT_METHOD, {}, { queue: false }).catch((error) => {
451
+ this.#log?.debug('[socket] heartbeat request failed:', error);
452
+ });
453
+ }
454
+ }
218
455
  #teardown(close_code) {
219
456
  if (!this.ws)
220
457
  return;
@@ -222,6 +459,7 @@ export class FrontendWebsocketClient {
222
459
  this.ws.removeEventListener('close', this.#handle_close);
223
460
  this.ws.removeEventListener('error', this.#handle_error);
224
461
  this.ws.removeEventListener('message', this.#handle_message);
462
+ this.#cancel_heartbeat();
225
463
  if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
226
464
  try {
227
465
  this.ws.close(close_code);
@@ -230,8 +468,10 @@ export class FrontendWebsocketClient {
230
468
  this.#log?.error('[socket] close failed:', error);
231
469
  }
232
470
  // Listeners are gone, so `#handle_close` won't fire for this close —
233
- // record it here so the client-initiated close is still observable.
471
+ // record it here so the client-initiated close is still observable,
472
+ // and reject any pending requests that can never resolve now.
234
473
  this.#record_close(close_code, '');
474
+ this.#reject_pending_only(`socket torn down (code ${close_code})`);
235
475
  }
236
476
  this.ws = null;
237
477
  }
@@ -267,12 +507,16 @@ export class FrontendWebsocketClient {
267
507
  this.current_reconnect_delay = 0;
268
508
  this.last_connect_time = Date.now();
269
509
  this.#cancel_reconnect();
510
+ this.#start_heartbeat();
511
+ // Flush buffered requests before anyone else can observe the open state.
512
+ this.#flush_queue();
270
513
  };
271
514
  #handle_close = (event) => {
272
515
  // Drop the dead-socket reference so consumers reading `client.ws` never
273
516
  // see a CLOSED WebSocket during the reconnect window.
274
517
  this.ws = null;
275
518
  this.#record_close(event.code, event.reason);
519
+ this.#cancel_heartbeat();
276
520
  // Session revocation is terminal — reconnecting would 401 in a loop.
277
521
  if (event.code === WS_CLOSE_SESSION_REVOKED) {
278
522
  this.#revoked = true;
@@ -280,8 +524,12 @@ export class FrontendWebsocketClient {
280
524
  this.#cancel_reconnect();
281
525
  this.reconnect_count = 0;
282
526
  this.current_reconnect_delay = 0;
527
+ this.#reject_all('session revoked');
283
528
  return;
284
529
  }
530
+ // Pending in-flight requests can't be correlated post-reconnect; reject
531
+ // them. Queue stays so the flush on reopen replays unsent work.
532
+ this.#reject_pending_only(`connection closed (code ${event.code})`);
285
533
  // Let `#schedule_reconnect` set `status: 'reconnecting'` directly to avoid
286
534
  // a transient `'closed'` flicker; only set `'closed'` when reconnect is off.
287
535
  if (this.#auto_reconnect) {
@@ -289,6 +537,7 @@ export class FrontendWebsocketClient {
289
537
  }
290
538
  else {
291
539
  this.status = 'closed';
540
+ this.#reject_all('connection closed, auto-reconnect disabled');
292
541
  }
293
542
  };
294
543
  #handle_error = (event) => {
@@ -304,6 +553,39 @@ export class FrontendWebsocketClient {
304
553
  // Browsers fire `close` after error; reconnect logic lives there.
305
554
  };
306
555
  #handle_message = (event) => {
556
+ this.#last_receive_time = Date.now();
557
+ // Intercept JSON-RPC responses for pending `request()` calls. Parse
558
+ // defensively — if the frame isn't valid JSON or isn't a response, fall
559
+ // through to the registered message handlers (which still see every
560
+ // notification, plus any stray response we don't own).
561
+ let json;
562
+ try {
563
+ json = JSON.parse(String(event.data));
564
+ }
565
+ catch {
566
+ json = undefined;
567
+ }
568
+ if (typeof json === 'object' &&
569
+ json !== null &&
570
+ 'id' in json &&
571
+ ('result' in json || 'error' in json)) {
572
+ const id = json.id;
573
+ if (id !== null) {
574
+ const pending = this.#pending.get(id);
575
+ if (pending) {
576
+ this.#pending.delete(id);
577
+ this.#detach_signal(pending);
578
+ if ('error' in json && json.error) {
579
+ const err = json.error;
580
+ pending.reject(new Error(`[rpc ${pending.method} #${id}] ${err.code ?? '?'} ${err.message ?? 'unknown error'}`));
581
+ }
582
+ else {
583
+ pending.resolve(json.result);
584
+ }
585
+ return;
586
+ }
587
+ }
588
+ }
307
589
  for (const handler of this.#message_handlers) {
308
590
  try {
309
591
  handler(event);
@@ -10,6 +10,10 @@ import { z } from 'zod';
10
10
  import type { JsonrpcMessageFromClientToServer, JsonrpcMessageFromServerToClient, JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
11
11
  /** WebSocket close code for session revocation. */
12
12
  export declare const WS_CLOSE_SESSION_REVOKED = 4001;
13
+ /** WebSocket close code — client timed out waiting for a response. */
14
+ export declare const WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002;
15
+ /** WebSocket close code — server timed out with no incoming activity. */
16
+ export declare const WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003;
13
17
  export declare const TransportName: z.ZodString;
14
18
  export type TransportName = z.infer<typeof TransportName>;
15
19
  export interface Transport {
@@ -1 +1 @@
1
- {"version":3,"file":"transports.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EACX,gCAAgC,EAChC,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAE5B,mDAAmD;AACnD,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAK7C,eAAO,MAAM,aAAa,aAAa,CAAC;AACxC,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D,MAAM,WAAW,SAAS;IACzB,cAAc,EAAE,aAAa,CAAC;IAE9B,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAAC;IACzE,IAAI,CAAC,OAAO,EAAE,gCAAgC,GAAG,OAAO,CAAC,gCAAgC,GAAG,IAAI,CAAC,CAAC;IAClG,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,qBAAa,UAAU;;IAItB;;;OAGG;IACH,cAAc,EAAE,OAAO,CAAQ;IAE/B;;OAEG;IACH,kBAAkB,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;IAS9C,qBAAqB,CAAC,cAAc,EAAE,aAAa,GAAG,IAAI;IAM1D;;;;;OAKG;IACH,aAAa,CAAC,cAAc,CAAC,EAAE,aAAa,GAAG,SAAS,GAAG,IAAI;IAO/D,QAAQ,IAAI,OAAO,GAAG,IAAI;IAM1B,qBAAqB,IAAI,SAAS,GAAG,IAAI;IAIzC,0BAA0B,IAAI,aAAa,GAAG,IAAI;IAIlD,qBAAqB,CAAC,cAAc,EAAE,aAAa,GAAG,SAAS,GAAG,IAAI;CAqDtE"}
1
+ {"version":3,"file":"transports.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EACX,gCAAgC,EAChC,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAE5B,mDAAmD;AACnD,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAC7C,sEAAsE;AACtE,eAAO,MAAM,iCAAiC,OAAO,CAAC;AACtD,yEAAyE;AACzE,eAAO,MAAM,iCAAiC,OAAO,CAAC;AAKtD,eAAO,MAAM,aAAa,aAAa,CAAC;AACxC,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D,MAAM,WAAW,SAAS;IACzB,cAAc,EAAE,aAAa,CAAC;IAE9B,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAAC;IACzE,IAAI,CAAC,OAAO,EAAE,gCAAgC,GAAG,OAAO,CAAC,gCAAgC,GAAG,IAAI,CAAC,CAAC;IAClG,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,qBAAa,UAAU;;IAItB;;;OAGG;IACH,cAAc,EAAE,OAAO,CAAQ;IAE/B;;OAEG;IACH,kBAAkB,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;IAS9C,qBAAqB,CAAC,cAAc,EAAE,aAAa,GAAG,IAAI;IAM1D;;;;;OAKG;IACH,aAAa,CAAC,cAAc,CAAC,EAAE,aAAa,GAAG,SAAS,GAAG,IAAI;IAO/D,QAAQ,IAAI,OAAO,GAAG,IAAI;IAM1B,qBAAqB,IAAI,SAAS,GAAG,IAAI;IAIzC,0BAA0B,IAAI,aAAa,GAAG,IAAI;IAIlD,qBAAqB,CAAC,cAAc,EAAE,aAAa,GAAG,SAAS,GAAG,IAAI;CAqDtE"}
@@ -9,6 +9,10 @@
9
9
  import { z } from 'zod';
10
10
  /** WebSocket close code for session revocation. */
11
11
  export const WS_CLOSE_SESSION_REVOKED = 4001;
12
+ /** WebSocket close code — client timed out waiting for a response. */
13
+ export const WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002;
14
+ /** WebSocket close code — server timed out with no incoming activity. */
15
+ export const WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003;
12
16
  // TODO figure out the symmetry of frontend and backend transports (none/partial/full?) --
13
17
  // we may also need orthogonal abstractions to clarify the transport role
14
18
  export const TransportName = z.string(); // not branded for convenience, will just error at runtime, the schema is just for docs atm
@@ -40,8 +40,9 @@ import type { Context } from 'hono';
40
40
  import { WSContext, type UpgradeWebSocket, type WSEvents } from 'hono/ws';
41
41
  import { Logger } from '@fuzdev/fuz_util/log.js';
42
42
  import type { ActionSpecUnion } from '../actions/action_spec.js';
43
+ import type { Action } from '../actions/action_types.js';
43
44
  import type { ActionEventEnvironment } from '../actions/action_event_types.js';
44
- import { type BaseHandlerContext, type RegisterActionWsOptions, type WsActionHandler } from '../actions/register_action_ws.js';
45
+ import { type BaseHandlerContext, type RegisterActionWsOptions } from '../actions/register_action_ws.js';
45
46
  import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
46
47
  import { type RequestContext } from '../auth/request_context.js';
47
48
  import { type CredentialType } from '../hono_context.js';
@@ -202,11 +203,22 @@ export declare const is_notification_with: <P>(method: string, match: (params: P
202
203
  export declare const is_response_for: (id: number | string) => (msg: unknown) => boolean;
203
204
  /** Options for `create_ws_test_harness`. */
204
205
  export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
205
- specs: ReadonlyArray<ActionSpecUnion>;
206
- handlers: Record<string, WsActionHandler<TCtx>>;
206
+ /**
207
+ * The actions registered on this endpoint — matches the shape
208
+ * `register_action_ws` accepts. Each entry is a `{spec, handler?}` tuple;
209
+ * shared fuz_app primitives (like `heartbeat_action`) can be spread in
210
+ * alongside consumer-specific actions.
211
+ */
212
+ actions: ReadonlyArray<Action<TCtx>>;
207
213
  extend_context?: RegisterActionWsOptions<TCtx>['extend_context'];
208
214
  /** Pass a pre-created transport to share with a broadcast API. */
209
215
  transport?: BackendWebsocketTransport;
216
+ /**
217
+ * Threaded through to `register_action_ws`. Defaults to `false` in tests —
218
+ * fake timers + receive-silence detection need explicit opt-in and per-
219
+ * test tuning to avoid spurious closes.
220
+ */
221
+ heartbeat?: RegisterActionWsOptions<TCtx>['heartbeat'];
210
222
  /** Optional logger. Defaults to a silent `[ws-test]` logger. */
211
223
  log?: Logger;
212
224
  /** Threaded straight through to `register_action_ws`. */
@@ -1 +1 @@
1
- {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
@@ -249,7 +249,7 @@ const build_simple_request_context = (role) => {
249
249
  * `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
250
250
  */
251
251
  export const create_ws_test_harness = (options) => {
252
- const { specs, handlers, extend_context = (base) => base, transport = new BackendWebsocketTransport(), log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, } = options;
252
+ const { actions, extend_context = (base) => base, transport = new BackendWebsocketTransport(), heartbeat = false, log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, } = options;
253
253
  const stub = create_stub_upgrade();
254
254
  // Minimal Hono stub — `register_action_ws` only needs `.get(path, handler)`.
255
255
  const stub_app = { get: () => stub_app };
@@ -257,10 +257,10 @@ export const create_ws_test_harness = (options) => {
257
257
  path: '/test/ws',
258
258
  app: stub_app,
259
259
  upgradeWebSocket: stub.upgradeWebSocket,
260
- specs,
261
- handlers,
260
+ actions,
262
261
  extend_context,
263
262
  transport,
263
+ heartbeat,
264
264
  log,
265
265
  on_socket_open,
266
266
  on_socket_close,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",