modeldispatcher-client 0.6.0 → 0.6.7
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/package.json +57 -52
- package/src/client.ts +148 -148
- package/src/events.ts +77 -77
- package/src/index.ts +56 -56
- package/src/interceptors/appcheck.ts +33 -33
- package/src/interceptors/auth.ts +34 -34
- package/src/interceptors/handoff.ts +91 -91
- package/src/interceptors/interceptor.ts +53 -53
- package/src/interceptors/retry.ts +92 -92
- package/src/interceptors/timeout.ts +48 -48
- package/src/react/useGateway.ts +67 -67
- package/src/types.ts +112 -112
package/package.json
CHANGED
|
@@ -1,52 +1,57 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "modeldispatcher-client",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Resilient TypeScript client for the ModelDispatcher AI gateway: interceptor pipeline with request timeouts, exponential backoff on 5xx, and structured 402/429 key-wizard handoff decoding.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "modeldispatcher-client",
|
|
3
|
+
"version": "0.6.7",
|
|
4
|
+
"description": "Resilient TypeScript client for the ModelDispatcher AI gateway: interceptor pipeline with request timeouts, exponential backoff on 5xx, and structured 402/429 key-wizard handoff decoding.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/joka-7/ModelDispatcher.git",
|
|
9
|
+
"directory": "clients/typescript"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./react": {
|
|
20
|
+
"types": "./dist/react/useGateway.d.ts",
|
|
21
|
+
"import": "./dist/react/useGateway.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.build.json",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"llm",
|
|
36
|
+
"gateway",
|
|
37
|
+
"interceptor",
|
|
38
|
+
"retry",
|
|
39
|
+
"backoff",
|
|
40
|
+
"firebase-app-check",
|
|
41
|
+
"react"
|
|
42
|
+
],
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"react": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/react": "^18.3.0",
|
|
53
|
+
"react": "^18.3.1",
|
|
54
|
+
"typescript": "^5.5.0",
|
|
55
|
+
"vitest": "^2.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,148 +1,148 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* {@link GatewayClient}: the facade a Vercel frontend calls.
|
|
3
|
-
*
|
|
4
|
-
* It composes the interceptor chain once (App Check → Auth → retry → timeout →
|
|
5
|
-
* fetch), then `dispatch` runs a request through it and hands the final
|
|
6
|
-
* response to the {@link decodeOutcome} decoder. Every transport failure is
|
|
7
|
-
* normalised into a `{ kind: "network" }` outcome, so callers only ever
|
|
8
|
-
* `switch` on {@link DispatchOutcome.kind} — no thrown errors escape `dispatch`.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { GatewayEventBus } from "./events.js";
|
|
12
|
-
import { appCheckInterceptor } from "./interceptors/appcheck.js";
|
|
13
|
-
import { authInterceptor } from "./interceptors/auth.js";
|
|
14
|
-
import { decodeOutcome } from "./interceptors/handoff.js";
|
|
15
|
-
import {
|
|
16
|
-
compose,
|
|
17
|
-
type GatewayRequestConfig,
|
|
18
|
-
type Interceptor,
|
|
19
|
-
type Send,
|
|
20
|
-
} from "./interceptors/interceptor.js";
|
|
21
|
-
import { type RetryOptions, retryInterceptor } from "./interceptors/retry.js";
|
|
22
|
-
import { TimeoutError, timeoutInterceptor } from "./interceptors/timeout.js";
|
|
23
|
-
import type {
|
|
24
|
-
AppCheckTokenProvider,
|
|
25
|
-
AuthTokenProvider,
|
|
26
|
-
DispatchOutcome,
|
|
27
|
-
GatewayResult,
|
|
28
|
-
NetworkError,
|
|
29
|
-
NetworkErrorKind,
|
|
30
|
-
} from "./types.js";
|
|
31
|
-
|
|
32
|
-
/** Construction options for {@link GatewayClient}. */
|
|
33
|
-
export interface GatewayClientOptions {
|
|
34
|
-
/** Endpoint the wrapper is deployed at. Defaults to `"/api/gateway"`. */
|
|
35
|
-
endpoint?: string;
|
|
36
|
-
/** Per-attempt timeout in milliseconds. Defaults to `15000`. */
|
|
37
|
-
timeoutMs?: number;
|
|
38
|
-
/** Firebase App Check token supplier. Omit to disable the header (dev). */
|
|
39
|
-
appCheckTokenProvider?: AppCheckTokenProvider;
|
|
40
|
-
/**
|
|
41
|
-
* Firebase Auth ID token supplier. Omit to disable the header (dev only —
|
|
42
|
-
* the wrapper's default `MD_AUTH_MODE=enforce` will reject every request
|
|
43
|
-
* with `401` without it; see `_lib/auth.py`).
|
|
44
|
-
*/
|
|
45
|
-
authTokenProvider?: AuthTokenProvider;
|
|
46
|
-
/** Retry/backoff tuning and injected clock (for tests). */
|
|
47
|
-
retry?: RetryOptions;
|
|
48
|
-
/** Shared event bus; a fresh one is created when omitted. */
|
|
49
|
-
bus?: GatewayEventBus;
|
|
50
|
-
/** Injectable `fetch` (defaults to the global). */
|
|
51
|
-
fetchImpl?: typeof fetch;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Per-call overrides for {@link GatewayClient.dispatch}. */
|
|
55
|
-
export interface DispatchOptions {
|
|
56
|
-
/** Caller cancellation, merged with the internal timeout. */
|
|
57
|
-
signal?: AbortSignal;
|
|
58
|
-
/** Extra headers to merge onto this request. */
|
|
59
|
-
headers?: Record<string, string>;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const DEFAULT_ENDPOINT = "/api/gateway";
|
|
63
|
-
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
64
|
-
|
|
65
|
-
function classifyNetworkError(error: unknown): NetworkError {
|
|
66
|
-
if (error instanceof TimeoutError) {
|
|
67
|
-
return { kind: "timeout", message: error.message, cause: error };
|
|
68
|
-
}
|
|
69
|
-
// `fetch` throws a TypeError for DNS/connection failures (offline).
|
|
70
|
-
if (error instanceof TypeError) {
|
|
71
|
-
return { kind: "offline", message: error.message, cause: error };
|
|
72
|
-
}
|
|
73
|
-
const kind: NetworkErrorKind = "unknown";
|
|
74
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
-
return { kind, message, cause: error };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Resilient client for the ModelDispatcher gateway endpoint. */
|
|
79
|
-
export class GatewayClient {
|
|
80
|
-
/** Event bus other subscribers (e.g. the wizard host) can listen on. */
|
|
81
|
-
readonly bus: GatewayEventBus;
|
|
82
|
-
|
|
83
|
-
readonly #endpoint: string;
|
|
84
|
-
readonly #timeoutMs: number;
|
|
85
|
-
readonly #send: Send;
|
|
86
|
-
|
|
87
|
-
constructor(options: GatewayClientOptions = {}) {
|
|
88
|
-
this.bus = options.bus ?? new GatewayEventBus();
|
|
89
|
-
this.#endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
|
90
|
-
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
91
|
-
|
|
92
|
-
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
93
|
-
|
|
94
|
-
const chain: Interceptor[] = [];
|
|
95
|
-
if (options.appCheckTokenProvider) {
|
|
96
|
-
chain.push(appCheckInterceptor(options.appCheckTokenProvider));
|
|
97
|
-
}
|
|
98
|
-
if (options.authTokenProvider) {
|
|
99
|
-
chain.push(authInterceptor(options.authTokenProvider));
|
|
100
|
-
}
|
|
101
|
-
chain.push(retryInterceptor(options.retry ?? {}));
|
|
102
|
-
chain.push(timeoutInterceptor());
|
|
103
|
-
|
|
104
|
-
const terminal: Send = (config: GatewayRequestConfig) =>
|
|
105
|
-
fetchImpl(config.url, {
|
|
106
|
-
method: config.method,
|
|
107
|
-
headers: config.headers,
|
|
108
|
-
body: JSON.stringify(config.body),
|
|
109
|
-
...(config.signal ? { signal: config.signal } : {}),
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
this.#send = compose(chain, terminal);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Dispatch a request body to the gateway and return a typed outcome.
|
|
117
|
-
*
|
|
118
|
-
* Never rejects: transport failures surface as `{ kind: "network" }`.
|
|
119
|
-
*
|
|
120
|
-
* @typeParam T - Expected success shape (defaults to {@link GatewayResult}).
|
|
121
|
-
*/
|
|
122
|
-
async dispatch<T = GatewayResult>(
|
|
123
|
-
body: unknown,
|
|
124
|
-
options: DispatchOptions = {},
|
|
125
|
-
): Promise<DispatchOutcome<T>> {
|
|
126
|
-
const config: GatewayRequestConfig = {
|
|
127
|
-
url: this.#endpoint,
|
|
128
|
-
method: "POST",
|
|
129
|
-
headers: { "content-type": "application/json", ...options.headers },
|
|
130
|
-
body,
|
|
131
|
-
timeoutMs: this.#timeoutMs,
|
|
132
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
let response: Response;
|
|
136
|
-
try {
|
|
137
|
-
response = await this.#send(config);
|
|
138
|
-
} catch (error) {
|
|
139
|
-
return { kind: "network", error: classifyNetworkError(error) };
|
|
140
|
-
}
|
|
141
|
-
return decodeOutcome<T>(response, this.bus);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Convenience factory mirroring the constructor. */
|
|
146
|
-
export function createGatewayClient(options: GatewayClientOptions = {}): GatewayClient {
|
|
147
|
-
return new GatewayClient(options);
|
|
148
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* {@link GatewayClient}: the facade a Vercel frontend calls.
|
|
3
|
+
*
|
|
4
|
+
* It composes the interceptor chain once (App Check → Auth → retry → timeout →
|
|
5
|
+
* fetch), then `dispatch` runs a request through it and hands the final
|
|
6
|
+
* response to the {@link decodeOutcome} decoder. Every transport failure is
|
|
7
|
+
* normalised into a `{ kind: "network" }` outcome, so callers only ever
|
|
8
|
+
* `switch` on {@link DispatchOutcome.kind} — no thrown errors escape `dispatch`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { GatewayEventBus } from "./events.js";
|
|
12
|
+
import { appCheckInterceptor } from "./interceptors/appcheck.js";
|
|
13
|
+
import { authInterceptor } from "./interceptors/auth.js";
|
|
14
|
+
import { decodeOutcome } from "./interceptors/handoff.js";
|
|
15
|
+
import {
|
|
16
|
+
compose,
|
|
17
|
+
type GatewayRequestConfig,
|
|
18
|
+
type Interceptor,
|
|
19
|
+
type Send,
|
|
20
|
+
} from "./interceptors/interceptor.js";
|
|
21
|
+
import { type RetryOptions, retryInterceptor } from "./interceptors/retry.js";
|
|
22
|
+
import { TimeoutError, timeoutInterceptor } from "./interceptors/timeout.js";
|
|
23
|
+
import type {
|
|
24
|
+
AppCheckTokenProvider,
|
|
25
|
+
AuthTokenProvider,
|
|
26
|
+
DispatchOutcome,
|
|
27
|
+
GatewayResult,
|
|
28
|
+
NetworkError,
|
|
29
|
+
NetworkErrorKind,
|
|
30
|
+
} from "./types.js";
|
|
31
|
+
|
|
32
|
+
/** Construction options for {@link GatewayClient}. */
|
|
33
|
+
export interface GatewayClientOptions {
|
|
34
|
+
/** Endpoint the wrapper is deployed at. Defaults to `"/api/gateway"`. */
|
|
35
|
+
endpoint?: string;
|
|
36
|
+
/** Per-attempt timeout in milliseconds. Defaults to `15000`. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
/** Firebase App Check token supplier. Omit to disable the header (dev). */
|
|
39
|
+
appCheckTokenProvider?: AppCheckTokenProvider;
|
|
40
|
+
/**
|
|
41
|
+
* Firebase Auth ID token supplier. Omit to disable the header (dev only —
|
|
42
|
+
* the wrapper's default `MD_AUTH_MODE=enforce` will reject every request
|
|
43
|
+
* with `401` without it; see `_lib/auth.py`).
|
|
44
|
+
*/
|
|
45
|
+
authTokenProvider?: AuthTokenProvider;
|
|
46
|
+
/** Retry/backoff tuning and injected clock (for tests). */
|
|
47
|
+
retry?: RetryOptions;
|
|
48
|
+
/** Shared event bus; a fresh one is created when omitted. */
|
|
49
|
+
bus?: GatewayEventBus;
|
|
50
|
+
/** Injectable `fetch` (defaults to the global). */
|
|
51
|
+
fetchImpl?: typeof fetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Per-call overrides for {@link GatewayClient.dispatch}. */
|
|
55
|
+
export interface DispatchOptions {
|
|
56
|
+
/** Caller cancellation, merged with the internal timeout. */
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
/** Extra headers to merge onto this request. */
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_ENDPOINT = "/api/gateway";
|
|
63
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
64
|
+
|
|
65
|
+
function classifyNetworkError(error: unknown): NetworkError {
|
|
66
|
+
if (error instanceof TimeoutError) {
|
|
67
|
+
return { kind: "timeout", message: error.message, cause: error };
|
|
68
|
+
}
|
|
69
|
+
// `fetch` throws a TypeError for DNS/connection failures (offline).
|
|
70
|
+
if (error instanceof TypeError) {
|
|
71
|
+
return { kind: "offline", message: error.message, cause: error };
|
|
72
|
+
}
|
|
73
|
+
const kind: NetworkErrorKind = "unknown";
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
+
return { kind, message, cause: error };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resilient client for the ModelDispatcher gateway endpoint. */
|
|
79
|
+
export class GatewayClient {
|
|
80
|
+
/** Event bus other subscribers (e.g. the wizard host) can listen on. */
|
|
81
|
+
readonly bus: GatewayEventBus;
|
|
82
|
+
|
|
83
|
+
readonly #endpoint: string;
|
|
84
|
+
readonly #timeoutMs: number;
|
|
85
|
+
readonly #send: Send;
|
|
86
|
+
|
|
87
|
+
constructor(options: GatewayClientOptions = {}) {
|
|
88
|
+
this.bus = options.bus ?? new GatewayEventBus();
|
|
89
|
+
this.#endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
|
90
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
91
|
+
|
|
92
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
93
|
+
|
|
94
|
+
const chain: Interceptor[] = [];
|
|
95
|
+
if (options.appCheckTokenProvider) {
|
|
96
|
+
chain.push(appCheckInterceptor(options.appCheckTokenProvider));
|
|
97
|
+
}
|
|
98
|
+
if (options.authTokenProvider) {
|
|
99
|
+
chain.push(authInterceptor(options.authTokenProvider));
|
|
100
|
+
}
|
|
101
|
+
chain.push(retryInterceptor(options.retry ?? {}));
|
|
102
|
+
chain.push(timeoutInterceptor());
|
|
103
|
+
|
|
104
|
+
const terminal: Send = (config: GatewayRequestConfig) =>
|
|
105
|
+
fetchImpl(config.url, {
|
|
106
|
+
method: config.method,
|
|
107
|
+
headers: config.headers,
|
|
108
|
+
body: JSON.stringify(config.body),
|
|
109
|
+
...(config.signal ? { signal: config.signal } : {}),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
this.#send = compose(chain, terminal);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Dispatch a request body to the gateway and return a typed outcome.
|
|
117
|
+
*
|
|
118
|
+
* Never rejects: transport failures surface as `{ kind: "network" }`.
|
|
119
|
+
*
|
|
120
|
+
* @typeParam T - Expected success shape (defaults to {@link GatewayResult}).
|
|
121
|
+
*/
|
|
122
|
+
async dispatch<T = GatewayResult>(
|
|
123
|
+
body: unknown,
|
|
124
|
+
options: DispatchOptions = {},
|
|
125
|
+
): Promise<DispatchOutcome<T>> {
|
|
126
|
+
const config: GatewayRequestConfig = {
|
|
127
|
+
url: this.#endpoint,
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "content-type": "application/json", ...options.headers },
|
|
130
|
+
body,
|
|
131
|
+
timeoutMs: this.#timeoutMs,
|
|
132
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let response: Response;
|
|
136
|
+
try {
|
|
137
|
+
response = await this.#send(config);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return { kind: "network", error: classifyNetworkError(error) };
|
|
140
|
+
}
|
|
141
|
+
return decodeOutcome<T>(response, this.bus);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Convenience factory mirroring the constructor. */
|
|
146
|
+
export function createGatewayClient(options: GatewayClientOptions = {}): GatewayClient {
|
|
147
|
+
return new GatewayClient(options);
|
|
148
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A tiny, strongly-typed event bus (Observer pattern).
|
|
3
|
-
*
|
|
4
|
-
* It exists to decouple *detecting* a Stage-2 handoff (deep inside the response
|
|
5
|
-
* pipeline) from *reacting* to it (a wizard component that may live anywhere in
|
|
6
|
-
* the tree). The interceptor publishes; {@link GatewayEventBus} fans the event
|
|
7
|
-
* out to every subscriber without either side knowing about the other.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { Handoff } from "./types.js";
|
|
11
|
-
|
|
12
|
-
/** Payload emitted whenever the gateway returns a structured handoff. */
|
|
13
|
-
export interface HandoffEvent {
|
|
14
|
-
/** HTTP status the gateway chose: `402` (budget wall) or `429` (rate window). */
|
|
15
|
-
readonly status: number;
|
|
16
|
-
/** The decoded handoff instruction. */
|
|
17
|
-
readonly handoff: Handoff;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** The map of event names to their payload types. */
|
|
21
|
-
export interface GatewayEventMap {
|
|
22
|
-
handoff: HandoffEvent;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Unsubscribe handle returned by {@link GatewayEventBus.on}. */
|
|
26
|
-
export type Unsubscribe = () => void;
|
|
27
|
-
|
|
28
|
-
/** Listener signature for a given event. */
|
|
29
|
-
export type Listener<E extends keyof GatewayEventMap> = (
|
|
30
|
-
event: GatewayEventMap[E],
|
|
31
|
-
) => void;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Synchronous, in-memory pub/sub for gateway events.
|
|
35
|
-
*
|
|
36
|
-
* Listener exceptions are swallowed (and forwarded to `onListenerError` when
|
|
37
|
-
* provided) so one bad subscriber can never break delivery to the others.
|
|
38
|
-
*/
|
|
39
|
-
export class GatewayEventBus {
|
|
40
|
-
readonly #listeners: {
|
|
41
|
-
[E in keyof GatewayEventMap]: Set<Listener<E>>;
|
|
42
|
-
} = { handoff: new Set() };
|
|
43
|
-
|
|
44
|
-
readonly #onListenerError: (error: unknown) => void;
|
|
45
|
-
|
|
46
|
-
constructor(onListenerError: (error: unknown) => void = () => {}) {
|
|
47
|
-
this.#onListenerError = onListenerError;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Subscribe to an event.
|
|
52
|
-
*
|
|
53
|
-
* @returns A function that removes this exact listener when called.
|
|
54
|
-
*/
|
|
55
|
-
on<E extends keyof GatewayEventMap>(event: E, listener: Listener<E>): Unsubscribe {
|
|
56
|
-
this.#listeners[event].add(listener);
|
|
57
|
-
return () => {
|
|
58
|
-
this.#listeners[event].delete(listener);
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Publish an event to every current subscriber. */
|
|
63
|
-
emit<E extends keyof GatewayEventMap>(event: E, payload: GatewayEventMap[E]): void {
|
|
64
|
-
for (const listener of this.#listeners[event]) {
|
|
65
|
-
try {
|
|
66
|
-
listener(payload);
|
|
67
|
-
} catch (error) {
|
|
68
|
-
this.#onListenerError(error);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Remove all listeners (useful in test teardown). */
|
|
74
|
-
clear(): void {
|
|
75
|
-
this.#listeners.handoff.clear();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* A tiny, strongly-typed event bus (Observer pattern).
|
|
3
|
+
*
|
|
4
|
+
* It exists to decouple *detecting* a Stage-2 handoff (deep inside the response
|
|
5
|
+
* pipeline) from *reacting* to it (a wizard component that may live anywhere in
|
|
6
|
+
* the tree). The interceptor publishes; {@link GatewayEventBus} fans the event
|
|
7
|
+
* out to every subscriber without either side knowing about the other.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Handoff } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** Payload emitted whenever the gateway returns a structured handoff. */
|
|
13
|
+
export interface HandoffEvent {
|
|
14
|
+
/** HTTP status the gateway chose: `402` (budget wall) or `429` (rate window). */
|
|
15
|
+
readonly status: number;
|
|
16
|
+
/** The decoded handoff instruction. */
|
|
17
|
+
readonly handoff: Handoff;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The map of event names to their payload types. */
|
|
21
|
+
export interface GatewayEventMap {
|
|
22
|
+
handoff: HandoffEvent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Unsubscribe handle returned by {@link GatewayEventBus.on}. */
|
|
26
|
+
export type Unsubscribe = () => void;
|
|
27
|
+
|
|
28
|
+
/** Listener signature for a given event. */
|
|
29
|
+
export type Listener<E extends keyof GatewayEventMap> = (
|
|
30
|
+
event: GatewayEventMap[E],
|
|
31
|
+
) => void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Synchronous, in-memory pub/sub for gateway events.
|
|
35
|
+
*
|
|
36
|
+
* Listener exceptions are swallowed (and forwarded to `onListenerError` when
|
|
37
|
+
* provided) so one bad subscriber can never break delivery to the others.
|
|
38
|
+
*/
|
|
39
|
+
export class GatewayEventBus {
|
|
40
|
+
readonly #listeners: {
|
|
41
|
+
[E in keyof GatewayEventMap]: Set<Listener<E>>;
|
|
42
|
+
} = { handoff: new Set() };
|
|
43
|
+
|
|
44
|
+
readonly #onListenerError: (error: unknown) => void;
|
|
45
|
+
|
|
46
|
+
constructor(onListenerError: (error: unknown) => void = () => {}) {
|
|
47
|
+
this.#onListenerError = onListenerError;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Subscribe to an event.
|
|
52
|
+
*
|
|
53
|
+
* @returns A function that removes this exact listener when called.
|
|
54
|
+
*/
|
|
55
|
+
on<E extends keyof GatewayEventMap>(event: E, listener: Listener<E>): Unsubscribe {
|
|
56
|
+
this.#listeners[event].add(listener);
|
|
57
|
+
return () => {
|
|
58
|
+
this.#listeners[event].delete(listener);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Publish an event to every current subscriber. */
|
|
63
|
+
emit<E extends keyof GatewayEventMap>(event: E, payload: GatewayEventMap[E]): void {
|
|
64
|
+
for (const listener of this.#listeners[event]) {
|
|
65
|
+
try {
|
|
66
|
+
listener(payload);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
this.#onListenerError(error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Remove all listeners (useful in test teardown). */
|
|
74
|
+
clear(): void {
|
|
75
|
+
this.#listeners.handoff.clear();
|
|
76
|
+
}
|
|
77
|
+
}
|