@hainiva/agent-sdk 0.0.0-stage → 0.2.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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/SECURITY.md +33 -0
- package/dist/cjs/agent.cjs +316 -0
- package/dist/cjs/agent.d.cts +235 -0
- package/dist/cjs/calls.cjs +277 -0
- package/dist/cjs/calls.d.cts +232 -0
- package/dist/cjs/contract.cjs +14 -0
- package/dist/cjs/contract.d.cts +215 -0
- package/dist/cjs/embed.cjs +11 -0
- package/dist/cjs/embed.d.cts +13 -0
- package/dist/cjs/emitter.cjs +50 -0
- package/dist/cjs/emitter.d.cts +22 -0
- package/dist/cjs/errors.cjs +28 -0
- package/dist/cjs/errors.d.cts +15 -0
- package/dist/cjs/events.cjs +97 -0
- package/dist/cjs/events.d.cts +97 -0
- package/dist/cjs/index.cjs +72 -0
- package/dist/cjs/index.d.cts +28 -0
- package/dist/cjs/livekit.cjs +56 -0
- package/dist/cjs/livekit.d.cts +35 -0
- package/dist/cjs/log.cjs +15 -0
- package/dist/cjs/log.d.cts +5 -0
- package/dist/cjs/react-native.cjs +27 -0
- package/dist/cjs/react-native.d.cts +38 -0
- package/dist/cjs/redact.cjs +71 -0
- package/dist/cjs/redact.d.cts +21 -0
- package/dist/cjs/rest.cjs +280 -0
- package/dist/cjs/rest.d.cts +166 -0
- package/dist/cjs/session.cjs +159 -0
- package/dist/cjs/session.d.cts +101 -0
- package/dist/cjs/singleFlight.cjs +49 -0
- package/dist/cjs/singleFlight.d.cts +32 -0
- package/dist/cjs/url.cjs +39 -0
- package/dist/cjs/url.d.cts +13 -0
- package/dist/cjs/web.cjs +28 -0
- package/dist/cjs/web.d.cts +23 -0
- package/dist/cjs/ws.cjs +246 -0
- package/dist/cjs/ws.d.cts +134 -0
- package/dist/esm/agent.d.ts +236 -0
- package/dist/esm/agent.d.ts.map +1 -0
- package/dist/esm/agent.js +313 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/calls.d.ts +233 -0
- package/dist/esm/calls.d.ts.map +1 -0
- package/dist/esm/calls.js +271 -0
- package/dist/esm/calls.js.map +1 -0
- package/dist/esm/contract.d.ts +216 -0
- package/dist/esm/contract.d.ts.map +1 -0
- package/dist/esm/contract.js +12 -0
- package/dist/esm/contract.js.map +1 -0
- package/dist/esm/embed.d.ts +14 -0
- package/dist/esm/embed.d.ts.map +1 -0
- package/dist/esm/embed.js +9 -0
- package/dist/esm/embed.js.map +1 -0
- package/dist/esm/emitter.d.ts +23 -0
- package/dist/esm/emitter.d.ts.map +1 -0
- package/dist/esm/emitter.js +47 -0
- package/dist/esm/emitter.js.map +1 -0
- package/dist/esm/errors.d.ts +16 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +25 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/events.d.ts +98 -0
- package/dist/esm/events.d.ts.map +1 -0
- package/dist/esm/events.js +90 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +29 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +29 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/livekit.d.ts +36 -0
- package/dist/esm/livekit.d.ts.map +1 -0
- package/dist/esm/livekit.js +54 -0
- package/dist/esm/livekit.js.map +1 -0
- package/dist/esm/log.d.ts +6 -0
- package/dist/esm/log.d.ts.map +1 -0
- package/dist/esm/log.js +13 -0
- package/dist/esm/log.js.map +1 -0
- package/dist/esm/react-native.d.ts +39 -0
- package/dist/esm/react-native.d.ts.map +1 -0
- package/dist/esm/react-native.js +23 -0
- package/dist/esm/react-native.js.map +1 -0
- package/dist/esm/redact.d.ts +22 -0
- package/dist/esm/redact.d.ts.map +1 -0
- package/dist/esm/redact.js +66 -0
- package/dist/esm/redact.js.map +1 -0
- package/dist/esm/rest.d.ts +167 -0
- package/dist/esm/rest.d.ts.map +1 -0
- package/dist/esm/rest.js +275 -0
- package/dist/esm/rest.js.map +1 -0
- package/dist/esm/session.d.ts +102 -0
- package/dist/esm/session.d.ts.map +1 -0
- package/dist/esm/session.js +155 -0
- package/dist/esm/session.js.map +1 -0
- package/dist/esm/singleFlight.d.ts +33 -0
- package/dist/esm/singleFlight.d.ts.map +1 -0
- package/dist/esm/singleFlight.js +46 -0
- package/dist/esm/singleFlight.js.map +1 -0
- package/dist/esm/url.d.ts +14 -0
- package/dist/esm/url.d.ts.map +1 -0
- package/dist/esm/url.js +37 -0
- package/dist/esm/url.js.map +1 -0
- package/dist/esm/web.d.ts +24 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +23 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/esm/ws.d.ts +135 -0
- package/dist/esm/ws.d.ts.map +1 -0
- package/dist/esm/ws.js +242 -0
- package/dist/esm/ws.js.map +1 -0
- package/package.json +80 -4
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { TypedEmitter } from "./emitter.cjs";
|
|
2
|
+
import { type Logger } from "./log.cjs";
|
|
3
|
+
import { Redactor } from "./redact.cjs";
|
|
4
|
+
/**
|
|
5
|
+
* Where the session is kept between launches. Same shape as @hainiva/app-sdk's.
|
|
6
|
+
* The default is in memory (nothing persisted). On the web, `localStorageStore`
|
|
7
|
+
* (`@hainiva/agent-sdk/web`) is opt-in; in React Native use
|
|
8
|
+
* `secureStoreSessionStore` (`@hainiva/agent-sdk/react-native`) — the refresh
|
|
9
|
+
* token then lives only in the Keychain / Android Keystore.
|
|
10
|
+
*/
|
|
11
|
+
export interface SessionStore {
|
|
12
|
+
get(): Promise<string | null> | string | null;
|
|
13
|
+
set(value: string): Promise<void> | void;
|
|
14
|
+
remove(): Promise<void> | void;
|
|
15
|
+
}
|
|
16
|
+
/** Keeps the session for the life of the page / JS context only. The default. */
|
|
17
|
+
export declare function memoryStore(): SessionStore;
|
|
18
|
+
/** A staff session. Fields beyond these are kept as the server sent them. */
|
|
19
|
+
export interface AgentSession {
|
|
20
|
+
accessToken: string;
|
|
21
|
+
/** ISO time. */
|
|
22
|
+
accessExpiresAt: string;
|
|
23
|
+
refreshToken: string;
|
|
24
|
+
/** ISO time. */
|
|
25
|
+
refreshExpiresAt: string;
|
|
26
|
+
[field: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Ask YOUR backend for a fresh staff assertion (a short-lived JWT it signs with
|
|
30
|
+
* the agent app's private key). Called every time a new session is needed —
|
|
31
|
+
* the SDK never caches or stores an assertion, and you must not either: each
|
|
32
|
+
* one carries a single-use `jti` and expires in minutes.
|
|
33
|
+
*/
|
|
34
|
+
export type GetAssertion = () => Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* How sessions are created and refreshed — bound by the REST client.
|
|
37
|
+
* TODO(S3b): implemented against `/agent/v1/sessions` once the S2 contract is final.
|
|
38
|
+
*/
|
|
39
|
+
export interface SessionTransport<S extends AgentSession = AgentSession> {
|
|
40
|
+
/** Exchange an assertion for a session (POST /agent/v1/sessions). */
|
|
41
|
+
exchange(assertion: string): Promise<S>;
|
|
42
|
+
/** Rotate: a new access token and (rotating) refresh token. */
|
|
43
|
+
refresh(refreshToken: string): Promise<Pick<S, "accessToken" | "accessExpiresAt"> & Partial<S>>;
|
|
44
|
+
/** End the session on the server (best effort). */
|
|
45
|
+
revoke?(session: S): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export interface SessionManagerOptions<S extends AgentSession> {
|
|
48
|
+
getAssertion: GetAssertion;
|
|
49
|
+
transport: SessionTransport<S>;
|
|
50
|
+
/** Default: memoryStore(). */
|
|
51
|
+
store?: SessionStore;
|
|
52
|
+
/** Refresh this long before the access token expires. Default 60 s. */
|
|
53
|
+
refreshSkewMs?: number;
|
|
54
|
+
now?: () => number;
|
|
55
|
+
redactor?: Redactor;
|
|
56
|
+
log?: Logger;
|
|
57
|
+
}
|
|
58
|
+
export interface SessionEvents<S extends AgentSession> {
|
|
59
|
+
/** A new access token (after sign-in or refresh): e.g. send it on the socket. */
|
|
60
|
+
token: string;
|
|
61
|
+
session: S | null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The staff session: sign in with a fresh assertion, keep it in the store,
|
|
65
|
+
* refresh before expiry (single flight — one refresh however many callers),
|
|
66
|
+
* sign in again with a NEW assertion when the refresh is refused.
|
|
67
|
+
*/
|
|
68
|
+
export declare class SessionManager<S extends AgentSession = AgentSession> {
|
|
69
|
+
private readonly o;
|
|
70
|
+
readonly events: TypedEmitter<SessionEvents<S>>;
|
|
71
|
+
private session;
|
|
72
|
+
private readonly store;
|
|
73
|
+
private readonly skew;
|
|
74
|
+
private readonly now;
|
|
75
|
+
readonly redactor: Redactor;
|
|
76
|
+
private readonly log;
|
|
77
|
+
private readonly refreshOnce;
|
|
78
|
+
private forceNext;
|
|
79
|
+
constructor(o: SessionManagerOptions<S>);
|
|
80
|
+
get current(): S | null;
|
|
81
|
+
/** Restore the stored session if it can still be refreshed, else sign in afresh. */
|
|
82
|
+
signIn(): Promise<S>;
|
|
83
|
+
/** A valid access token, refreshing first when it is about to expire. */
|
|
84
|
+
accessToken(): Promise<string>;
|
|
85
|
+
/**
|
|
86
|
+
* The server refused the access token (401 / socket 4001): the next
|
|
87
|
+
* accessToken() refreshes. Pass the token that was refused: a refusal of an
|
|
88
|
+
* OLDER token (a request that was in flight while another caller already
|
|
89
|
+
* refreshed) is ignored, so ten parallel 401s cause one refresh, not ten.
|
|
90
|
+
*/
|
|
91
|
+
invalidate(refused?: string): void;
|
|
92
|
+
/** Forget the session here only (the server already ended it): no revoke call. */
|
|
93
|
+
clear(): Promise<void>;
|
|
94
|
+
/** End the session here and (best effort) on the server. */
|
|
95
|
+
signOut(): Promise<void>;
|
|
96
|
+
private fresh;
|
|
97
|
+
private doRefresh;
|
|
98
|
+
private readonly freshSignIn;
|
|
99
|
+
private keep;
|
|
100
|
+
private adopt;
|
|
101
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.singleFlight = singleFlight;
|
|
4
|
+
exports.createTokenRefresher = createTokenRefresher;
|
|
5
|
+
/**
|
|
6
|
+
* Calls `fn` at most once at a time: callers that arrive while it runs share its
|
|
7
|
+
* result (or its error). The next call after it settles runs `fn` again.
|
|
8
|
+
*/
|
|
9
|
+
function singleFlight(fn) {
|
|
10
|
+
let inFlight = null;
|
|
11
|
+
return () => {
|
|
12
|
+
inFlight ?? (inFlight = (async () => {
|
|
13
|
+
try {
|
|
14
|
+
return await fn();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
inFlight = null;
|
|
18
|
+
}
|
|
19
|
+
})());
|
|
20
|
+
return inFlight;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Single-flight token refresh with a pluggable refresh function. Ten requests that
|
|
25
|
+
* find the token expired cause one refresh, not ten — which also keeps a
|
|
26
|
+
* rotating refresh token from being spent twice (reuse detection would then
|
|
27
|
+
* revoke the whole session family).
|
|
28
|
+
*/
|
|
29
|
+
function createTokenRefresher(o) {
|
|
30
|
+
const skew = o.skewMs ?? 60000;
|
|
31
|
+
const now = o.now ?? Date.now;
|
|
32
|
+
let current = null;
|
|
33
|
+
const run = singleFlight(async () => {
|
|
34
|
+
const t = await o.refresh();
|
|
35
|
+
if (!t || typeof t.token !== "string" || !t.token)
|
|
36
|
+
throw new Error("refresh returned no token");
|
|
37
|
+
current = t;
|
|
38
|
+
return t.token;
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
get valid() { return current !== null && current.expiresAt - now() > skew; },
|
|
42
|
+
async get() {
|
|
43
|
+
if (current && current.expiresAt - now() > skew)
|
|
44
|
+
return current.token;
|
|
45
|
+
return run();
|
|
46
|
+
},
|
|
47
|
+
invalidate() { current = null; },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calls `fn` at most once at a time: callers that arrive while it runs share its
|
|
3
|
+
* result (or its error). The next call after it settles runs `fn` again.
|
|
4
|
+
*/
|
|
5
|
+
export declare function singleFlight<T>(fn: () => Promise<T>): () => Promise<T>;
|
|
6
|
+
export interface RefreshedToken {
|
|
7
|
+
token: string;
|
|
8
|
+
/** Epoch ms. */
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
}
|
|
11
|
+
export interface TokenRefresherOptions {
|
|
12
|
+
/** Fetch a new token. Called once however many callers wait for it. */
|
|
13
|
+
refresh: () => Promise<RefreshedToken>;
|
|
14
|
+
/** Refresh this long before expiry. Default 60 s. */
|
|
15
|
+
skewMs?: number;
|
|
16
|
+
now?: () => number;
|
|
17
|
+
}
|
|
18
|
+
export interface TokenRefresher {
|
|
19
|
+
/** A token valid for at least `skewMs`, refreshing (once, shared) when needed. */
|
|
20
|
+
get(): Promise<string>;
|
|
21
|
+
/** Forget the current token (the server refused it): the next get() refreshes. */
|
|
22
|
+
invalidate(): void;
|
|
23
|
+
/** Whether a token is held that is still valid beyond the skew. */
|
|
24
|
+
readonly valid: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Single-flight token refresh with a pluggable refresh function. Ten requests that
|
|
28
|
+
* find the token expired cause one refresh, not ten — which also keeps a
|
|
29
|
+
* rotating refresh token from being spent twice (reuse detection would then
|
|
30
|
+
* revoke the whole session family).
|
|
31
|
+
*/
|
|
32
|
+
export declare function createTokenRefresher(o: TokenRefresherOptions): TokenRefresher;
|
package/dist/cjs/url.cjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.assertSafeUrl = assertSafeUrl;
|
|
4
|
+
const errors_js_1 = require("./errors.cjs");
|
|
5
|
+
const LOCAL = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
6
|
+
/** Query parameter names that would carry a credential. */
|
|
7
|
+
const CREDENTIAL_PARAM = /token|assertion|secret|password|passw|key|auth|jwt|session|signature|ticket/i;
|
|
8
|
+
/**
|
|
9
|
+
* A base / socket / media URL a partner configured, checked before use: TLS only,
|
|
10
|
+
* no user:password@, and no credential in the query string. Tokens travel in
|
|
11
|
+
* headers or in the socket's first frame — never in a URL, which ends up in
|
|
12
|
+
* access logs, proxies and browser history.
|
|
13
|
+
*/
|
|
14
|
+
function assertSafeUrl(raw, rules) {
|
|
15
|
+
let u;
|
|
16
|
+
try {
|
|
17
|
+
u = new URL(raw);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new errors_js_1.HainivaAgentError(0, "invalid_url", { kind: rules.kind });
|
|
21
|
+
}
|
|
22
|
+
const secure = rules.kind === "http" ? "https:" : "wss:";
|
|
23
|
+
const insecure = rules.kind === "http" ? "http:" : "ws:";
|
|
24
|
+
const local = LOCAL.has(u.hostname);
|
|
25
|
+
if (u.protocol !== secure && !(u.protocol === insecure && local && rules.allowInsecureLocalhost)) {
|
|
26
|
+
throw new errors_js_1.HainivaAgentError(0, "insecure_url", { expected: secure, got: u.protocol, host: u.host });
|
|
27
|
+
}
|
|
28
|
+
if (u.username || u.password)
|
|
29
|
+
throw new errors_js_1.HainivaAgentError(0, "credentials_in_url", { host: u.host });
|
|
30
|
+
let param = null;
|
|
31
|
+
u.searchParams.forEach((_v, name) => { if (!param && CREDENTIAL_PARAM.test(name))
|
|
32
|
+
param = name; });
|
|
33
|
+
if (param)
|
|
34
|
+
throw new errors_js_1.HainivaAgentError(0, "credentials_in_url", { host: u.host, param });
|
|
35
|
+
if (/eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/.test(u.search + u.hash + u.pathname)) {
|
|
36
|
+
throw new errors_js_1.HainivaAgentError(0, "credentials_in_url", { host: u.host });
|
|
37
|
+
}
|
|
38
|
+
return u;
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface UrlRules {
|
|
2
|
+
/** "http" for REST (https required), "ws" for sockets (wss required). */
|
|
3
|
+
kind: "http" | "ws";
|
|
4
|
+
/** Allow http:// / ws:// to localhost (local development only). Default false. */
|
|
5
|
+
allowInsecureLocalhost?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A base / socket / media URL a partner configured, checked before use: TLS only,
|
|
9
|
+
* no user:password@, and no credential in the query string. Tokens travel in
|
|
10
|
+
* headers or in the socket's first frame — never in a URL, which ends up in
|
|
11
|
+
* access logs, proxies and browser history.
|
|
12
|
+
*/
|
|
13
|
+
export declare function assertSafeUrl(raw: string, rules: UrlRules): URL;
|
package/dist/cjs/web.cjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.joinCallRoom = exports.memoryStore = void 0;
|
|
4
|
+
exports.localStorageStore = localStorageStore;
|
|
5
|
+
var session_js_1 = require("./session.cjs");
|
|
6
|
+
Object.defineProperty(exports, "memoryStore", { enumerable: true, get: function () { return session_js_1.memoryStore; } });
|
|
7
|
+
var livekit_js_1 = require("./livekit.cjs");
|
|
8
|
+
Object.defineProperty(exports, "joinCallRoom", { enumerable: true, get: function () { return livekit_js_1.joinCallRoom; } });
|
|
9
|
+
/**
|
|
10
|
+
* OPT-IN: keep the session in localStorage so a reload stays signed in.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ Any script running on your origin (an XSS, a compromised third-party script)
|
|
13
|
+
* can read localStorage — including the 30-day refresh token. Use it only with a
|
|
14
|
+
* strict Content-Security-Policy and no third-party scripts on the page. The
|
|
15
|
+
* default (memoryStore) keeps nothing: a reload signs in again with a fresh
|
|
16
|
+
* assertion from your backend, which is the safer choice.
|
|
17
|
+
*/
|
|
18
|
+
function localStorageStore(o = {}) {
|
|
19
|
+
const key = o.key ?? "hainiva.agent.session";
|
|
20
|
+
const storage = o.storage ?? globalThis.localStorage;
|
|
21
|
+
if (!storage)
|
|
22
|
+
throw new Error("localStorage is not available here; use memoryStore()");
|
|
23
|
+
return {
|
|
24
|
+
get: () => storage.getItem(key),
|
|
25
|
+
set: (v) => storage.setItem(key, v),
|
|
26
|
+
remove: () => storage.removeItem(key),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hainiva/agent-sdk/web — browser adapters: an opt-in localStorage session store
|
|
3
|
+
* and LiveKit call media (peer dependency `livekit-client`).
|
|
4
|
+
*/
|
|
5
|
+
import type { SessionStore } from "./session.cjs";
|
|
6
|
+
export { memoryStore } from "./session.cjs";
|
|
7
|
+
export { joinCallRoom, type JoinOptions, type MediaCall, type MediaJoin } from "./livekit.cjs";
|
|
8
|
+
export interface LocalStorageStoreOptions {
|
|
9
|
+
/** Default "hainiva.agent.session". */
|
|
10
|
+
key?: string;
|
|
11
|
+
/** Default globalThis.localStorage. */
|
|
12
|
+
storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* OPT-IN: keep the session in localStorage so a reload stays signed in.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ Any script running on your origin (an XSS, a compromised third-party script)
|
|
18
|
+
* can read localStorage — including the 30-day refresh token. Use it only with a
|
|
19
|
+
* strict Content-Security-Policy and no third-party scripts on the page. The
|
|
20
|
+
* default (memoryStore) keeps nothing: a reload signs in again with a fresh
|
|
21
|
+
* assertion from your backend, which is the safer choice.
|
|
22
|
+
*/
|
|
23
|
+
export declare function localStorageStore(o?: LocalStorageStoreOptions): SessionStore;
|
package/dist/cjs/ws.cjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ReconnectingSocket = void 0;
|
|
4
|
+
exports.backoffDelay = backoffDelay;
|
|
5
|
+
const emitter_js_1 = require("./emitter.cjs");
|
|
6
|
+
const errors_js_1 = require("./errors.cjs");
|
|
7
|
+
const log_js_1 = require("./log.cjs");
|
|
8
|
+
const redact_js_1 = require("./redact.cjs");
|
|
9
|
+
const url_js_1 = require("./url.cjs");
|
|
10
|
+
/** Exponential backoff with full jitter: between half and all of min(max, initial·2^attempt). */
|
|
11
|
+
function backoffDelay(attempt, initial = 1000, max = 30000, rnd = Math.random) {
|
|
12
|
+
const cap = Math.min(max, initial * 2 ** Math.max(0, attempt));
|
|
13
|
+
return Math.round(cap / 2 + rnd() * (cap / 2));
|
|
14
|
+
}
|
|
15
|
+
const OPEN = 1;
|
|
16
|
+
/**
|
|
17
|
+
* A WebSocket that stays connected: exponential backoff with jitter, auth in the
|
|
18
|
+
* first frame (the token is never in the URL), heartbeat with a dead-connection
|
|
19
|
+
* timeout, and a resume hook carrying the last sequence number seen. Frame shapes
|
|
20
|
+
* are the caller's: pass `authFrame`, `isReady`, `resume`, `seqOf` for the
|
|
21
|
+
* protocol (`/agent/v1/ws` bindings: TODO(S3b)).
|
|
22
|
+
*/
|
|
23
|
+
class ReconnectingSocket {
|
|
24
|
+
constructor(o) {
|
|
25
|
+
this.o = o;
|
|
26
|
+
this.ws = null;
|
|
27
|
+
this.st = "idle";
|
|
28
|
+
this.want = false;
|
|
29
|
+
this.authed = false;
|
|
30
|
+
this.attempt = 0;
|
|
31
|
+
this.seq = 0;
|
|
32
|
+
this.lastFrameAt = 0;
|
|
33
|
+
this.reconnectTimer = null;
|
|
34
|
+
this.authTimer = null;
|
|
35
|
+
this.pingTimer = null;
|
|
36
|
+
this.WS = o.WebSocket ?? globalThis.WebSocket;
|
|
37
|
+
this.redactor = o.redactor ?? new redact_js_1.Redactor();
|
|
38
|
+
this.log = (0, log_js_1.safeLogger)(o.log, this.redactor);
|
|
39
|
+
this.events = new emitter_js_1.TypedEmitter((e) => this.log("listener_error", e));
|
|
40
|
+
this.expiredCode = o.closeCodes?.tokenExpired ?? 4001;
|
|
41
|
+
this.revokedCode = o.closeCodes?.revoked ?? 4003;
|
|
42
|
+
}
|
|
43
|
+
get state() { return this.st; }
|
|
44
|
+
/** The highest sequence number received (what `resume` is called with). */
|
|
45
|
+
get lastSeq() { return this.seq; }
|
|
46
|
+
/** Restore a sequence number kept from an earlier run. */
|
|
47
|
+
setLastSeq(n) { if (Number.isFinite(n) && n >= 0)
|
|
48
|
+
this.seq = n; }
|
|
49
|
+
connect() {
|
|
50
|
+
this.want = true;
|
|
51
|
+
if (this.ws || this.reconnectTimer)
|
|
52
|
+
return;
|
|
53
|
+
if (!this.WS) {
|
|
54
|
+
this.fail(new errors_js_1.HainivaAgentError(0, "no_websocket"));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
void this.open();
|
|
58
|
+
}
|
|
59
|
+
disconnect() {
|
|
60
|
+
this.want = false;
|
|
61
|
+
this.clearTimers();
|
|
62
|
+
const ws = this.ws;
|
|
63
|
+
this.ws = null;
|
|
64
|
+
this.authed = false;
|
|
65
|
+
ws?.close(1000);
|
|
66
|
+
this.setState("closed");
|
|
67
|
+
}
|
|
68
|
+
/** Send a frame on an authenticated socket. False when not open (nothing is queued). */
|
|
69
|
+
send(frame) {
|
|
70
|
+
if (!this.ws || !this.authed || this.ws.readyState !== OPEN)
|
|
71
|
+
return false;
|
|
72
|
+
this.ws.send(this.encode(frame));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/** Hand a refreshed access token to the open socket (needs `tokenFrame`). */
|
|
76
|
+
updateToken(token) {
|
|
77
|
+
this.redactor.add(token);
|
|
78
|
+
return this.o.tokenFrame ? this.send(this.o.tokenFrame(token)) : false;
|
|
79
|
+
}
|
|
80
|
+
encode(f) { return (this.o.encode ?? JSON.stringify)(f); }
|
|
81
|
+
setState(s) {
|
|
82
|
+
if (this.st === s)
|
|
83
|
+
return;
|
|
84
|
+
this.st = s;
|
|
85
|
+
this.events.emit("state", s);
|
|
86
|
+
}
|
|
87
|
+
fail(e) {
|
|
88
|
+
this.log("socket_error", { code: e.code });
|
|
89
|
+
this.events.emit("error", e);
|
|
90
|
+
}
|
|
91
|
+
async open() {
|
|
92
|
+
this.setState(this.attempt ? "reconnecting" : "connecting");
|
|
93
|
+
let token;
|
|
94
|
+
let url;
|
|
95
|
+
try {
|
|
96
|
+
token = await this.o.getToken();
|
|
97
|
+
this.redactor.add(token);
|
|
98
|
+
url = typeof this.o.url === "function" ? await this.o.url() : this.o.url;
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
this.fail(errors_js_1.HainivaAgentError.from(e, "socket_auth_failed", this.redactor));
|
|
102
|
+
return this.scheduleReconnect();
|
|
103
|
+
}
|
|
104
|
+
if (!this.want)
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
(0, url_js_1.assertSafeUrl)(url, { kind: "ws", ...(this.o.allowInsecureLocalhost ? { allowInsecureLocalhost: true } : {}) });
|
|
108
|
+
if (token && url.includes(token))
|
|
109
|
+
throw new errors_js_1.HainivaAgentError(0, "credentials_in_url");
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
// A configuration mistake: retrying cannot fix it.
|
|
113
|
+
this.want = false;
|
|
114
|
+
this.setState("closed");
|
|
115
|
+
return this.fail(errors_js_1.HainivaAgentError.from(e, "invalid_url", this.redactor));
|
|
116
|
+
}
|
|
117
|
+
let ws;
|
|
118
|
+
try {
|
|
119
|
+
ws = new this.WS(url);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
this.fail(errors_js_1.HainivaAgentError.from(e, "socket_failed", this.redactor));
|
|
123
|
+
return this.scheduleReconnect();
|
|
124
|
+
}
|
|
125
|
+
this.ws = ws;
|
|
126
|
+
this.authed = false;
|
|
127
|
+
ws.onopen = () => {
|
|
128
|
+
if (this.ws !== ws)
|
|
129
|
+
return;
|
|
130
|
+
// First frame: the credential. Nothing else is sent before the server says ready.
|
|
131
|
+
ws.send(this.encode((this.o.authFrame ?? ((t) => ({ type: "auth", token: t })))(token)));
|
|
132
|
+
this.authTimer = setTimeout(() => {
|
|
133
|
+
if (this.ws === ws && !this.authed) {
|
|
134
|
+
this.log("auth_timeout");
|
|
135
|
+
ws.close(4000, "auth timeout");
|
|
136
|
+
}
|
|
137
|
+
}, this.o.authTimeoutMs ?? 10000);
|
|
138
|
+
};
|
|
139
|
+
ws.onmessage = (ev) => { if (this.ws === ws)
|
|
140
|
+
this.onFrame(ws, typeof ev.data === "string" ? ev.data : String(ev.data)); };
|
|
141
|
+
ws.onerror = () => { };
|
|
142
|
+
ws.onclose = (ev) => {
|
|
143
|
+
if (this.ws !== ws)
|
|
144
|
+
return;
|
|
145
|
+
this.ws = null;
|
|
146
|
+
this.authed = false;
|
|
147
|
+
this.clearTimers();
|
|
148
|
+
this.log("socket_closed", { code: ev.code });
|
|
149
|
+
if (!this.want)
|
|
150
|
+
return this.setState("closed");
|
|
151
|
+
if (ev.code === this.revokedCode) {
|
|
152
|
+
this.want = false;
|
|
153
|
+
this.setState("closed");
|
|
154
|
+
this.events.emit("revoked", { code: ev.code });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (this.o.closeCodes?.fatal?.includes(ev.code)) {
|
|
158
|
+
this.want = false;
|
|
159
|
+
this.setState("closed");
|
|
160
|
+
return this.fail(new errors_js_1.HainivaAgentError(0, `socket_closed_${ev.code}`, {}, this.redactor));
|
|
161
|
+
}
|
|
162
|
+
if (ev.code === this.expiredCode)
|
|
163
|
+
this.o.onTokenRejected?.();
|
|
164
|
+
this.scheduleReconnect();
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
onFrame(ws, raw) {
|
|
168
|
+
let f;
|
|
169
|
+
try {
|
|
170
|
+
f = (this.o.decode ?? JSON.parse)(raw);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this.lastFrameAt = Date.now();
|
|
176
|
+
if (!this.authed) {
|
|
177
|
+
const ready = this.o.isReady ?? ((x) => x?.["type"] === "ready");
|
|
178
|
+
if (!ready(f))
|
|
179
|
+
return; // nothing is delivered before the server accepted the credential
|
|
180
|
+
this.authed = true;
|
|
181
|
+
this.attempt = 0;
|
|
182
|
+
if (this.authTimer) {
|
|
183
|
+
clearTimeout(this.authTimer);
|
|
184
|
+
this.authTimer = null;
|
|
185
|
+
}
|
|
186
|
+
this.startHeartbeat(ws);
|
|
187
|
+
this.setState("open");
|
|
188
|
+
const resume = this.seq > 0 ? this.o.resume?.(this.seq) : null;
|
|
189
|
+
if (resume)
|
|
190
|
+
ws.send(this.encode(resume));
|
|
191
|
+
this.events.emit("ready", f);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const seqOf = this.o.seqOf ?? ((x) => { const s = x?.["seq"]; return typeof s === "number" ? s : undefined; });
|
|
195
|
+
const s = seqOf(f);
|
|
196
|
+
if (s !== undefined) {
|
|
197
|
+
if (s <= this.seq)
|
|
198
|
+
return; // a replayed frame already delivered
|
|
199
|
+
this.seq = s;
|
|
200
|
+
}
|
|
201
|
+
this.events.emit("frame", f);
|
|
202
|
+
}
|
|
203
|
+
startHeartbeat(ws) {
|
|
204
|
+
const hb = this.o.heartbeat;
|
|
205
|
+
if (hb === false)
|
|
206
|
+
return;
|
|
207
|
+
const every = hb?.intervalMs ?? 20000;
|
|
208
|
+
const dead = hb?.timeoutMs ?? 45000;
|
|
209
|
+
const frame = hb?.frame ?? (() => ({ type: "ping" }));
|
|
210
|
+
this.pingTimer = setInterval(() => {
|
|
211
|
+
if (this.ws !== ws)
|
|
212
|
+
return;
|
|
213
|
+
if (Date.now() - this.lastFrameAt > dead) {
|
|
214
|
+
this.log("heartbeat_timeout");
|
|
215
|
+
ws.close(4000, "heartbeat timeout");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (ws.readyState === OPEN)
|
|
219
|
+
ws.send(this.encode(frame()));
|
|
220
|
+
}, every);
|
|
221
|
+
}
|
|
222
|
+
scheduleReconnect() {
|
|
223
|
+
if (!this.want)
|
|
224
|
+
return;
|
|
225
|
+
const b = this.o.backoff;
|
|
226
|
+
const d = backoffDelay(this.attempt++, b?.initialMs, b?.maxMs, b?.random);
|
|
227
|
+
this.setState("reconnecting");
|
|
228
|
+
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; if (this.want)
|
|
229
|
+
void this.open(); }, d);
|
|
230
|
+
}
|
|
231
|
+
clearTimers() {
|
|
232
|
+
if (this.reconnectTimer) {
|
|
233
|
+
clearTimeout(this.reconnectTimer);
|
|
234
|
+
this.reconnectTimer = null;
|
|
235
|
+
}
|
|
236
|
+
if (this.authTimer) {
|
|
237
|
+
clearTimeout(this.authTimer);
|
|
238
|
+
this.authTimer = null;
|
|
239
|
+
}
|
|
240
|
+
if (this.pingTimer) {
|
|
241
|
+
clearInterval(this.pingTimer);
|
|
242
|
+
this.pingTimer = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
exports.ReconnectingSocket = ReconnectingSocket;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { TypedEmitter } from "./emitter.cjs";
|
|
2
|
+
import { HainivaAgentError } from "./errors.cjs";
|
|
3
|
+
import { type Logger } from "./log.cjs";
|
|
4
|
+
import { Redactor } from "./redact.cjs";
|
|
5
|
+
export type ConnectionState = "idle" | "connecting" | "open" | "reconnecting" | "closed";
|
|
6
|
+
/** Exponential backoff with full jitter: between half and all of min(max, initial·2^attempt). */
|
|
7
|
+
export declare function backoffDelay(attempt: number, initial?: number, max?: number, rnd?: () => number): number;
|
|
8
|
+
/** The part of the WebSocket API this client uses (browser, React Native, `ws` in Node). */
|
|
9
|
+
export interface WebSocketLike {
|
|
10
|
+
readonly readyState: number;
|
|
11
|
+
send(data: string): void;
|
|
12
|
+
close(code?: number, reason?: string): void;
|
|
13
|
+
onopen: ((ev: unknown) => void) | null;
|
|
14
|
+
onmessage: ((ev: {
|
|
15
|
+
data: unknown;
|
|
16
|
+
}) => void) | null;
|
|
17
|
+
onclose: ((ev: {
|
|
18
|
+
code: number;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}) => void) | null;
|
|
21
|
+
onerror: ((ev: unknown) => void) | null;
|
|
22
|
+
}
|
|
23
|
+
export type WebSocketCtor = new (url: string) => WebSocketLike;
|
|
24
|
+
type AnyFrame = Record<string, unknown>;
|
|
25
|
+
export interface SocketOptions<In = AnyFrame, Out = AnyFrame> {
|
|
26
|
+
/** wss:// URL, or a function returning it. Never carries a credential (checked). */
|
|
27
|
+
url: string | (() => string | Promise<string>);
|
|
28
|
+
/** A valid access token (e.g. `session.accessToken()`); sent in the first frame. */
|
|
29
|
+
getToken: () => Promise<string>;
|
|
30
|
+
/** The server refused the token (close code `tokenExpired`): e.g. `session.invalidate()`. */
|
|
31
|
+
onTokenRejected?: () => void;
|
|
32
|
+
WebSocket?: WebSocketCtor;
|
|
33
|
+
/** First frame after open. Default `{ type: "auth", token }`. */
|
|
34
|
+
authFrame?: (token: string) => Out;
|
|
35
|
+
/** Whether a frame is the server's "authenticated" answer. Default `type === "ready"`. */
|
|
36
|
+
isReady?: (frame: In) => boolean;
|
|
37
|
+
/** Give up on an open socket that is not ready after this long. Default 10 s. */
|
|
38
|
+
authTimeoutMs?: number;
|
|
39
|
+
/** Keep-alive. `false` turns it off. */
|
|
40
|
+
heartbeat?: false | {
|
|
41
|
+
/** Send `frame()` this often. Default 20 s. */
|
|
42
|
+
intervalMs?: number;
|
|
43
|
+
/** Reconnect when nothing at all was received for this long. Default 45 s. */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
/** Default `{ type: "ping" }`. */
|
|
46
|
+
frame?: () => Out;
|
|
47
|
+
};
|
|
48
|
+
/** Resume hook: the frame to send after `ready` when frames were seen before. */
|
|
49
|
+
resume?: (lastSeq: number) => Out | null;
|
|
50
|
+
/** A frame's sequence number, if it has one. Default: numeric `seq`. */
|
|
51
|
+
seqOf?: (frame: In) => number | undefined;
|
|
52
|
+
/** Frame to hand the server a refreshed access token on an open socket. */
|
|
53
|
+
tokenFrame?: (token: string) => Out;
|
|
54
|
+
encode?: (frame: Out) => string;
|
|
55
|
+
decode?: (raw: string) => In;
|
|
56
|
+
backoff?: {
|
|
57
|
+
initialMs?: number;
|
|
58
|
+
maxMs?: number;
|
|
59
|
+
random?: () => number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Server close codes. Defaults: tokenExpired 4001 (refresh and reconnect),
|
|
63
|
+
* revoked 4003 (stop). `fatal`: codes reconnecting cannot fix (stop, emit
|
|
64
|
+
* `error` with code `socket_closed_<code>`). Default none.
|
|
65
|
+
*/
|
|
66
|
+
closeCodes?: {
|
|
67
|
+
tokenExpired?: number;
|
|
68
|
+
revoked?: number;
|
|
69
|
+
fatal?: number[];
|
|
70
|
+
};
|
|
71
|
+
/** Allow ws://localhost in development. Default false. */
|
|
72
|
+
allowInsecureLocalhost?: boolean;
|
|
73
|
+
log?: Logger;
|
|
74
|
+
redactor?: Redactor;
|
|
75
|
+
}
|
|
76
|
+
export interface SocketEvents<In> {
|
|
77
|
+
state: ConnectionState;
|
|
78
|
+
/** Authenticated (after every (re)connect). */
|
|
79
|
+
ready: In;
|
|
80
|
+
/** Every frame after `ready`, except ones already seen (seq ≤ lastSeq). */
|
|
81
|
+
frame: In;
|
|
82
|
+
/** The server revoked the session: the socket stays closed until connect() is called again. */
|
|
83
|
+
revoked: {
|
|
84
|
+
code: number;
|
|
85
|
+
};
|
|
86
|
+
error: HainivaAgentError;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A WebSocket that stays connected: exponential backoff with jitter, auth in the
|
|
90
|
+
* first frame (the token is never in the URL), heartbeat with a dead-connection
|
|
91
|
+
* timeout, and a resume hook carrying the last sequence number seen. Frame shapes
|
|
92
|
+
* are the caller's: pass `authFrame`, `isReady`, `resume`, `seqOf` for the
|
|
93
|
+
* protocol (`/agent/v1/ws` bindings: TODO(S3b)).
|
|
94
|
+
*/
|
|
95
|
+
export declare class ReconnectingSocket<In = AnyFrame, Out = AnyFrame> {
|
|
96
|
+
private readonly o;
|
|
97
|
+
readonly events: TypedEmitter<SocketEvents<In>>;
|
|
98
|
+
private ws;
|
|
99
|
+
private st;
|
|
100
|
+
private want;
|
|
101
|
+
private authed;
|
|
102
|
+
private attempt;
|
|
103
|
+
private seq;
|
|
104
|
+
private lastFrameAt;
|
|
105
|
+
private reconnectTimer;
|
|
106
|
+
private authTimer;
|
|
107
|
+
private pingTimer;
|
|
108
|
+
private readonly WS;
|
|
109
|
+
private readonly redactor;
|
|
110
|
+
private readonly log;
|
|
111
|
+
private readonly expiredCode;
|
|
112
|
+
private readonly revokedCode;
|
|
113
|
+
constructor(o: SocketOptions<In, Out>);
|
|
114
|
+
get state(): ConnectionState;
|
|
115
|
+
/** The highest sequence number received (what `resume` is called with). */
|
|
116
|
+
get lastSeq(): number;
|
|
117
|
+
/** Restore a sequence number kept from an earlier run. */
|
|
118
|
+
setLastSeq(n: number): void;
|
|
119
|
+
connect(): void;
|
|
120
|
+
disconnect(): void;
|
|
121
|
+
/** Send a frame on an authenticated socket. False when not open (nothing is queued). */
|
|
122
|
+
send(frame: Out): boolean;
|
|
123
|
+
/** Hand a refreshed access token to the open socket (needs `tokenFrame`). */
|
|
124
|
+
updateToken(token: string): boolean;
|
|
125
|
+
private encode;
|
|
126
|
+
private setState;
|
|
127
|
+
private fail;
|
|
128
|
+
private open;
|
|
129
|
+
private onFrame;
|
|
130
|
+
private startHeartbeat;
|
|
131
|
+
private scheduleReconnect;
|
|
132
|
+
private clearTimers;
|
|
133
|
+
}
|
|
134
|
+
export {};
|