@fuzdev/fuz_app 0.22.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 +116 -11
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +137 -56
- 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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAKpD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,oBAAoB,CAAC;AAE5D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,qDAAqD;AACrD,eAAO,MAAM,0BAA0B,QAAS,CAAC;AACjD,8FAA8F;AAC9F,eAAO,MAAM,iCAAiC,QAAS,CAAC;AACxD,+EAA+E;AAC/E,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iCAAiC;IACjD;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,6BAA6B;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,CAAC;IACxD;;;;;OAKG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,6BAA6B,CAAC;IAChD,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAiBD;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IA0B9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD;;;;;;;;OAQG;IACH,eAAe,EAAE,KAAK,GAAG,IAAI,CAAoB;IASjD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAwBrE;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IA4CzF,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;OAIG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;OAIG;IACH,OAAO,IAAI,IAAI;IA2Bf;;;;OAIG;IACH,UAAU,CAAC,IAAI,GAAE,MAA2B,GAAG,IAAI;IAUnD,sGAAsG;IACtG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAc3B;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,CAAC,GAAG,OAAO,EAClB,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,OAAY,EACpB,OAAO,GAAE;QAAC,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAM,GACnD,OAAO,CAAC,CAAC,CAAC;IAkEb,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CAqS1D"}
|