@fuzdev/fuz_app 0.17.0 → 0.18.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.
@@ -96,8 +96,8 @@ export interface RegisterActionWsResult {
96
96
  * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
97
97
  * - Per-action auth: `public` / `authenticated` pass through (upgrade auth
98
98
  * already verified identity); `keeper` requires `daemon_token` credential
99
- * type *and* the keeper role; role-based `{role}` is currently rejected as
100
- * not-yet-supported.
99
+ * type *and* the keeper role; role-based `{role}` requires the named role
100
+ * via `has_role`, matching the HTTP path in `action_rpc.ts`.
101
101
  * - DEV mode validates handler output against the spec's `output` schema and
102
102
  * warns on mismatches.
103
103
  *
@@ -42,8 +42,8 @@ import { BackendWebsocketTransport } from './transports_ws_backend.js';
42
42
  * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
43
43
  * - Per-action auth: `public` / `authenticated` pass through (upgrade auth
44
44
  * already verified identity); `keeper` requires `daemon_token` credential
45
- * type *and* the keeper role; role-based `{role}` is currently rejected as
46
- * not-yet-supported.
45
+ * type *and* the keeper role; role-based `{role}` requires the named role
46
+ * via `has_role`, matching the HTTP path in `action_rpc.ts`.
47
47
  * - DEV mode validates handler output against the spec's `output` schema and
48
48
  * warns on mismatches.
49
49
  *
@@ -118,8 +118,10 @@ export const register_action_ws = (options) => {
118
118
  }
119
119
  }
120
120
  else if (typeof auth === 'object' && auth !== null) {
121
- ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.internal_error('role-based action auth is not yet supported on WebSocket'))));
122
- return;
121
+ if (!has_role(request_context, auth.role)) {
122
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.forbidden(`requires role: ${auth.role}`))));
123
+ return;
124
+ }
123
125
  }
124
126
  // Look up handler — method is validated against spec_by_method above.
125
127
  const handler = handlers[method];
@@ -12,6 +12,16 @@
12
12
  import type { ActionEventEnvironment } from './action_event_types.js';
13
13
  import type { ActionPeer } from './action_peer.js';
14
14
  import type { ActionEventDataUnion } from './action_event_data.js';
15
+ import type { TransportName } from './transports.js';
16
+ /**
17
+ * Optional per-method transport selector. Return the transport to use for a
18
+ * given method, or `undefined` to let the peer pick via its fallback rules.
19
+ *
20
+ * Useful when methods are registered on different backend dispatchers — e.g.
21
+ * a streaming action mounted on the WebSocket endpoint while the rest of the
22
+ * RPC surface lives on HTTP.
23
+ */
24
+ export type TransportForMethod = (method: string) => TransportName | undefined;
15
25
  /** Duck-typed action history — consumers pass their concrete Actions cell. */
16
26
  export interface RpcClientActionHistory {
17
27
  add_from_json: (json: {
@@ -27,6 +37,13 @@ export interface CreateRpcClientOptions {
27
37
  environment: ActionEventEnvironment;
28
38
  /** Optional action history tracking (duck-typed Actions cell). */
29
39
  actions?: RpcClientActionHistory;
40
+ /**
41
+ * Optional per-method transport selector. When provided, the client calls
42
+ * `peer.send(msg, {transport_name})` with the returned transport for each
43
+ * `request_response` / `remote_notification` dispatch. Returning `undefined`
44
+ * falls back to the peer's default selection.
45
+ */
46
+ transport_for_method?: TransportForMethod;
30
47
  }
31
48
  /**
32
49
  * Creates a Proxy-based API from action specs.
@@ -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;AAMjE,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;CACjC;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;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"}
@@ -23,14 +23,14 @@ import { is_send_request, is_notification_send, extract_action_result, } from '.
23
23
  * @returns a Proxy that responds to any method name found in the environment's specs
24
24
  */
25
25
  export const create_rpc_client = (options) => {
26
- const { peer, environment, actions } = options;
26
+ const { peer, environment, actions, transport_for_method } = options;
27
27
  return new Proxy({}, {
28
28
  get(_target, method) {
29
29
  const spec = environment.lookup_action_spec(method);
30
30
  if (!spec) {
31
31
  return undefined;
32
32
  }
33
- return create_action_method(peer, environment, spec, actions);
33
+ return create_action_method(peer, environment, spec, actions, transport_for_method);
34
34
  },
35
35
  has(_target, method) {
36
36
  return environment.lookup_action_spec(method) !== undefined;
@@ -40,16 +40,16 @@ export const create_rpc_client = (options) => {
40
40
  /**
41
41
  * Creates a method that executes an action through its complete lifecycle.
42
42
  */
43
- const create_action_method = (peer, environment, spec, actions) => {
43
+ const create_action_method = (peer, environment, spec, actions, transport_for_method) => {
44
44
  switch (spec.kind) {
45
45
  case 'local_call':
46
46
  return spec.async
47
47
  ? create_async_local_call_method(environment, spec, actions)
48
48
  : create_sync_local_call_method(environment, spec, actions);
49
49
  case 'request_response':
50
- return create_request_response_method(peer, environment, spec, actions);
50
+ return create_request_response_method(peer, environment, spec, actions, transport_for_method);
51
51
  case 'remote_notification':
52
- return create_remote_notification_method(peer, environment, spec, actions);
52
+ return create_remote_notification_method(peer, environment, spec, actions, transport_for_method);
53
53
  }
54
54
  };
55
55
  /**
@@ -94,7 +94,7 @@ const create_async_local_call_method = (environment, spec, actions) => {
94
94
  /**
95
95
  * Creates a request/response method that communicates over the network.
96
96
  */
97
- const create_request_response_method = (peer, environment, spec, actions) => {
97
+ const create_request_response_method = (peer, environment, spec, actions, transport_for_method) => {
98
98
  return async (input) => {
99
99
  const event = create_action_event(environment, spec, input);
100
100
  const action = actions?.add_from_json({
@@ -113,7 +113,8 @@ const create_request_response_method = (peer, environment, spec, actions) => {
113
113
  if (event.data.step !== 'handled') {
114
114
  return extract_action_result(event);
115
115
  }
116
- const response = await peer.send(event.data.request);
116
+ const transport_name = transport_for_method?.(spec.method);
117
+ const response = await peer.send(event.data.request, transport_name ? { transport_name } : undefined);
117
118
  event.transition('receive_response');
118
119
  // TODO @api shouldn't this happen in the peer like the other method calls?
119
120
  event.set_response(response);
@@ -126,7 +127,7 @@ const create_request_response_method = (peer, environment, spec, actions) => {
126
127
  * Creates a remote notification method (fire and forget).
127
128
  * Returns Result<{value: void}> for consistency.
128
129
  */
129
- const create_remote_notification_method = (peer, environment, spec, actions) => {
130
+ const create_remote_notification_method = (peer, environment, spec, actions, transport_for_method) => {
130
131
  return async (input) => {
131
132
  const event = create_action_event(environment, spec, input);
132
133
  const action = actions?.add_from_json({
@@ -138,7 +139,8 @@ const create_remote_notification_method = (peer, environment, spec, actions) =>
138
139
  if (!is_notification_send(event.data))
139
140
  throw Error(); // TODO @many maybe make this an assertion helper?
140
141
  if (event.data.step === 'handled') {
141
- const send_result = await peer.send(event.data.notification);
142
+ const transport_name = transport_for_method?.(spec.method);
143
+ const send_result = await peer.send(event.data.notification, transport_name ? { transport_name } : undefined);
142
144
  // Check if notification failed to send
143
145
  if (send_result !== null) {
144
146
  environment.log?.error('notification send failed:', send_result.error);
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Frontend WebSocket client — portable, Svelte-reactive, implements `WebsocketConnection`.
3
+ *
4
+ * Plain class with `$state` runes (no Cell inheritance, no app coupling).
5
+ * Drop into any SvelteKit frontend as the underlying connection for
6
+ * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
7
+ * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
8
+ * server revokes auth), and exposes reactive status for UI indicators.
9
+ *
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.
13
+ *
14
+ * @module
15
+ */
16
+ import type { Logger } from '@fuzdev/fuz_util/log.js';
17
+ import type { WebsocketConnection } from './transports_ws.js';
18
+ /** Default WebSocket close code (normal closure). */
19
+ export declare const DEFAULT_CLOSE_CODE = 1000;
20
+ /** Base reconnect delay in ms. */
21
+ export declare const DEFAULT_RECONNECT_DELAY = 1000;
22
+ /** Max reconnect delay in ms (cap on exponential backoff). */
23
+ export declare const DEFAULT_RECONNECT_DELAY_MAX = 10000;
24
+ /** Exponential backoff factor: delay = base * factor^(attempt-1). */
25
+ export declare const DEFAULT_BACKOFF_FACTOR = 1.5;
26
+ /**
27
+ * Client-side WebSocket status.
28
+ *
29
+ * - `initial` — never connected; `connect()` has not been called.
30
+ * - `connecting` — WebSocket `readyState === CONNECTING`.
31
+ * - `connected` — WebSocket `readyState === OPEN`.
32
+ * - `reconnecting` — close fired; waiting out backoff before next attempt.
33
+ * - `closed` — socket is not open. Terminal only when `revoked` is `true`
34
+ * or auto-reconnect is disabled; otherwise `connect()` reopens.
35
+ */
36
+ export type SocketStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
37
+ export type SocketMessageHandler = (event: MessageEvent) => void;
38
+ export type SocketErrorHandler = (event: Event) => void;
39
+ export interface FrontendWebsocketReconnectOptions {
40
+ /** Base reconnect delay in ms. Defaults to 1000. */
41
+ delay?: number;
42
+ /** Max reconnect delay in ms (cap on exponential backoff). Defaults to 10000. */
43
+ delay_max?: number;
44
+ /** Exponential backoff factor. Defaults to 1.5. */
45
+ factor?: number;
46
+ }
47
+ export interface FrontendWebsocketClientOptions {
48
+ /**
49
+ * Auto-reconnect policy. `false` disables reconnect entirely; `true` or
50
+ * omit for default timing; pass an object to customize.
51
+ */
52
+ reconnect?: boolean | FrontendWebsocketReconnectOptions | null;
53
+ /** Optional logger for diagnostic messages. */
54
+ log?: Logger | null;
55
+ }
56
+ /**
57
+ * Reactive WebSocket client implementing `WebsocketConnection`.
58
+ *
59
+ * Construct with a URL and optional config; call `connect()` to open the
60
+ * socket and begin auto-reconnect. Register message/error handlers via
61
+ * `add_message_handler` / `add_error_handler` — both return unsubscribe
62
+ * functions. `FrontendWebsocketTransport` consumes this as its connection.
63
+ *
64
+ * Session-revocation close codes (`WS_CLOSE_SESSION_REVOKED`) put the client
65
+ * in a permanently-closed state; reconnecting would just loop on 401.
66
+ */
67
+ export declare class FrontendWebsocketClient implements WebsocketConnection, Disposable {
68
+ #private;
69
+ ws: WebSocket | null;
70
+ status: SocketStatus;
71
+ reconnect_count: number;
72
+ current_reconnect_delay: number;
73
+ /** Epoch ms of the most recent successful open. Never cleared on close. */
74
+ last_connect_time: number | null;
75
+ /** Epoch ms of the most recent close event or client-initiated close. */
76
+ last_close_time: number | null;
77
+ /** Close code from the most recent close. Initial `null` means "never closed." */
78
+ last_close_code: number | null;
79
+ /** Reason string from the most recent close event (may be empty). */
80
+ last_close_reason: string | null;
81
+ readonly connected: boolean;
82
+ constructor(url: string, options?: FrontendWebsocketClientOptions);
83
+ get url(): string;
84
+ /**
85
+ * Whether the server has permanently closed the session. Once `true`, all
86
+ * `connect()` calls are no-ops. Distinct from `status:'closed'`, which
87
+ * reflects any closed state (incl. user-initiated `disconnect()`).
88
+ */
89
+ get revoked(): boolean;
90
+ /**
91
+ * Open the WebSocket. No-op on SSR, or if the session has been revoked.
92
+ * Cancels any pending reconnect and tears down any existing connection first;
93
+ * an open prior socket is closed with a normal-closure code.
94
+ */
95
+ connect(): void;
96
+ /**
97
+ * Close the WebSocket, cancel any pending reconnect, and reset the reconnect
98
+ * backoff counters. Puts the client in `closed` status; call `connect()` to
99
+ * reopen. Safe to call more than once.
100
+ */
101
+ disconnect(code?: number): void;
102
+ /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
103
+ [Symbol.dispose](): void;
104
+ send(data: object): boolean;
105
+ add_message_handler(handler: SocketMessageHandler): () => void;
106
+ add_error_handler(handler: SocketErrorHandler): () => void;
107
+ }
108
+ //# sourceMappingURL=socket.svelte.d.ts.map
@@ -0,0 +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;IAQpD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAWrE,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;IAW3B,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CA8G1D"}
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Frontend WebSocket client — portable, Svelte-reactive, implements `WebsocketConnection`.
3
+ *
4
+ * Plain class with `$state` runes (no Cell inheritance, no app coupling).
5
+ * Drop into any SvelteKit frontend as the underlying connection for
6
+ * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
7
+ * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
8
+ * server revokes auth), and exposes reactive status for UI indicators.
9
+ *
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.
13
+ *
14
+ * @module
15
+ */
16
+ import { BROWSER } from 'esm-env';
17
+ import { WS_CLOSE_SESSION_REVOKED } from './transports.js';
18
+ /** Default WebSocket close code (normal closure). */
19
+ export const DEFAULT_CLOSE_CODE = 1000;
20
+ /** Base reconnect delay in ms. */
21
+ export const DEFAULT_RECONNECT_DELAY = 1000;
22
+ /** Max reconnect delay in ms (cap on exponential backoff). */
23
+ export const DEFAULT_RECONNECT_DELAY_MAX = 10000;
24
+ /** Exponential backoff factor: delay = base * factor^(attempt-1). */
25
+ export const DEFAULT_BACKOFF_FACTOR = 1.5;
26
+ /**
27
+ * Reactive WebSocket client implementing `WebsocketConnection`.
28
+ *
29
+ * Construct with a URL and optional config; call `connect()` to open the
30
+ * socket and begin auto-reconnect. Register message/error handlers via
31
+ * `add_message_handler` / `add_error_handler` — both return unsubscribe
32
+ * functions. `FrontendWebsocketTransport` consumes this as its connection.
33
+ *
34
+ * Session-revocation close codes (`WS_CLOSE_SESSION_REVOKED`) put the client
35
+ * in a permanently-closed state; reconnecting would just loop on 401.
36
+ */
37
+ export class FrontendWebsocketClient {
38
+ #url;
39
+ #auto_reconnect;
40
+ #reconnect_delay;
41
+ #reconnect_delay_max;
42
+ #backoff_factor;
43
+ #log;
44
+ ws = $state.raw(null);
45
+ status = $state.raw('initial');
46
+ reconnect_count = $state.raw(0);
47
+ current_reconnect_delay = $state.raw(0);
48
+ /** Epoch ms of the most recent successful open. Never cleared on close. */
49
+ last_connect_time = $state.raw(null);
50
+ /** Epoch ms of the most recent close event or client-initiated close. */
51
+ last_close_time = $state.raw(null);
52
+ /** Close code from the most recent close. Initial `null` means "never closed." */
53
+ last_close_code = $state.raw(null);
54
+ /** Reason string from the most recent close event (may be empty). */
55
+ last_close_reason = $state.raw(null);
56
+ #reconnect_timeout = null;
57
+ #revoked = $state.raw(false);
58
+ #message_handlers = new Set();
59
+ #error_handlers = new Set();
60
+ connected = $derived(this.status === 'connected');
61
+ constructor(url, options = {}) {
62
+ this.#url = url;
63
+ const reconnect = options.reconnect;
64
+ this.#auto_reconnect = reconnect !== false;
65
+ const config = typeof reconnect === 'object' && reconnect !== null ? reconnect : {};
66
+ this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
67
+ this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
68
+ this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
69
+ this.#log = options.log ?? null;
70
+ }
71
+ get url() {
72
+ return this.#url;
73
+ }
74
+ /**
75
+ * Whether the server has permanently closed the session. Once `true`, all
76
+ * `connect()` calls are no-ops. Distinct from `status:'closed'`, which
77
+ * reflects any closed state (incl. user-initiated `disconnect()`).
78
+ */
79
+ get revoked() {
80
+ return this.#revoked;
81
+ }
82
+ /**
83
+ * Open the WebSocket. No-op on SSR, or if the session has been revoked.
84
+ * Cancels any pending reconnect and tears down any existing connection first;
85
+ * an open prior socket is closed with a normal-closure code.
86
+ */
87
+ connect() {
88
+ if (!BROWSER)
89
+ return;
90
+ if (this.#revoked)
91
+ return;
92
+ this.#cancel_reconnect();
93
+ this.#teardown(DEFAULT_CLOSE_CODE);
94
+ try {
95
+ this.status = 'connecting';
96
+ const ws = new WebSocket(this.#url);
97
+ this.ws = ws;
98
+ ws.addEventListener('open', this.#handle_open);
99
+ ws.addEventListener('close', this.#handle_close);
100
+ ws.addEventListener('error', this.#handle_error);
101
+ ws.addEventListener('message', this.#handle_message);
102
+ }
103
+ catch (error) {
104
+ this.#log?.error('[socket] failed to create WebSocket:', error);
105
+ this.ws = null;
106
+ if (this.#auto_reconnect) {
107
+ this.#schedule_reconnect();
108
+ }
109
+ else {
110
+ this.status = 'closed';
111
+ }
112
+ }
113
+ }
114
+ /**
115
+ * Close the WebSocket, cancel any pending reconnect, and reset the reconnect
116
+ * backoff counters. Puts the client in `closed` status; call `connect()` to
117
+ * reopen. Safe to call more than once.
118
+ */
119
+ disconnect(code = DEFAULT_CLOSE_CODE) {
120
+ this.#cancel_reconnect();
121
+ this.#teardown(code);
122
+ this.status = 'closed';
123
+ this.reconnect_count = 0;
124
+ this.current_reconnect_delay = 0;
125
+ }
126
+ /** Explicit-resource-management hook — supports `using client = new FrontendWebsocketClient(url)`. */
127
+ [Symbol.dispose]() {
128
+ this.disconnect();
129
+ }
130
+ send(data) {
131
+ if (!this.connected || !this.ws)
132
+ return false;
133
+ try {
134
+ this.ws.send(JSON.stringify(data));
135
+ return true;
136
+ }
137
+ catch (error) {
138
+ this.#log?.error('[socket] send failed:', error);
139
+ return false;
140
+ }
141
+ }
142
+ add_message_handler(handler) {
143
+ this.#message_handlers.add(handler);
144
+ return () => this.#message_handlers.delete(handler);
145
+ }
146
+ add_error_handler(handler) {
147
+ this.#error_handlers.add(handler);
148
+ return () => this.#error_handlers.delete(handler);
149
+ }
150
+ #teardown(close_code) {
151
+ if (!this.ws)
152
+ return;
153
+ this.ws.removeEventListener('open', this.#handle_open);
154
+ this.ws.removeEventListener('close', this.#handle_close);
155
+ this.ws.removeEventListener('error', this.#handle_error);
156
+ this.ws.removeEventListener('message', this.#handle_message);
157
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
158
+ try {
159
+ this.ws.close(close_code);
160
+ }
161
+ catch (error) {
162
+ this.#log?.error('[socket] close failed:', error);
163
+ }
164
+ // Listeners are gone, so `#handle_close` won't fire for this close —
165
+ // record it here so the client-initiated close is still observable.
166
+ this.#record_close(close_code, '');
167
+ }
168
+ this.ws = null;
169
+ }
170
+ #record_close(code, reason) {
171
+ this.last_close_time = Date.now();
172
+ this.last_close_code = code;
173
+ this.last_close_reason = reason;
174
+ }
175
+ #schedule_reconnect() {
176
+ if (!this.#auto_reconnect || this.#revoked)
177
+ return;
178
+ this.#cancel_reconnect();
179
+ this.reconnect_count++;
180
+ this.current_reconnect_delay = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** (this.reconnect_count - 1)));
181
+ this.status = 'reconnecting';
182
+ this.#reconnect_timeout = setTimeout(() => {
183
+ this.#reconnect_timeout = null;
184
+ this.connect();
185
+ }, this.current_reconnect_delay);
186
+ }
187
+ #cancel_reconnect() {
188
+ if (this.#reconnect_timeout !== null) {
189
+ clearTimeout(this.#reconnect_timeout);
190
+ this.#reconnect_timeout = null;
191
+ }
192
+ }
193
+ #handle_open = (_event) => {
194
+ this.status = 'connected';
195
+ this.reconnect_count = 0;
196
+ this.current_reconnect_delay = 0;
197
+ this.last_connect_time = Date.now();
198
+ this.#cancel_reconnect();
199
+ };
200
+ #handle_close = (event) => {
201
+ // Drop the dead-socket reference so consumers reading `client.ws` never
202
+ // see a CLOSED WebSocket during the reconnect window.
203
+ this.ws = null;
204
+ this.#record_close(event.code, event.reason);
205
+ // Session revocation is terminal — reconnecting would 401 in a loop.
206
+ if (event.code === WS_CLOSE_SESSION_REVOKED) {
207
+ this.#revoked = true;
208
+ this.status = 'closed';
209
+ this.#cancel_reconnect();
210
+ this.reconnect_count = 0;
211
+ this.current_reconnect_delay = 0;
212
+ return;
213
+ }
214
+ // Let `#schedule_reconnect` set `status: 'reconnecting'` directly to avoid
215
+ // a transient `'closed'` flicker; only set `'closed'` when reconnect is off.
216
+ if (this.#auto_reconnect) {
217
+ this.#schedule_reconnect();
218
+ }
219
+ else {
220
+ this.status = 'closed';
221
+ }
222
+ };
223
+ #handle_error = (event) => {
224
+ this.#log?.error('[socket] websocket error:', event);
225
+ for (const handler of this.#error_handlers) {
226
+ try {
227
+ handler(event);
228
+ }
229
+ catch (error) {
230
+ this.#log?.error('[socket] error handler threw:', error);
231
+ }
232
+ }
233
+ // Browsers fire `close` after error; reconnect logic lives there.
234
+ };
235
+ #handle_message = (event) => {
236
+ for (const handler of this.#message_handlers) {
237
+ try {
238
+ handler(event);
239
+ }
240
+ catch (error) {
241
+ this.#log?.error('[socket] message handler threw:', error);
242
+ }
243
+ }
244
+ };
245
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",