@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.
@@ -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
+ }
@@ -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,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "outDir": "./dist",
8
+ "strict": true
9
+ },
10
+ "include": ["index.ts"]
11
+ }
@@ -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
+ });