@fuzdev/fuz_app 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/actions/action_codegen.d.ts +25 -0
  2. package/dist/actions/action_codegen.d.ts.map +1 -1
  3. package/dist/actions/action_codegen.js +39 -0
  4. package/dist/actions/action_peer.d.ts +7 -0
  5. package/dist/actions/action_peer.d.ts.map +1 -1
  6. package/dist/actions/action_peer.js +1 -1
  7. package/dist/actions/action_types.d.ts +72 -0
  8. package/dist/actions/action_types.d.ts.map +1 -0
  9. package/dist/actions/action_types.js +11 -0
  10. package/dist/actions/cancel.d.ts +78 -0
  11. package/dist/actions/cancel.d.ts.map +1 -0
  12. package/dist/actions/cancel.js +79 -0
  13. package/dist/actions/heartbeat.d.ts +51 -0
  14. package/dist/actions/heartbeat.d.ts.map +1 -0
  15. package/dist/actions/heartbeat.js +50 -0
  16. package/dist/actions/register_action_ws.d.ts +28 -30
  17. package/dist/actions/register_action_ws.d.ts.map +1 -1
  18. package/dist/actions/register_action_ws.js +103 -20
  19. package/dist/actions/rpc_client.d.ts +10 -0
  20. package/dist/actions/rpc_client.d.ts.map +1 -1
  21. package/dist/actions/rpc_client.js +22 -7
  22. package/dist/actions/socket.svelte.d.ts +88 -4
  23. package/dist/actions/socket.svelte.d.ts.map +1 -1
  24. package/dist/actions/socket.svelte.js +322 -6
  25. package/dist/actions/transports.d.ts +18 -3
  26. package/dist/actions/transports.d.ts.map +1 -1
  27. package/dist/actions/transports.js +4 -0
  28. package/dist/actions/transports_http.d.ts +3 -3
  29. package/dist/actions/transports_http.d.ts.map +1 -1
  30. package/dist/actions/transports_http.js +4 -3
  31. package/dist/actions/transports_ws.d.ts +33 -6
  32. package/dist/actions/transports_ws.d.ts.map +1 -1
  33. package/dist/actions/transports_ws.js +43 -46
  34. package/dist/actions/transports_ws_backend.d.ts +12 -3
  35. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  36. package/dist/actions/transports_ws_backend.js +12 -1
  37. package/dist/auth/bearer_auth.js +0 -1
  38. package/dist/auth/keyring.d.ts.map +1 -1
  39. package/dist/auth/keyring.js +0 -2
  40. package/dist/auth/migrations.js +4 -4
  41. package/dist/db/migrate.d.ts +12 -2
  42. package/dist/db/migrate.d.ts.map +1 -1
  43. package/dist/db/migrate.js +25 -16
  44. package/dist/db/status.d.ts.map +1 -1
  45. package/dist/db/status.js +0 -2
  46. package/dist/dev/setup.js +2 -2
  47. package/dist/http/db_routes.d.ts.map +1 -1
  48. package/dist/http/db_routes.js +0 -1
  49. package/dist/testing/admin_integration.d.ts.map +1 -1
  50. package/dist/testing/admin_integration.js +0 -3
  51. package/dist/testing/app_server.js +1 -1
  52. package/dist/testing/data_exposure.js +6 -8
  53. package/dist/testing/db.js +1 -1
  54. package/dist/testing/integration.js +0 -1
  55. package/dist/testing/rate_limiting.d.ts.map +1 -1
  56. package/dist/testing/rate_limiting.js +0 -6
  57. package/dist/testing/rpc_round_trip.js +4 -4
  58. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  59. package/dist/testing/sse_round_trip.js +1 -2
  60. package/dist/testing/ws_round_trip.d.ts +15 -3
  61. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  62. package/dist/testing/ws_round_trip.js +3 -3
  63. package/package.json +2 -2
@@ -31,7 +31,12 @@ 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 { CANCEL_METHOD, CancelNotificationParams } from './cancel.js';
36
+ import { WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT } from './transports.js';
34
37
  import { BackendWebsocketTransport } from './transports_ws_backend.js';
38
+ /** Default inactivity window before the server closes a silent socket. */
39
+ export const DEFAULT_SERVER_HEARTBEAT_TIMEOUT = 60_000;
35
40
  /**
36
41
  * Mount a JSON-RPC WebSocket endpoint that dispatches to the supplied handler
37
42
  * map. Per-request context is built from the base + consumer-provided
@@ -51,9 +56,24 @@ import { BackendWebsocketTransport } from './transports_ws_backend.js';
51
56
  * `create_ws_auth_guard` or broadcast on audit events.
52
57
  */
53
58
  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]));
59
+ 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;
60
+ // Fan the unified actions array into the two lookups the dispatcher
61
+ // consults at message time. Keeping them internal means the composable
62
+ // `{spec, handler}` tuple remains the only shape consumers name.
63
+ const spec_by_method = new Map();
64
+ const handlers = {};
65
+ for (const action of actions) {
66
+ spec_by_method.set(action.spec.method, action.spec);
67
+ if (action.handler)
68
+ handlers[action.spec.method] = action.handler;
69
+ }
70
+ const heartbeat_enabled = heartbeat !== false;
71
+ const heartbeat_config = typeof heartbeat === 'object' ? heartbeat : {};
72
+ const heartbeat_timeout = heartbeat_config.timeout ?? DEFAULT_SERVER_HEARTBEAT_TIMEOUT;
73
+ // Run the checker on timeout/2 so event-loop blockage pauses the timer
74
+ // itself — a dead-because-blocked socket is close enough to
75
+ // dead-because-unresponsive that closing is arguably correct.
76
+ const heartbeat_tick_interval = Math.max(100, Math.floor(heartbeat_timeout / 2));
57
77
  app.get(path, upgradeWebSocket((c) => {
58
78
  // Upgrade-time auth extraction — `require_auth` middleware has already
59
79
  // rejected unauthenticated requests, so request_context is guaranteed
@@ -69,12 +89,19 @@ export const register_action_ws = (options) => {
69
89
  // `close_sockets_for_token` to tear down just this socket on
70
90
  // `token_revoke` without affecting the account's other sockets.
71
91
  const api_token_id = c.get(AUTH_API_TOKEN_ID_KEY);
72
- // Per-socket abort controller — fires on socket close, threaded into
73
- // every in-flight handler's ctx.signal on this connection. A
74
- // dedicated per-request controller linked to this is future work;
75
- // a single socket-scoped signal is sufficient today since cancel
76
- // granularity tracks connection lifetime, not individual requests.
92
+ // Per-socket abort controller — fires on socket close, chained into
93
+ // every in-flight handler's per-request controller via
94
+ // `AbortSignal.any`. Keeping both signals lets the client
95
+ // cancel-one-request-by-id (via the `cancel` notification) without
96
+ // tearing down the whole socket.
77
97
  const socket_abort_controller = new AbortController();
98
+ // Per-request controllers keyed by JSON-RPC request id — lets an
99
+ // incoming `cancel` notification abort just the matching handler.
100
+ // Populated on request dispatch, cleared in the handler's `finally`
101
+ // so a late-arriving cancel for a completed id (or a reused id)
102
+ // can't null-abort a freshly-arrived request. Idempotent: cancel
103
+ // for unknown ids no-ops.
104
+ const pending_controllers = new Map();
78
105
  // Identity is assembled at upgrade time so `on_socket_close` can
79
106
  // still read it after the audit guard tears the transport record
80
107
  // down; `BackendWebsocketTransport.#revoke_connection` clears the
@@ -83,6 +110,18 @@ export const register_action_ws = (options) => {
83
110
  // Captured on open, consumed on close. Null before onOpen fires or
84
111
  // when a consumer never opens (e.g. immediate disconnect).
85
112
  let captured_connection_id = null;
113
+ // Receive-silence watchdog. Seeded to open-time so the first window is
114
+ // exempt (cold-start grace — avoid killing mid-handshake sockets).
115
+ // Bumped by onMessage. Any incoming activity counts, not just
116
+ // heartbeats — chatty clients don't need to send extras.
117
+ let last_receive_time = 0;
118
+ let heartbeat_timer = null;
119
+ const stop_heartbeat_timer = () => {
120
+ if (heartbeat_timer !== null) {
121
+ clearInterval(heartbeat_timer);
122
+ heartbeat_timer = null;
123
+ }
124
+ };
86
125
  // Socket-scoped notification helper — routes to this socket only,
87
126
  // matches the `ctx.notify` semantics exposed to per-message handlers.
88
127
  const notify_socket = (ws) => (notify_method, notify_params) => {
@@ -99,6 +138,23 @@ export const register_action_ws = (options) => {
99
138
  const connection_id = transport.add_connection(ws, token_hash, account_id, api_token_id);
100
139
  captured_connection_id = connection_id;
101
140
  log.debug('ws opened', connection_id, event);
141
+ if (heartbeat_enabled) {
142
+ last_receive_time = Date.now();
143
+ heartbeat_timer = setInterval(() => {
144
+ const now = Date.now();
145
+ const silence = now - last_receive_time;
146
+ if (silence >= heartbeat_timeout) {
147
+ log.info(`heartbeat timeout (${silence}ms) — closing ${WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT}`, connection_id, identity.account_id);
148
+ stop_heartbeat_timer();
149
+ try {
150
+ ws.close(WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT, 'server heartbeat timeout');
151
+ }
152
+ catch (error) {
153
+ log.error('heartbeat timeout close failed:', error);
154
+ }
155
+ }
156
+ }, heartbeat_tick_interval);
157
+ }
102
158
  if (on_socket_open) {
103
159
  try {
104
160
  await on_socket_open({
@@ -122,6 +178,7 @@ export const register_action_ws = (options) => {
122
178
  }
123
179
  },
124
180
  onMessage: async (event, ws) => {
181
+ last_receive_time = Date.now();
125
182
  let json;
126
183
  try {
127
184
  json = JSON.parse(String(event.data)); // eslint-disable-line @typescript-eslint/no-base-to-string
@@ -136,9 +193,26 @@ export const register_action_ws = (options) => {
136
193
  ws.send(JSON.stringify(create_jsonrpc_error_response(null, jsonrpc_error_messages.invalid_request('batch JSON-RPC requests are not supported on WebSocket'))));
137
194
  return;
138
195
  }
139
- // Only handle requests (method + id). Notifications (no id) are silenced per JSON-RPC spec.
196
+ // Notifications (method + no id) `cancel` is intercepted
197
+ // for request-scoped cancellation; other notifications are
198
+ // silenced per JSON-RPC spec (consumer notification handlers
199
+ // are not a feature yet).
140
200
  if (!is_jsonrpc_request(json)) {
141
201
  if (typeof json === 'object' && json !== null && 'method' in json && !('id' in json)) {
202
+ if (json.method === CANCEL_METHOD) {
203
+ const parsed = CancelNotificationParams.safeParse(json.params);
204
+ if (!parsed.success) {
205
+ log.debug('cancel: invalid params, ignoring', parsed.error.issues);
206
+ return;
207
+ }
208
+ const controller = pending_controllers.get(parsed.data.request_id);
209
+ if (controller) {
210
+ controller.abort();
211
+ }
212
+ else {
213
+ log.debug('cancel: no pending request for id', parsed.data.request_id);
214
+ }
215
+ }
142
216
  return;
143
217
  }
144
218
  ws.send(JSON.stringify(create_jsonrpc_error_response(to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request())));
@@ -184,22 +258,27 @@ export const register_action_ws = (options) => {
184
258
  await wait(artificial_delay);
185
259
  }
186
260
  // Socket-scoped notification — routes to originator only, not
187
- // broadcast. Future work: other audiences account-scoped,
261
+ // broadcast. Same helper used in `on_socket_open` so both
262
+ // paths share one code path for send-and-log-on-failure.
263
+ // Future work: other audiences — account-scoped,
188
264
  // ACL-filtered, broadcast — likely via a transport-level
189
265
  // policy hook.
190
- const notify = (notify_method, notify_params) => {
191
- try {
192
- const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
193
- ws.send(JSON.stringify(notification));
194
- }
195
- catch (error) {
196
- log.error('notify send failed:', notify_method, error);
197
- }
198
- };
266
+ const notify = notify_socket(ws);
267
+ // Per-request controller — fires on explicit `cancel` or on
268
+ // socket close (via the socket_abort_controller chain below).
269
+ // Registered before dispatch so a cancel arriving mid-handler
270
+ // finds it; cleared in `finally` so late cancels for a
271
+ // completed id (or a future request that reuses the id) can't
272
+ // null-abort the wrong handler.
273
+ const request_controller = new AbortController();
274
+ pending_controllers.set(id, request_controller);
199
275
  const base = {
200
276
  request_id: id,
277
+ // Populated in `onOpen` before any message can dispatch —
278
+ // non-null assertion is safe.
279
+ connection_id: captured_connection_id,
201
280
  notify,
202
- signal: socket_abort_controller.signal,
281
+ signal: AbortSignal.any([socket_abort_controller.signal, request_controller.signal]),
203
282
  };
204
283
  const ctx = extend_context(base, c);
205
284
  try {
@@ -218,8 +297,12 @@ export const register_action_ws = (options) => {
218
297
  log.error('handler error:', method, error);
219
298
  ws.send(JSON.stringify(create_jsonrpc_error_response_from_thrown(id, error)));
220
299
  }
300
+ finally {
301
+ pending_controllers.delete(id);
302
+ }
221
303
  },
222
304
  onClose: async (event, ws) => {
305
+ stop_heartbeat_timer();
223
306
  socket_abort_controller.abort();
224
307
  if (on_socket_close && captured_connection_id) {
225
308
  try {
@@ -57,4 +57,14 @@ export interface CreateRpcClientOptions {
57
57
  * @returns a Proxy that responds to any method name found in the environment's specs
58
58
  */
59
59
  export declare const create_rpc_client: (options: CreateRpcClientOptions) => Record<string, (...args: Array<any>) => any>;
60
+ /**
61
+ * Per-call options accepted by every typed Proxy method. `signal` lets the
62
+ * caller cancel an in-flight request (sends the shared `cancel` notification
63
+ * on the WS path, aborts `fetch` on HTTP). `transport_name` overrides the
64
+ * per-method `transport_for_method` selector for this call.
65
+ */
66
+ export interface RpcClientCallOptions {
67
+ signal?: AbortSignal;
68
+ transport_name?: TransportName;
69
+ }
60
70
  //# sourceMappingURL=rpc_client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rpc_client.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/rpc_client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,yBAAyB,CAAC;AAOpE,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,iBAAiB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAC;AAM/E,8EAA8E;AAC9E,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,CAAC,IAAI,EAAE;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,iBAAiB,EAAE,oBAAoB,CAAA;KAAC,KAC5E;QACA,sBAAsB,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;KAC5C,GACD,SAAS,CAAC;CACb;AAED,uCAAuC;AACvC,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,UAAU,CAAC;IACjB,WAAW,EAAE,sBAAsB,CAAC;IACpC,kEAAkE;IAClE,OAAO,CAAC,EAAE,sBAAsB,CAAC;IACjC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,kBAAkB,CAAC;CAC1C;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,sBAAsB,KAC7B,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,CAgB7C,CAAC"}
1
+ {"version":3,"file":"rpc_client.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/rpc_client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,yBAAyB,CAAC;AAOpE,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,iBAAiB,CAAC;AAGnD;;;;;;;GAOG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAC;AAM/E,8EAA8E;AAC9E,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,CAAC,IAAI,EAAE;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,iBAAiB,EAAE,oBAAoB,CAAA;KAAC,KAC5E;QACA,sBAAsB,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;KAC5C,GACD,SAAS,CAAC;CACb;AAED,uCAAuC;AACvC,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,UAAU,CAAC;IACjB,WAAW,EAAE,sBAAsB,CAAC;IACpC,kEAAkE;IAClE,OAAO,CAAC,EAAE,sBAAsB,CAAC;IACjC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,kBAAkB,CAAC;CAC1C;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,sBAAsB,KAC7B,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,CAgB7C,CAAC;AA2DF;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACpC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,cAAc,CAAC,EAAE,aAAa,CAAC;CAC/B"}
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { create_action_event } from './action_event.js';
13
13
  import { is_send_request, is_notification_send, extract_action_result, } from './action_event_helpers.js';
14
+ import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
14
15
  /**
15
16
  * Creates a Proxy-based API from action specs.
16
17
  *
@@ -78,9 +79,19 @@ const create_sync_local_call_method = (environment, spec, actions) => {
78
79
  /**
79
80
  * Creates an asynchronous local call method.
80
81
  * Returns Result for type-safe error handling.
82
+ *
83
+ * Local calls don't traverse a transport, so `transport_name` is ignored and
84
+ * `signal` can only short-circuit before the synchronous handler runs (no
85
+ * cooperative interrupt mid-handler).
81
86
  */
82
87
  const create_async_local_call_method = (environment, spec, actions) => {
83
- return async (input) => {
88
+ return async (input, options) => {
89
+ if (options?.signal?.aborted) {
90
+ return {
91
+ ok: false,
92
+ error: jsonrpc_error_messages.internal_error(`${spec.method} aborted before execution`),
93
+ };
94
+ }
84
95
  const event = create_action_event(environment, spec, input);
85
96
  const action = actions?.add_from_json({
86
97
  method: spec.method,
@@ -95,7 +106,7 @@ const create_async_local_call_method = (environment, spec, actions) => {
95
106
  * Creates a request/response method that communicates over the network.
96
107
  */
97
108
  const create_request_response_method = (peer, environment, spec, actions, transport_for_method) => {
98
- return async (input) => {
109
+ return async (input, options) => {
99
110
  const event = create_action_event(environment, spec, input);
100
111
  const action = actions?.add_from_json({
101
112
  method: spec.method,
@@ -113,8 +124,10 @@ const create_request_response_method = (peer, environment, spec, actions, transp
113
124
  if (event.data.step !== 'handled') {
114
125
  return extract_action_result(event);
115
126
  }
116
- const transport_name = transport_for_method?.(spec.method);
117
- const response = await peer.send(event.data.request, transport_name ? { transport_name } : undefined);
127
+ const response = await peer.send(event.data.request, {
128
+ transport_name: options?.transport_name ?? transport_for_method?.(spec.method),
129
+ signal: options?.signal,
130
+ });
118
131
  event.transition('receive_response');
119
132
  // TODO @api shouldn't this happen in the peer like the other method calls?
120
133
  event.set_response(response);
@@ -128,7 +141,7 @@ const create_request_response_method = (peer, environment, spec, actions, transp
128
141
  * Returns Result<{value: void}> for consistency.
129
142
  */
130
143
  const create_remote_notification_method = (peer, environment, spec, actions, transport_for_method) => {
131
- return async (input) => {
144
+ return async (input, options) => {
132
145
  const event = create_action_event(environment, spec, input);
133
146
  const action = actions?.add_from_json({
134
147
  method: spec.method,
@@ -139,8 +152,10 @@ const create_remote_notification_method = (peer, environment, spec, actions, tra
139
152
  if (!is_notification_send(event.data))
140
153
  throw Error(); // TODO @many maybe make this an assertion helper?
141
154
  if (event.data.step === 'handled') {
142
- const transport_name = transport_for_method?.(spec.method);
143
- const send_result = await peer.send(event.data.notification, transport_name ? { transport_name } : undefined);
155
+ const send_result = await peer.send(event.data.notification, {
156
+ transport_name: options?.transport_name ?? transport_for_method?.(spec.method),
157
+ signal: options?.signal,
158
+ });
144
159
  // Check if notification failed to send
145
160
  if (send_result !== null) {
146
161
  environment.log?.error('notification send failed:', send_result.error);
@@ -5,15 +5,26 @@
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 type { Logger } from '@fuzdev/fuz_util/log.js';
27
+ import { type JsonrpcRequestId } from '../http/jsonrpc.js';
17
28
  import type { WebsocketConnection } from './transports_ws.js';
18
29
  /** Default WebSocket close code (normal closure). */
19
30
  export declare const DEFAULT_CLOSE_CODE = 1000;
@@ -23,6 +34,12 @@ export declare const DEFAULT_RECONNECT_DELAY = 1000;
23
34
  export declare const DEFAULT_RECONNECT_DELAY_MAX = 10000;
24
35
  /** Exponential backoff factor: delay = base * factor^(attempt-1). */
25
36
  export declare const DEFAULT_BACKOFF_FACTOR = 1.5;
37
+ /** Idle interval before sending a heartbeat (ms). */
38
+ export declare const DEFAULT_HEARTBEAT_INTERVAL = 30000;
39
+ /** Max receive silence before closing with {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT} (ms). */
40
+ export declare const DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60000;
41
+ /** Default bound on buffered requests while disconnected. Overflow rejects. */
42
+ export declare const DEFAULT_QUEUE_MAX_SIZE = 100;
26
43
  /**
27
44
  * Client-side WebSocket status.
28
45
  *
@@ -44,12 +61,48 @@ export interface FrontendWebsocketReconnectOptions {
44
61
  /** Exponential backoff factor. Defaults to 1.5. */
45
62
  factor?: number;
46
63
  }
64
+ export interface FrontendWebsocketHeartbeatOptions {
65
+ /**
66
+ * Idle duration (ms) after which a heartbeat is sent. Reset by any send or
67
+ * receive — chatty clients never emit extras. Defaults to
68
+ * {@link DEFAULT_HEARTBEAT_INTERVAL}.
69
+ */
70
+ interval?: number;
71
+ /**
72
+ * Receive-silence (ms) after which the client closes the socket with
73
+ * {@link WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT}, letting auto-reconnect kick
74
+ * in. Should be a comfortable multiple of {@link interval}. Defaults to
75
+ * {@link DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT}.
76
+ */
77
+ receive_timeout?: number;
78
+ }
79
+ export interface FrontendWebsocketQueueOptions {
80
+ /**
81
+ * Maximum number of requests held while the socket is disconnected.
82
+ * Enqueue past this rejects the new call with a `queue_overflow` error.
83
+ * Defaults to {@link DEFAULT_QUEUE_MAX_SIZE}.
84
+ */
85
+ max_size?: number;
86
+ }
47
87
  export interface FrontendWebsocketClientOptions {
48
88
  /**
49
89
  * Auto-reconnect policy. `false` disables reconnect entirely; `true` or
50
90
  * omit for default timing; pass an object to customize.
51
91
  */
52
92
  reconnect?: boolean | FrontendWebsocketReconnectOptions | null;
93
+ /**
94
+ * Activity-aware heartbeat. `true` or omit for defaults; `false` disables
95
+ * the timer entirely (only do this if the server side is also running
96
+ * without heartbeat); pass an object to tune `interval` / `receive_timeout`.
97
+ */
98
+ heartbeat?: boolean | FrontendWebsocketHeartbeatOptions;
99
+ /**
100
+ * Durable queue for {@link FrontendWebsocketClient.request}. `true` or omit
101
+ * for defaults; `false` disables buffering (requests while disconnected
102
+ * reject immediately). Raw {@link FrontendWebsocketClient.send} is never
103
+ * queued — use `request()` for RPC semantics.
104
+ */
105
+ queue?: boolean | FrontendWebsocketQueueOptions;
53
106
  /** Optional logger for diagnostic messages. */
54
107
  log?: Logger | null;
55
108
  }
@@ -132,6 +185,37 @@ export declare class FrontendWebsocketClient implements WebsocketConnection, Dis
132
185
  /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
133
186
  [Symbol.dispose](): void;
134
187
  send(data: object): boolean;
188
+ /**
189
+ * Promise-based JSON-RPC over the socket. Auto-assigns a monotonic request
190
+ * id (or uses an explicit one supplied via `options.id` — used by
191
+ * `FrontendWebsocketTransport` which delegates to this method and has its
192
+ * own peer-minted UUID), tracks the pending promise, and resolves when the
193
+ * server sends a matching response (or rejects on error frame, socket
194
+ * close, or aborted signal).
195
+ *
196
+ * Callers supplying an explicit `options.id` are responsible for
197
+ * uniqueness — the pending map is keyed by id, and a duplicate silently
198
+ * overwrites the prior entry. Auto-minted ids are monotonic and never
199
+ * collide with themselves or with peer-minted UUIDs (the types differ:
200
+ * integer vs string).
201
+ *
202
+ * While the socket is disconnected, the request is buffered in a bounded
203
+ * queue (default-on, `DEFAULT_QUEUE_MAX_SIZE`) and flushed on reopen. Pass
204
+ * `{queue: false}` to reject immediately when disconnected — used
205
+ * internally by the heartbeat, which must not fight the queue for the
206
+ * disconnect-detection slot.
207
+ *
208
+ * On `AbortSignal` fire: rejects the local promise *and* sends the shared
209
+ * `cancel` notification (`CANCEL_METHOD`) so the server-side dispatcher
210
+ * can abort the matching handler's `ctx.signal`. Suppressed for
211
+ * queued-but-never-sent (server doesn't know about it) and
212
+ * response-beat-cancel races.
213
+ */
214
+ request<R = unknown>(method: string, params?: unknown, options?: {
215
+ signal?: AbortSignal;
216
+ queue?: boolean;
217
+ id?: JsonrpcRequestId;
218
+ }): Promise<R>;
135
219
  add_message_handler(handler: SocketMessageHandler): () => void;
136
220
  add_error_handler(handler: SocketErrorHandler): () => void;
137
221
  }
@@ -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":"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"}