@fuzdev/fuz_app 0.17.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/rpc_client.d.ts +17 -0
- package/dist/actions/rpc_client.d.ts.map +1 -1
- package/dist/actions/rpc_client.js +11 -9
- package/dist/actions/socket.svelte.d.ts +108 -0
- package/dist/actions/socket.svelte.d.ts.map +1 -0
- package/dist/actions/socket.svelte.js +245 -0
- package/package.json +1 -1
|
@@ -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;
|
|
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
|
|
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
|
|
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
|
+
}
|