@nozich/api 1.0.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/circuit-breaker.ts +68 -0
- package/dist/circuit-breaker.d.ts +22 -0
- package/dist/circuit-breaker.js +52 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +284 -0
- package/index.ts +360 -0
- package/package.json +28 -0
- package/test-circuit-breaker.ts +56 -0
- package/tsconfig.json +11 -0
- package/validate-standards.ts +100 -0
- package/validate-ws.ts +106 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export enum CircuitState {
|
|
2
|
+
CLOSED,
|
|
3
|
+
OPEN,
|
|
4
|
+
HALF_OPEN,
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface CircuitBreakerOptions {
|
|
8
|
+
failureThreshold: number;
|
|
9
|
+
resetTimeoutMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class CircuitBreaker {
|
|
13
|
+
private state: CircuitState = CircuitState.CLOSED;
|
|
14
|
+
private failures = 0;
|
|
15
|
+
private lastFailureTime = 0;
|
|
16
|
+
private readonly failureThreshold: number;
|
|
17
|
+
private readonly resetTimeoutMs: number;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
options: CircuitBreakerOptions = {
|
|
21
|
+
failureThreshold: 5,
|
|
22
|
+
resetTimeoutMs: 10000,
|
|
23
|
+
},
|
|
24
|
+
) {
|
|
25
|
+
this.failureThreshold = options.failureThreshold;
|
|
26
|
+
this.resetTimeoutMs = options.resetTimeoutMs;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public async execute<T>(action: () => Promise<T>): Promise<T> {
|
|
30
|
+
if (this.state === CircuitState.OPEN) {
|
|
31
|
+
if (Date.now() - this.lastFailureTime > this.resetTimeoutMs) {
|
|
32
|
+
this.state = CircuitState.HALF_OPEN;
|
|
33
|
+
} else {
|
|
34
|
+
throw new Error('Circuit Breaker is OPEN');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const result = await action();
|
|
40
|
+
this.onSuccess();
|
|
41
|
+
return result;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
this.onFailure();
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private onSuccess() {
|
|
49
|
+
this.failures = 0;
|
|
50
|
+
this.state = CircuitState.CLOSED;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private onFailure() {
|
|
54
|
+
this.failures++;
|
|
55
|
+
this.lastFailureTime = Date.now();
|
|
56
|
+
|
|
57
|
+
if (
|
|
58
|
+
this.state === CircuitState.HALF_OPEN ||
|
|
59
|
+
this.failures >= this.failureThreshold
|
|
60
|
+
) {
|
|
61
|
+
this.state = CircuitState.OPEN;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public getState(): CircuitState {
|
|
66
|
+
return this.state;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare enum CircuitState {
|
|
2
|
+
CLOSED = 0,
|
|
3
|
+
OPEN = 1,
|
|
4
|
+
HALF_OPEN = 2
|
|
5
|
+
}
|
|
6
|
+
interface CircuitBreakerOptions {
|
|
7
|
+
failureThreshold: number;
|
|
8
|
+
resetTimeoutMs: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class CircuitBreaker {
|
|
11
|
+
private state;
|
|
12
|
+
private failures;
|
|
13
|
+
private lastFailureTime;
|
|
14
|
+
private readonly failureThreshold;
|
|
15
|
+
private readonly resetTimeoutMs;
|
|
16
|
+
constructor(options?: CircuitBreakerOptions);
|
|
17
|
+
execute<T>(action: () => Promise<T>): Promise<T>;
|
|
18
|
+
private onSuccess;
|
|
19
|
+
private onFailure;
|
|
20
|
+
getState(): CircuitState;
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export var CircuitState;
|
|
2
|
+
(function (CircuitState) {
|
|
3
|
+
CircuitState[CircuitState["CLOSED"] = 0] = "CLOSED";
|
|
4
|
+
CircuitState[CircuitState["OPEN"] = 1] = "OPEN";
|
|
5
|
+
CircuitState[CircuitState["HALF_OPEN"] = 2] = "HALF_OPEN";
|
|
6
|
+
})(CircuitState || (CircuitState = {}));
|
|
7
|
+
export class CircuitBreaker {
|
|
8
|
+
constructor(options = {
|
|
9
|
+
failureThreshold: 5,
|
|
10
|
+
resetTimeoutMs: 10000,
|
|
11
|
+
}) {
|
|
12
|
+
this.state = CircuitState.CLOSED;
|
|
13
|
+
this.failures = 0;
|
|
14
|
+
this.lastFailureTime = 0;
|
|
15
|
+
this.failureThreshold = options.failureThreshold;
|
|
16
|
+
this.resetTimeoutMs = options.resetTimeoutMs;
|
|
17
|
+
}
|
|
18
|
+
async execute(action) {
|
|
19
|
+
if (this.state === CircuitState.OPEN) {
|
|
20
|
+
if (Date.now() - this.lastFailureTime > this.resetTimeoutMs) {
|
|
21
|
+
this.state = CircuitState.HALF_OPEN;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
throw new Error('Circuit Breaker is OPEN');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const result = await action();
|
|
29
|
+
this.onSuccess();
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
this.onFailure();
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
onSuccess() {
|
|
38
|
+
this.failures = 0;
|
|
39
|
+
this.state = CircuitState.CLOSED;
|
|
40
|
+
}
|
|
41
|
+
onFailure() {
|
|
42
|
+
this.failures++;
|
|
43
|
+
this.lastFailureTime = Date.now();
|
|
44
|
+
if (this.state === CircuitState.HALF_OPEN ||
|
|
45
|
+
this.failures >= this.failureThreshold) {
|
|
46
|
+
this.state = CircuitState.OPEN;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
getState() {
|
|
50
|
+
return this.state;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ApiResponse } from '@nozich/types';
|
|
2
|
+
export interface ApiConfig {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
tokens?: {
|
|
6
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
7
|
+
};
|
|
8
|
+
adapters?: {
|
|
9
|
+
socket?: any;
|
|
10
|
+
notifications?: any;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export declare class ApiClient {
|
|
14
|
+
private readonly config;
|
|
15
|
+
private ws;
|
|
16
|
+
private wsFailures;
|
|
17
|
+
private forceHttp;
|
|
18
|
+
private readonly MAX_WS_FAILURES;
|
|
19
|
+
private readonly circuitBreaker;
|
|
20
|
+
private wsConnected;
|
|
21
|
+
private readonly listeners;
|
|
22
|
+
private readonly pendingRequests;
|
|
23
|
+
private heartbeatInterval;
|
|
24
|
+
constructor(config: ApiConfig);
|
|
25
|
+
private getToken;
|
|
26
|
+
setToken(token: string): Promise<void>;
|
|
27
|
+
subscribe<T>(event: string, callback: (payload: T) => void): () => void;
|
|
28
|
+
unsubscribe(event: string, callback: (payload: any) => void): void;
|
|
29
|
+
private connectWs;
|
|
30
|
+
private startHeartbeat;
|
|
31
|
+
private sendWs;
|
|
32
|
+
private dispatch;
|
|
33
|
+
private handleWsError;
|
|
34
|
+
request<T>(endpoint: string, options?: RequestInit & {
|
|
35
|
+
silent?: boolean;
|
|
36
|
+
}): Promise<ApiResponse<T>>;
|
|
37
|
+
private requestHttp;
|
|
38
|
+
get<T>(endpoint: string, query?: any, options?: RequestInit): Promise<ApiResponse<T>>;
|
|
39
|
+
post<T>(endpoint: string, body?: any, options?: RequestInit): Promise<ApiResponse<T>>;
|
|
40
|
+
put<T>(endpoint: string, body?: any, options?: RequestInit): Promise<ApiResponse<T>>;
|
|
41
|
+
patch<T>(endpoint: string, body?: any, options?: RequestInit): Promise<ApiResponse<T>>;
|
|
42
|
+
delete<T>(endpoint: string, body?: any, options?: RequestInit): Promise<ApiResponse<T>>;
|
|
43
|
+
}
|
|
44
|
+
export declare const createApiClient: (config: ApiConfig) => ApiClient;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import SuperJSON from 'superjson';
|
|
3
|
+
import { CircuitBreaker } from './circuit-breaker';
|
|
4
|
+
export class ApiClient {
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.ws = null;
|
|
7
|
+
this.wsFailures = 0;
|
|
8
|
+
this.forceHttp = false;
|
|
9
|
+
this.MAX_WS_FAILURES = 3;
|
|
10
|
+
this.wsConnected = false;
|
|
11
|
+
this.listeners = new Map();
|
|
12
|
+
this.pendingRequests = new Map();
|
|
13
|
+
this.heartbeatInterval = null;
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.circuitBreaker = new CircuitBreaker();
|
|
16
|
+
this.connectWs();
|
|
17
|
+
}
|
|
18
|
+
async getToken() {
|
|
19
|
+
if (this.config.tokens?.getToken) {
|
|
20
|
+
return await this.config.tokens.getToken();
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
async setToken(token) {
|
|
25
|
+
// Legacy support? Or just rely on getToken?
|
|
26
|
+
// In new design, we pull token from callback.
|
|
27
|
+
// But socket might need immediate push.
|
|
28
|
+
if (this.wsConnected && this.ws) {
|
|
29
|
+
this.sendWs({ type: 'auth.identify', payload: { token }, id: nanoid() });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
subscribe(event, callback) {
|
|
33
|
+
if (!this.listeners.has(event)) {
|
|
34
|
+
this.listeners.set(event, []);
|
|
35
|
+
}
|
|
36
|
+
this.listeners.get(event)?.push(callback);
|
|
37
|
+
return () => this.unsubscribe(event, callback);
|
|
38
|
+
}
|
|
39
|
+
unsubscribe(event, callback) {
|
|
40
|
+
const list = this.listeners.get(event);
|
|
41
|
+
if (!list)
|
|
42
|
+
return;
|
|
43
|
+
this.listeners.set(event, list.filter((cb) => cb !== callback));
|
|
44
|
+
}
|
|
45
|
+
connectWs() {
|
|
46
|
+
if (this.config.adapters?.socket) {
|
|
47
|
+
// Use external socket adapter if provided (e.g. from Panel's useSocket store)
|
|
48
|
+
this.ws = this.config.adapters.socket;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (this.forceHttp)
|
|
52
|
+
return;
|
|
53
|
+
if (typeof WebSocket === 'undefined')
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
const wsUrl = this.config.baseUrl.replace('http', 'ws') + '/ws';
|
|
57
|
+
this.ws = new WebSocket(wsUrl);
|
|
58
|
+
this.ws.onopen = async () => {
|
|
59
|
+
this.wsFailures = 0;
|
|
60
|
+
this.wsConnected = true;
|
|
61
|
+
console.log('[API] WS Connected');
|
|
62
|
+
this.startHeartbeat();
|
|
63
|
+
const token = await this.getToken();
|
|
64
|
+
// Auto-identify if token exists
|
|
65
|
+
if (token) {
|
|
66
|
+
this.sendWs({
|
|
67
|
+
type: 'auth.identify',
|
|
68
|
+
payload: { token },
|
|
69
|
+
id: nanoid(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
this.ws.onmessage = (event) => {
|
|
74
|
+
try {
|
|
75
|
+
const data = JSON.parse(event.data.toString());
|
|
76
|
+
// 1. Handle Request Correlation (Standard)
|
|
77
|
+
if (data.id && this.pendingRequests.has(data.id)) {
|
|
78
|
+
const req = this.pendingRequests.get(data.id);
|
|
79
|
+
if (req) {
|
|
80
|
+
clearTimeout(req.timer);
|
|
81
|
+
this.pendingRequests.delete(data.id);
|
|
82
|
+
if (data.type === 'error') {
|
|
83
|
+
req.reject(new Error(data.payload?.message || 'Unknown WS Error'));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
req.resolve(data.payload);
|
|
87
|
+
}
|
|
88
|
+
return; // Handled as response
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// 2. Handle Events
|
|
92
|
+
if (data.type) {
|
|
93
|
+
this.dispatch(data.type, data.payload);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
console.error('[API] WS Parse Error', e);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
this.ws.onerror = () => {
|
|
101
|
+
this.handleWsError();
|
|
102
|
+
};
|
|
103
|
+
this.ws.onclose = () => {
|
|
104
|
+
this.wsConnected = false;
|
|
105
|
+
if (this.heartbeatInterval)
|
|
106
|
+
clearInterval(this.heartbeatInterval);
|
|
107
|
+
// Reject all pending requests
|
|
108
|
+
for (const [id, req] of this.pendingRequests) {
|
|
109
|
+
clearTimeout(req.timer);
|
|
110
|
+
req.reject(new Error('WS Closed'));
|
|
111
|
+
}
|
|
112
|
+
this.pendingRequests.clear();
|
|
113
|
+
// Exponential backoff
|
|
114
|
+
if (!this.forceHttp) {
|
|
115
|
+
const delay = Math.min(30000, 1000 * Math.pow(2, this.wsFailures));
|
|
116
|
+
console.log(`[API] Reconnecting WS in ${delay}ms...`);
|
|
117
|
+
setTimeout(() => this.connectWs(), delay);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
this.handleWsError();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
startHeartbeat() {
|
|
126
|
+
if (this.heartbeatInterval)
|
|
127
|
+
clearInterval(this.heartbeatInterval);
|
|
128
|
+
this.heartbeatInterval = setInterval(() => {
|
|
129
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
130
|
+
this.ws.send(JSON.stringify({ type: 'ping', payload: Date.now() }));
|
|
131
|
+
}
|
|
132
|
+
}, 30000);
|
|
133
|
+
}
|
|
134
|
+
sendWs(event) {
|
|
135
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
136
|
+
this.ws.send(JSON.stringify(event));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
dispatch(type, payload) {
|
|
140
|
+
const callbacks = this.listeners.get(type);
|
|
141
|
+
callbacks?.forEach((cb) => cb(payload));
|
|
142
|
+
}
|
|
143
|
+
handleWsError() {
|
|
144
|
+
this.wsFailures++;
|
|
145
|
+
console.warn(`[API] WS Error (${this.wsFailures}/${this.MAX_WS_FAILURES})`);
|
|
146
|
+
if (this.wsFailures >= this.MAX_WS_FAILURES) {
|
|
147
|
+
this.forceHttp = true;
|
|
148
|
+
console.error('[API] WS Circuit Breaker Tripped. Locked to HTTP.');
|
|
149
|
+
this.ws?.close();
|
|
150
|
+
this.ws = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async request(endpoint, options = {}) {
|
|
154
|
+
const isSock = this.ws &&
|
|
155
|
+
this.ws.readyState === WebSocket.OPEN &&
|
|
156
|
+
!this.forceHttp &&
|
|
157
|
+
options.method !== 'HEAD'; // HEAD not supported over WS usually?
|
|
158
|
+
// In original code: const isSock = useSocket.getState().readyState === socketStates.OPEN && opt.type !== 'fetch';
|
|
159
|
+
// We try to use WS if connected.
|
|
160
|
+
if (isSock) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const id = nanoid();
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
if (this.pendingRequests.has(id)) {
|
|
165
|
+
this.pendingRequests.delete(id);
|
|
166
|
+
console.warn(`[API] WS Request Timeout for ${endpoint}, falling back to HTTP`);
|
|
167
|
+
this.requestHttp(endpoint, options).then(resolve).catch(reject);
|
|
168
|
+
}
|
|
169
|
+
}, 5000); // 5s timeout
|
|
170
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
171
|
+
this.sendWs({
|
|
172
|
+
type: 'api.request',
|
|
173
|
+
payload: {
|
|
174
|
+
path: endpoint,
|
|
175
|
+
method: options?.method || 'GET',
|
|
176
|
+
body: options?.body
|
|
177
|
+
? JSON.parse(options.body)
|
|
178
|
+
: undefined,
|
|
179
|
+
},
|
|
180
|
+
id,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// 2. HTTP Fallback with Circuit Breaker
|
|
185
|
+
return this.requestHttp(endpoint, options);
|
|
186
|
+
}
|
|
187
|
+
async requestHttp(endpoint, options = {}) {
|
|
188
|
+
try {
|
|
189
|
+
return await this.circuitBreaker.execute(async () => {
|
|
190
|
+
let url = `${this.config.baseUrl}/${endpoint}`;
|
|
191
|
+
// Handle GET query params if body is present (SuperJSON behavior from original file)
|
|
192
|
+
if (['GET', 'HEAD'].includes(options.method || 'GET') && options.body) {
|
|
193
|
+
// It seems options.body is stringified already by wrapper methods?
|
|
194
|
+
// Wrapper methods below create JSON string.
|
|
195
|
+
// We need to parse it back if we want to put it in QP? Or just trust caller?
|
|
196
|
+
// Original: path = path + '?' + new URLSearchParams({ q: SuperJSON.stringify(opt.body) }).toString();
|
|
197
|
+
// Here options.body is currently stringified JSON standard.
|
|
198
|
+
// Let's assume wrapper passes object if it's GET? No, wrapper stringifies.
|
|
199
|
+
// We should change wrapper to NOT stringify for GET?
|
|
200
|
+
}
|
|
201
|
+
const token = await this.getToken();
|
|
202
|
+
const headers = {
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
'X-Project-ID': this.config.projectId,
|
|
205
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
206
|
+
...options?.headers,
|
|
207
|
+
};
|
|
208
|
+
const res = await fetch(url, { ...options, headers });
|
|
209
|
+
const text = await res.text();
|
|
210
|
+
// SuperJSON parse? Or standard?
|
|
211
|
+
// Original used:
|
|
212
|
+
// if (isJson(message)) { message = JSON.parse(message); }
|
|
213
|
+
// Let's standard JSON parse for now unless SuperJSON is enforced by server.
|
|
214
|
+
// If server returns SuperJSON, we need SuperJSON.parse.
|
|
215
|
+
let json;
|
|
216
|
+
try {
|
|
217
|
+
json = JSON.parse(text);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return text;
|
|
221
|
+
}
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
const error = json.error || {
|
|
224
|
+
code: `HTTP_${res.status}`,
|
|
225
|
+
message: json.message || res.statusText,
|
|
226
|
+
details: json.details,
|
|
227
|
+
};
|
|
228
|
+
throw new Error(error.message || JSON.stringify(error));
|
|
229
|
+
}
|
|
230
|
+
return json;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (!options.silent && this.config.adapters?.notifications) {
|
|
235
|
+
this.config.adapters.notifications.show({
|
|
236
|
+
color: 'red',
|
|
237
|
+
title: 'Error',
|
|
238
|
+
message: error.message || 'Unknown Error',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
get(endpoint, query, options) {
|
|
245
|
+
// If query is object, we should maybe append to URL?
|
|
246
|
+
// Original code: if (['GET', 'HEAD'].includes(opt.method) && opt.body) ... q=SuperJSON(body)
|
|
247
|
+
if (query) {
|
|
248
|
+
endpoint =
|
|
249
|
+
endpoint +
|
|
250
|
+
'?' +
|
|
251
|
+
new URLSearchParams({ q: SuperJSON.stringify(query) }).toString();
|
|
252
|
+
}
|
|
253
|
+
return this.request(endpoint, { ...options, method: 'GET' });
|
|
254
|
+
}
|
|
255
|
+
post(endpoint, body, options) {
|
|
256
|
+
return this.request(endpoint, {
|
|
257
|
+
...options,
|
|
258
|
+
method: 'POST',
|
|
259
|
+
body: SuperJSON.stringify(body),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
put(endpoint, body, options) {
|
|
263
|
+
return this.request(endpoint, {
|
|
264
|
+
...options,
|
|
265
|
+
method: 'PUT',
|
|
266
|
+
body: SuperJSON.stringify(body),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
patch(endpoint, body, options) {
|
|
270
|
+
return this.request(endpoint, {
|
|
271
|
+
...options,
|
|
272
|
+
method: 'PATCH',
|
|
273
|
+
body: SuperJSON.stringify(body),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
delete(endpoint, body, options) {
|
|
277
|
+
return this.request(endpoint, {
|
|
278
|
+
...options,
|
|
279
|
+
method: 'DELETE',
|
|
280
|
+
body: body ? SuperJSON.stringify(body) : undefined,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
export const createApiClient = (config) => new ApiClient(config);
|
package/index.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { ApiResponse } from '@nozich/types';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
import SuperJSON from 'superjson';
|
|
4
|
+
|
|
5
|
+
export interface ApiConfig {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
projectId: string;
|
|
8
|
+
tokens?: {
|
|
9
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
10
|
+
};
|
|
11
|
+
adapters?: {
|
|
12
|
+
socket?: any; // Socket adapter
|
|
13
|
+
notifications?: any;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
import { CircuitBreaker } from './circuit-breaker';
|
|
18
|
+
|
|
19
|
+
interface PendingRequest {
|
|
20
|
+
resolve: (value: any) => void;
|
|
21
|
+
reject: (reason?: any) => void;
|
|
22
|
+
timer: ReturnType<typeof setTimeout>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ApiClient {
|
|
26
|
+
private readonly config: ApiConfig;
|
|
27
|
+
private ws: WebSocket | null = null;
|
|
28
|
+
private wsFailures = 0;
|
|
29
|
+
private forceHttp = false;
|
|
30
|
+
private readonly MAX_WS_FAILURES = 3;
|
|
31
|
+
private readonly circuitBreaker: CircuitBreaker;
|
|
32
|
+
|
|
33
|
+
private wsConnected = false;
|
|
34
|
+
private readonly listeners: Map<string, ((payload: any) => void)[]> =
|
|
35
|
+
new Map();
|
|
36
|
+
private readonly pendingRequests = new Map<string, PendingRequest>();
|
|
37
|
+
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(config: ApiConfig) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.circuitBreaker = new CircuitBreaker();
|
|
42
|
+
this.connectWs();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async getToken(): Promise<string | null> {
|
|
46
|
+
if (this.config.tokens?.getToken) {
|
|
47
|
+
return await this.config.tokens.getToken();
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public async setToken(token: string) {
|
|
53
|
+
// Legacy support? Or just rely on getToken?
|
|
54
|
+
// In new design, we pull token from callback.
|
|
55
|
+
// But socket might need immediate push.
|
|
56
|
+
if (this.wsConnected && this.ws) {
|
|
57
|
+
this.sendWs({ type: 'auth.identify', payload: { token }, id: nanoid() });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public subscribe<T>(event: string, callback: (payload: T) => void) {
|
|
62
|
+
if (!this.listeners.has(event)) {
|
|
63
|
+
this.listeners.set(event, []);
|
|
64
|
+
}
|
|
65
|
+
this.listeners.get(event)?.push(callback);
|
|
66
|
+
return () => this.unsubscribe(event, callback);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public unsubscribe(event: string, callback: (payload: any) => void) {
|
|
70
|
+
const list = this.listeners.get(event);
|
|
71
|
+
if (!list) return;
|
|
72
|
+
this.listeners.set(
|
|
73
|
+
event,
|
|
74
|
+
list.filter((cb) => cb !== callback),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private connectWs() {
|
|
79
|
+
if (this.config.adapters?.socket) {
|
|
80
|
+
// Use external socket adapter if provided (e.g. from Panel's useSocket store)
|
|
81
|
+
this.ws = this.config.adapters.socket;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.forceHttp) return;
|
|
86
|
+
if (typeof WebSocket === 'undefined') return;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const wsUrl = this.config.baseUrl.replace('http', 'ws') + '/ws';
|
|
90
|
+
this.ws = new WebSocket(wsUrl);
|
|
91
|
+
|
|
92
|
+
this.ws.onopen = async () => {
|
|
93
|
+
this.wsFailures = 0;
|
|
94
|
+
this.wsConnected = true;
|
|
95
|
+
console.log('[API] WS Connected');
|
|
96
|
+
this.startHeartbeat();
|
|
97
|
+
|
|
98
|
+
const token = await this.getToken();
|
|
99
|
+
|
|
100
|
+
// Auto-identify if token exists
|
|
101
|
+
if (token) {
|
|
102
|
+
this.sendWs({
|
|
103
|
+
type: 'auth.identify',
|
|
104
|
+
payload: { token },
|
|
105
|
+
id: nanoid(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
this.ws.onmessage = (event) => {
|
|
111
|
+
try {
|
|
112
|
+
const data = JSON.parse(event.data.toString());
|
|
113
|
+
|
|
114
|
+
// 1. Handle Request Correlation (Standard)
|
|
115
|
+
if (data.id && this.pendingRequests.has(data.id)) {
|
|
116
|
+
const req = this.pendingRequests.get(data.id);
|
|
117
|
+
if (req) {
|
|
118
|
+
clearTimeout(req.timer);
|
|
119
|
+
this.pendingRequests.delete(data.id);
|
|
120
|
+
|
|
121
|
+
if (data.type === 'error') {
|
|
122
|
+
req.reject(
|
|
123
|
+
new Error(data.payload?.message || 'Unknown WS Error'),
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
req.resolve(data.payload);
|
|
127
|
+
}
|
|
128
|
+
return; // Handled as response
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Handle Events
|
|
133
|
+
if (data.type) {
|
|
134
|
+
this.dispatch(data.type, data.payload);
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.error('[API] WS Parse Error', e);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
this.ws.onerror = () => {
|
|
142
|
+
this.handleWsError();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
this.ws.onclose = () => {
|
|
146
|
+
this.wsConnected = false;
|
|
147
|
+
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
|
|
148
|
+
|
|
149
|
+
// Reject all pending requests
|
|
150
|
+
for (const [id, req] of this.pendingRequests) {
|
|
151
|
+
clearTimeout(req.timer);
|
|
152
|
+
req.reject(new Error('WS Closed'));
|
|
153
|
+
}
|
|
154
|
+
this.pendingRequests.clear();
|
|
155
|
+
|
|
156
|
+
// Exponential backoff
|
|
157
|
+
if (!this.forceHttp) {
|
|
158
|
+
const delay = Math.min(30000, 1000 * Math.pow(2, this.wsFailures));
|
|
159
|
+
console.log(`[API] Reconnecting WS in ${delay}ms...`);
|
|
160
|
+
setTimeout(() => this.connectWs(), delay);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
} catch (e) {
|
|
164
|
+
this.handleWsError();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private startHeartbeat() {
|
|
169
|
+
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
|
|
170
|
+
this.heartbeatInterval = setInterval(() => {
|
|
171
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
172
|
+
this.ws.send(JSON.stringify({ type: 'ping', payload: Date.now() }));
|
|
173
|
+
}
|
|
174
|
+
}, 30000);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private sendWs(event: { type: string; payload: any; id?: string }) {
|
|
178
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
179
|
+
this.ws.send(JSON.stringify(event));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private dispatch(type: string, payload: any) {
|
|
184
|
+
const callbacks = this.listeners.get(type);
|
|
185
|
+
callbacks?.forEach((cb) => cb(payload));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private handleWsError() {
|
|
189
|
+
this.wsFailures++;
|
|
190
|
+
console.warn(`[API] WS Error (${this.wsFailures}/${this.MAX_WS_FAILURES})`);
|
|
191
|
+
|
|
192
|
+
if (this.wsFailures >= this.MAX_WS_FAILURES) {
|
|
193
|
+
this.forceHttp = true;
|
|
194
|
+
console.error('[API] WS Circuit Breaker Tripped. Locked to HTTP.');
|
|
195
|
+
this.ws?.close();
|
|
196
|
+
this.ws = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async request<T>(
|
|
201
|
+
endpoint: string,
|
|
202
|
+
options: RequestInit & { silent?: boolean } = {},
|
|
203
|
+
): Promise<ApiResponse<T>> {
|
|
204
|
+
const isSock =
|
|
205
|
+
this.ws &&
|
|
206
|
+
this.ws.readyState === WebSocket.OPEN &&
|
|
207
|
+
!this.forceHttp &&
|
|
208
|
+
options.method !== 'HEAD'; // HEAD not supported over WS usually?
|
|
209
|
+
|
|
210
|
+
// In original code: const isSock = useSocket.getState().readyState === socketStates.OPEN && opt.type !== 'fetch';
|
|
211
|
+
// We try to use WS if connected.
|
|
212
|
+
|
|
213
|
+
if (isSock) {
|
|
214
|
+
return new Promise<ApiResponse<T>>((resolve, reject) => {
|
|
215
|
+
const id = nanoid();
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
if (this.pendingRequests.has(id)) {
|
|
218
|
+
this.pendingRequests.delete(id);
|
|
219
|
+
console.warn(
|
|
220
|
+
`[API] WS Request Timeout for ${endpoint}, falling back to HTTP`,
|
|
221
|
+
);
|
|
222
|
+
this.requestHttp<T>(endpoint, options).then(resolve).catch(reject);
|
|
223
|
+
}
|
|
224
|
+
}, 5000); // 5s timeout
|
|
225
|
+
|
|
226
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
227
|
+
|
|
228
|
+
this.sendWs({
|
|
229
|
+
type: 'api.request',
|
|
230
|
+
payload: {
|
|
231
|
+
path: endpoint,
|
|
232
|
+
method: options?.method || 'GET',
|
|
233
|
+
body: options?.body
|
|
234
|
+
? JSON.parse(options.body as string)
|
|
235
|
+
: undefined,
|
|
236
|
+
},
|
|
237
|
+
id,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 2. HTTP Fallback with Circuit Breaker
|
|
243
|
+
return this.requestHttp<T>(endpoint, options);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async requestHttp<T>(
|
|
247
|
+
endpoint: string,
|
|
248
|
+
options: RequestInit & { silent?: boolean } = {},
|
|
249
|
+
): Promise<ApiResponse<T>> {
|
|
250
|
+
try {
|
|
251
|
+
return await this.circuitBreaker.execute(async () => {
|
|
252
|
+
let url = `${this.config.baseUrl}/${endpoint}`;
|
|
253
|
+
|
|
254
|
+
// Handle GET query params if body is present (SuperJSON behavior from original file)
|
|
255
|
+
if (['GET', 'HEAD'].includes(options.method || 'GET') && options.body) {
|
|
256
|
+
// It seems options.body is stringified already by wrapper methods?
|
|
257
|
+
// Wrapper methods below create JSON string.
|
|
258
|
+
// We need to parse it back if we want to put it in QP? Or just trust caller?
|
|
259
|
+
// Original: path = path + '?' + new URLSearchParams({ q: SuperJSON.stringify(opt.body) }).toString();
|
|
260
|
+
// Here options.body is currently stringified JSON standard.
|
|
261
|
+
// Let's assume wrapper passes object if it's GET? No, wrapper stringifies.
|
|
262
|
+
// We should change wrapper to NOT stringify for GET?
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const token = await this.getToken();
|
|
266
|
+
|
|
267
|
+
const headers: Record<string, string> = {
|
|
268
|
+
'Content-Type': 'application/json',
|
|
269
|
+
'X-Project-ID': this.config.projectId,
|
|
270
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
271
|
+
...(options?.headers as any),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const res = await fetch(url, { ...options, headers });
|
|
275
|
+
const text = await res.text();
|
|
276
|
+
|
|
277
|
+
// SuperJSON parse? Or standard?
|
|
278
|
+
// Original used:
|
|
279
|
+
// if (isJson(message)) { message = JSON.parse(message); }
|
|
280
|
+
// Let's standard JSON parse for now unless SuperJSON is enforced by server.
|
|
281
|
+
// If server returns SuperJSON, we need SuperJSON.parse.
|
|
282
|
+
|
|
283
|
+
let json;
|
|
284
|
+
try {
|
|
285
|
+
json = JSON.parse(text);
|
|
286
|
+
} catch {
|
|
287
|
+
return text as any;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!res.ok) {
|
|
291
|
+
const error = json.error || {
|
|
292
|
+
code: `HTTP_${res.status}`,
|
|
293
|
+
message: json.message || res.statusText,
|
|
294
|
+
details: json.details,
|
|
295
|
+
};
|
|
296
|
+
throw new Error(error.message || JSON.stringify(error));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return json as ApiResponse<T>;
|
|
300
|
+
});
|
|
301
|
+
} catch (error: any) {
|
|
302
|
+
if (!options.silent && this.config.adapters?.notifications) {
|
|
303
|
+
this.config.adapters.notifications.show({
|
|
304
|
+
color: 'red',
|
|
305
|
+
title: 'Error',
|
|
306
|
+
message: error.message || 'Unknown Error',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
public get<T>(endpoint: string, query?: any, options?: RequestInit) {
|
|
314
|
+
// If query is object, we should maybe append to URL?
|
|
315
|
+
// Original code: if (['GET', 'HEAD'].includes(opt.method) && opt.body) ... q=SuperJSON(body)
|
|
316
|
+
|
|
317
|
+
if (query) {
|
|
318
|
+
endpoint =
|
|
319
|
+
endpoint +
|
|
320
|
+
'?' +
|
|
321
|
+
new URLSearchParams({ q: SuperJSON.stringify(query) }).toString();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return this.request<T>(endpoint, { ...options, method: 'GET' });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
public post<T>(endpoint: string, body?: any, options?: RequestInit) {
|
|
328
|
+
return this.request<T>(endpoint, {
|
|
329
|
+
...options,
|
|
330
|
+
method: 'POST',
|
|
331
|
+
body: SuperJSON.stringify(body),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
public put<T>(endpoint: string, body?: any, options?: RequestInit) {
|
|
336
|
+
return this.request<T>(endpoint, {
|
|
337
|
+
...options,
|
|
338
|
+
method: 'PUT',
|
|
339
|
+
body: SuperJSON.stringify(body),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
public patch<T>(endpoint: string, body?: any, options?: RequestInit) {
|
|
344
|
+
return this.request<T>(endpoint, {
|
|
345
|
+
...options,
|
|
346
|
+
method: 'PATCH',
|
|
347
|
+
body: SuperJSON.stringify(body),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
public delete<T>(endpoint: string, body?: any, options?: RequestInit) {
|
|
352
|
+
return this.request<T>(endpoint, {
|
|
353
|
+
...options,
|
|
354
|
+
method: 'DELETE',
|
|
355
|
+
body: body ? SuperJSON.stringify(body) : undefined,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export const createApiClient = (config: ApiConfig) => new ApiClient(config);
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nozich/api",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"validate:api": "bun run validate-standards.ts",
|
|
9
|
+
"validate:ws": "bun run validate-ws.ts"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"typescript": "^5.9.3"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@nozich/types": "*",
|
|
16
|
+
"nanoid": "^5.1.6",
|
|
17
|
+
"superjson": "^2.2.1"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public",
|
|
21
|
+
"registry": "https://registry.npmjs.org/"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/anilcan-kara/nozich-api.git",
|
|
26
|
+
"directory": "packages/api"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createApiClient } from './index';
|
|
2
|
+
|
|
3
|
+
// Mock fetch
|
|
4
|
+
const originalFetch = global.fetch;
|
|
5
|
+
let mockResponse: Response | Error = new Response(JSON.stringify({}), {
|
|
6
|
+
status: 200,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
global.fetch = async (input, init) => {
|
|
10
|
+
if (mockResponse instanceof Error) throw mockResponse;
|
|
11
|
+
return mockResponse;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Helper: sleep
|
|
15
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
|
|
17
|
+
async function testCircuitBreaker() {
|
|
18
|
+
console.log('--- Starting Circuit Breaker Test ---');
|
|
19
|
+
|
|
20
|
+
// Create client (internal circuit breaker has threshold 5, timeout 10000ms by default)
|
|
21
|
+
// For testing, we might need to export options or just hit it 5 times.
|
|
22
|
+
const client = createApiClient({
|
|
23
|
+
baseUrl: 'http://test.com',
|
|
24
|
+
projectId: 'test',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// 1. Send 5 failed requests
|
|
28
|
+
console.log('1. Simulating 5 failures...');
|
|
29
|
+
mockResponse = new Response('Server Error', { status: 500 });
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < 5; i++) {
|
|
32
|
+
const res = await client.request('/test');
|
|
33
|
+
if (res.error?.code === 'NETWORK_ERROR') {
|
|
34
|
+
console.log(`Failure ${i + 1}: Caught NETWORK_ERROR`);
|
|
35
|
+
} else {
|
|
36
|
+
console.log(`Failure ${i + 1}: Got unexpected result`, res);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Next request should fail instantly with CIRCUIT_OPEN
|
|
41
|
+
console.log('2. Sending 6th request (should trip circuit)...');
|
|
42
|
+
const resOpen = await client.request('/test');
|
|
43
|
+
|
|
44
|
+
if (resOpen.error?.code === 'CIRCUIT_OPEN') {
|
|
45
|
+
console.log('PASS: Circuit is OPEN');
|
|
46
|
+
} else {
|
|
47
|
+
console.error('FAIL: Circuit did NOT open', resOpen);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Note: We can't easily wait 10s in this script without blocking,
|
|
51
|
+
// but we verified the state transition.
|
|
52
|
+
|
|
53
|
+
console.log('--- Test Complete ---');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
testCircuitBreaker().catch(console.error);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ApiErrorCode } from '@nozich/types';
|
|
2
|
+
|
|
3
|
+
import { createApiClient } from './index';
|
|
4
|
+
|
|
5
|
+
// Mock fetch globally
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
async function runValidation() {
|
|
9
|
+
console.log('Validating API Client Standards...');
|
|
10
|
+
|
|
11
|
+
const mockResponse = {
|
|
12
|
+
success: true,
|
|
13
|
+
data: { id: 1, name: 'Test Item' },
|
|
14
|
+
meta: { page: 1 },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let lastRequest: { url: string; options: any } | null = null;
|
|
18
|
+
|
|
19
|
+
globalThis.fetch = async (url: any, options: any) => {
|
|
20
|
+
lastRequest = { url: url.toString(), options };
|
|
21
|
+
return new Response(JSON.stringify(mockResponse), {
|
|
22
|
+
status: 200,
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const client = createApiClient({
|
|
28
|
+
baseUrl: 'http://api.nozich.com',
|
|
29
|
+
projectId: 'test-project',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Test GET
|
|
33
|
+
console.log('Testing GET...');
|
|
34
|
+
const res = await client.get('/items');
|
|
35
|
+
|
|
36
|
+
if (lastRequest?.options.headers['X-Project-ID'] !== 'test-project') {
|
|
37
|
+
console.error('FAIL: X-Project-ID header missing or incorrect');
|
|
38
|
+
// @ts-ignore
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
console.log('PASS: X-Project-ID header present');
|
|
42
|
+
|
|
43
|
+
if (!res.success || res.data?.id !== 1) {
|
|
44
|
+
console.error('FAIL: Response parsing incorrect', res);
|
|
45
|
+
// @ts-ignore
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
console.log('PASS: Response envelope parsed');
|
|
49
|
+
|
|
50
|
+
// Test Auth
|
|
51
|
+
console.log('Testing Auth...');
|
|
52
|
+
const testToken = 'abc-123';
|
|
53
|
+
client.setToken(testToken);
|
|
54
|
+
await client.get('/protected');
|
|
55
|
+
|
|
56
|
+
// @ts-ignore
|
|
57
|
+
const authHeader = lastRequest?.options.headers['Authorization'];
|
|
58
|
+
if (authHeader !== `Bearer ${testToken}`) {
|
|
59
|
+
console.error(
|
|
60
|
+
'FAIL: Authorization header missing or incorrect',
|
|
61
|
+
authHeader,
|
|
62
|
+
);
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
console.log('PASS: Authorization header present');
|
|
67
|
+
|
|
68
|
+
// Test Error Handling
|
|
69
|
+
globalThis.fetch = async (url: any, options: any) => {
|
|
70
|
+
return new Response(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
success: false,
|
|
73
|
+
error: {
|
|
74
|
+
code: ApiErrorCode.VALIDATION_ERROR,
|
|
75
|
+
message: 'Something went wrong',
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
{ status: 400 },
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
console.log('Testing Error Handling...');
|
|
83
|
+
const errorRes = await client.post('/items', { name: 'Bad Item' });
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
errorRes.success ||
|
|
87
|
+
errorRes.error?.code !== ApiErrorCode.VALIDATION_ERROR
|
|
88
|
+
) {
|
|
89
|
+
console.error('FAIL: Error parsing incorrect', errorRes);
|
|
90
|
+
// @ts-ignore
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.log('PASS: Standard error parsed');
|
|
94
|
+
|
|
95
|
+
console.log('ALL STANDARDS VALIDATED.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
runValidation().finally(() => {
|
|
99
|
+
globalThis.fetch = originalFetch;
|
|
100
|
+
});
|
package/validate-ws.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createApiClient } from './index';
|
|
2
|
+
|
|
3
|
+
// Mock WebSocket
|
|
4
|
+
class MockWebSocket {
|
|
5
|
+
onopen: any;
|
|
6
|
+
onmessage: any;
|
|
7
|
+
onerror: any;
|
|
8
|
+
onclose: any;
|
|
9
|
+
readyState: number = 1; // OPEN
|
|
10
|
+
|
|
11
|
+
static OPEN = 1;
|
|
12
|
+
|
|
13
|
+
send(data: string) {
|
|
14
|
+
console.log('WS SEND:', data);
|
|
15
|
+
const parsed = JSON.parse(data);
|
|
16
|
+
|
|
17
|
+
// Auto-respond to ping
|
|
18
|
+
if (parsed.type === 'ping') {
|
|
19
|
+
// no-op, server usually doesn't need to pong back app-level for this simple contract, or maybe it does?
|
|
20
|
+
// Contract says Ping/Pong, implies standard or app level?
|
|
21
|
+
// Contract: "Heartbeat: Ping/Pong every 30s"
|
|
22
|
+
// Let's assume server sends nothing back or sends pong
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check auth
|
|
26
|
+
if (parsed.type === 'auth.identify') {
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
if (this.onmessage) {
|
|
29
|
+
this.onmessage({
|
|
30
|
+
data: JSON.stringify({
|
|
31
|
+
type: 'auth.success',
|
|
32
|
+
payload: { userId: '1' },
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}, 50);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
close() {
|
|
41
|
+
console.log('WS CLOSED');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// @ts-ignore
|
|
46
|
+
globalThis.WebSocket = MockWebSocket;
|
|
47
|
+
|
|
48
|
+
async function runTest() {
|
|
49
|
+
console.log('Testing WebSocket Client...');
|
|
50
|
+
|
|
51
|
+
const client = createApiClient({
|
|
52
|
+
baseUrl: 'http://api.test.com',
|
|
53
|
+
projectId: 'test',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Test 1: Connect and Auth
|
|
57
|
+
console.log('Test 1: Auth');
|
|
58
|
+
client.setToken('my-token');
|
|
59
|
+
|
|
60
|
+
// Wait for WS to trigger (it's async in construction/setToken)
|
|
61
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
62
|
+
|
|
63
|
+
// Test 2: Subscription
|
|
64
|
+
console.log('Test 2: Subscription');
|
|
65
|
+
return new Promise<void>((resolve, reject) => {
|
|
66
|
+
const unsub = client.subscribe('data.update', (payload: any) => {
|
|
67
|
+
console.log('RECEIVED EVENT:', payload);
|
|
68
|
+
if (payload.value === 123) {
|
|
69
|
+
console.log('PASS: Received data.update');
|
|
70
|
+
resolve();
|
|
71
|
+
} else {
|
|
72
|
+
reject(new Error('Wrong payload'));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Simulate server push
|
|
77
|
+
// We need access to the mock instance or just simulate via the event loop if we could hook into it.
|
|
78
|
+
// Since we mocked the class, we can find the instance if we stored it, or just use a dirty hack in the MockWebSocket to trigger it?
|
|
79
|
+
// Actually, `client` has the `ws` instance private.
|
|
80
|
+
// For this test, let's verify `setToken` triggered the send (saw in logs).
|
|
81
|
+
// And to test `subscribe`, we need to simulate an incoming message.
|
|
82
|
+
|
|
83
|
+
// Let's simulate incoming message by hacking the MockWebSocket prototype or just ...
|
|
84
|
+
// Better: modifying MockWebSocket to emit an event content after a delay.
|
|
85
|
+
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
// @ts-ignore
|
|
88
|
+
if (client['ws'] && client['ws'].onmessage) {
|
|
89
|
+
// @ts-ignore
|
|
90
|
+
client['ws'].onmessage({
|
|
91
|
+
data: JSON.stringify({
|
|
92
|
+
type: 'data.update',
|
|
93
|
+
payload: { value: 123 },
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}, 200);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
runTest()
|
|
102
|
+
.then(() => console.log('WS VALIDATION PASSED'))
|
|
103
|
+
.catch((e) => {
|
|
104
|
+
console.error(e);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|