@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.
- package/dist/actions/action_types.d.ts +57 -0
- package/dist/actions/action_types.d.ts.map +1 -0
- package/dist/actions/action_types.js +11 -0
- package/dist/actions/heartbeat.d.ts +51 -0
- package/dist/actions/heartbeat.d.ts.map +1 -0
- package/dist/actions/heartbeat.js +50 -0
- package/dist/actions/register_action_ws.d.ts +28 -30
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +53 -3
- package/dist/actions/socket.svelte.d.ts +76 -4
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +288 -6
- package/dist/actions/transports.d.ts +4 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +4 -0
- package/dist/testing/ws_round_trip.d.ts +15 -3
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +3 -3
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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":"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;
|
|
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,
|
|
55
|
-
//
|
|
56
|
-
|
|
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),
|
|
8
|
+
* server revokes auth), exposes reactive status for UI indicators, and ships
|
|
9
|
+
* three correctness primitives default-on:
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
1
|
+
{"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"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"}
|
|
@@ -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),
|
|
8
|
+
* server revokes auth), exposes reactive status for UI indicators, and ships
|
|
9
|
+
* three correctness primitives default-on:
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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;
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAO,MAAM,MAAM,CAAC;AACxC,OAAO,EACN,SAAS,EAET,KAAK,gBAAgB,EAErB,KAAK,QAAQ,EACb,MAAM,SAAS,CAAC;AACjB,OAAO,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAE/C,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,2BAA2B,CAAC;
|
|
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 {
|
|
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
|
-
|
|
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,
|