@igniter-js/caller 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1343 @@
1
+ 'use strict';
2
+
3
+ var core = require('@igniter-js/core');
4
+
5
+ // src/builder/igniter-caller.builder.ts
6
+ var IgniterCallerBuilder = class _IgniterCallerBuilder {
7
+ constructor(state, factory) {
8
+ this.state = state;
9
+ this.factory = factory;
10
+ }
11
+ /**
12
+ * Creates a new builder instance.
13
+ */
14
+ static create(factory) {
15
+ return new _IgniterCallerBuilder({}, factory);
16
+ }
17
+ /** Sets the base URL for all requests. */
18
+ withBaseUrl(baseURL) {
19
+ return new _IgniterCallerBuilder({ ...this.state, baseURL }, this.factory);
20
+ }
21
+ /** Merges default headers for all requests. */
22
+ withHeaders(headers) {
23
+ return new _IgniterCallerBuilder({ ...this.state, headers }, this.factory);
24
+ }
25
+ /** Sets default cookies (sent as the `Cookie` header). */
26
+ withCookies(cookies) {
27
+ return new _IgniterCallerBuilder({ ...this.state, cookies }, this.factory);
28
+ }
29
+ /** Attaches a logger instance. */
30
+ withLogger(logger) {
31
+ return new _IgniterCallerBuilder({ ...this.state, logger }, this.factory);
32
+ }
33
+ /** Adds a request interceptor that runs before each request. */
34
+ withRequestInterceptor(interceptor) {
35
+ const requestInterceptors = [
36
+ ...this.state.requestInterceptors || [],
37
+ interceptor
38
+ ];
39
+ return new _IgniterCallerBuilder(
40
+ { ...this.state, requestInterceptors },
41
+ this.factory
42
+ );
43
+ }
44
+ /** Adds a response interceptor that runs after each request. */
45
+ withResponseInterceptor(interceptor) {
46
+ const responseInterceptors = [
47
+ ...this.state.responseInterceptors || [],
48
+ interceptor
49
+ ];
50
+ return new _IgniterCallerBuilder(
51
+ { ...this.state, responseInterceptors },
52
+ this.factory
53
+ );
54
+ }
55
+ /**
56
+ * Configures a persistent store adapter for caching.
57
+ *
58
+ * When configured, cache operations will use the store (e.g., Redis)
59
+ * instead of in-memory cache, enabling persistent cache across deployments.
60
+ */
61
+ withStore(store, options) {
62
+ return new _IgniterCallerBuilder(
63
+ { ...this.state, store, storeOptions: options },
64
+ this.factory
65
+ );
66
+ }
67
+ /**
68
+ * Configures schema-based type safety and validation.
69
+ *
70
+ * Enables automatic type inference for requests/responses based on
71
+ * route and method, with optional runtime validation via Zod.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * const api = IgniterCaller.create()
76
+ * .withSchemas({
77
+ * '/users': {
78
+ * GET: {
79
+ * responses: {
80
+ * 200: z.array(UserSchema),
81
+ * 401: ErrorSchema,
82
+ * },
83
+ * },
84
+ * POST: {
85
+ * request: CreateUserSchema,
86
+ * responses: {
87
+ * 201: UserSchema,
88
+ * 400: ValidationErrorSchema,
89
+ * },
90
+ * },
91
+ * },
92
+ * })
93
+ * .build()
94
+ * ```
95
+ */
96
+ withSchemas(schemas, validation) {
97
+ return new _IgniterCallerBuilder(
98
+ { ...this.state, schemas, schemaValidation: validation },
99
+ this.factory
100
+ );
101
+ }
102
+ /**
103
+ * Builds the `IgniterCaller` instance.
104
+ */
105
+ build() {
106
+ return this.factory(this.state);
107
+ }
108
+ };
109
+ var IgniterCallerError = class _IgniterCallerError extends core.IgniterError {
110
+ constructor(payload) {
111
+ const metadata = {
112
+ ...payload.metadata,
113
+ operation: payload.operation,
114
+ statusText: payload.statusText
115
+ };
116
+ const details = payload.details ?? (payload.cause !== void 0 ? { cause: payload.cause } : void 0);
117
+ super({
118
+ code: payload.code,
119
+ message: payload.message,
120
+ statusCode: payload.statusCode,
121
+ causer: "@igniter-js/caller",
122
+ details,
123
+ metadata,
124
+ logger: payload.logger
125
+ });
126
+ this.name = "IgniterCallerError";
127
+ this.operation = payload.operation;
128
+ this.statusText = payload.statusText;
129
+ this.cause = payload.cause;
130
+ }
131
+ static is(error) {
132
+ return error instanceof _IgniterCallerError;
133
+ }
134
+ };
135
+
136
+ // src/utils/body.ts
137
+ var IgniterCallerBodyUtils = class {
138
+ /**
139
+ * Returns true when the request body should be passed to `fetch` as-is.
140
+ */
141
+ static isRawBody(body) {
142
+ if (!body) return false;
143
+ if (typeof FormData !== "undefined" && body instanceof FormData) return true;
144
+ if (typeof Blob !== "undefined" && body instanceof Blob) return true;
145
+ if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer)
146
+ return true;
147
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams)
148
+ return true;
149
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream)
150
+ return true;
151
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(body)) return true;
152
+ if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body))
153
+ return true;
154
+ return false;
155
+ }
156
+ /**
157
+ * Removes Content-Type for FormData so fetch can set boundaries automatically.
158
+ */
159
+ static normalizeHeadersForBody(headers, body) {
160
+ if (!headers) return headers;
161
+ const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
162
+ if (!isFormData) return headers;
163
+ if (!("Content-Type" in headers)) return headers;
164
+ const { "Content-Type": _contentType, ...rest } = headers;
165
+ return rest;
166
+ }
167
+ };
168
+
169
+ // src/utils/cache.ts
170
+ var _IgniterCallerCacheUtils = class _IgniterCallerCacheUtils {
171
+ /**
172
+ * Configures a persistent store adapter for caching.
173
+ *
174
+ * When configured, cache operations will use the store (e.g., Redis)
175
+ * instead of in-memory cache, enabling persistent cache across deployments.
176
+ */
177
+ static setStore(store, options) {
178
+ _IgniterCallerCacheUtils.store = store;
179
+ if (options) {
180
+ _IgniterCallerCacheUtils.storeOptions = {
181
+ ..._IgniterCallerCacheUtils.storeOptions,
182
+ ...options
183
+ };
184
+ }
185
+ }
186
+ /**
187
+ * Gets the configured store adapter.
188
+ */
189
+ static getStore() {
190
+ return _IgniterCallerCacheUtils.store;
191
+ }
192
+ /**
193
+ * Gets cached data if it exists and is not stale.
194
+ */
195
+ static async get(key, staleTime) {
196
+ const prefixedKey = _IgniterCallerCacheUtils.getPrefixedKey(key);
197
+ if (_IgniterCallerCacheUtils.store) {
198
+ try {
199
+ const data = await _IgniterCallerCacheUtils.store.get(prefixedKey);
200
+ if (data !== null) {
201
+ return data;
202
+ }
203
+ } catch (error) {
204
+ console.error("IgniterCaller: Store get failed", error);
205
+ }
206
+ }
207
+ const entry = _IgniterCallerCacheUtils.cache.get(prefixedKey);
208
+ if (!entry) return void 0;
209
+ if (staleTime && Date.now() - entry.timestamp > staleTime) {
210
+ _IgniterCallerCacheUtils.cache.delete(prefixedKey);
211
+ return void 0;
212
+ }
213
+ return entry.data;
214
+ }
215
+ /**
216
+ * Stores data in cache with current timestamp.
217
+ */
218
+ static async set(key, data, ttl) {
219
+ const prefixedKey = _IgniterCallerCacheUtils.getPrefixedKey(key);
220
+ const effectiveTtl = ttl || _IgniterCallerCacheUtils.storeOptions.ttl || 3600;
221
+ if (_IgniterCallerCacheUtils.store) {
222
+ try {
223
+ await _IgniterCallerCacheUtils.store.set(prefixedKey, data, {
224
+ ttl: effectiveTtl
225
+ });
226
+ return;
227
+ } catch (error) {
228
+ console.error("IgniterCaller: Store set failed", error);
229
+ }
230
+ }
231
+ _IgniterCallerCacheUtils.cache.set(prefixedKey, {
232
+ data,
233
+ timestamp: Date.now()
234
+ });
235
+ }
236
+ /**
237
+ * Clears a specific cache entry.
238
+ */
239
+ static async clear(key) {
240
+ const prefixedKey = _IgniterCallerCacheUtils.getPrefixedKey(key);
241
+ if (_IgniterCallerCacheUtils.store) {
242
+ try {
243
+ await _IgniterCallerCacheUtils.store.delete(prefixedKey);
244
+ } catch (error) {
245
+ console.error("IgniterCaller: Store delete failed", error);
246
+ }
247
+ }
248
+ _IgniterCallerCacheUtils.cache.delete(prefixedKey);
249
+ }
250
+ /**
251
+ * Clears all cache entries matching a pattern.
252
+ *
253
+ * @param pattern Glob pattern (e.g., '/users/*') or exact key
254
+ */
255
+ static async clearPattern(pattern) {
256
+ const prefixedPattern = _IgniterCallerCacheUtils.getPrefixedKey(pattern);
257
+ const regex = _IgniterCallerCacheUtils.globToRegex(prefixedPattern);
258
+ for (const key of _IgniterCallerCacheUtils.cache.keys()) {
259
+ if (regex.test(key)) {
260
+ _IgniterCallerCacheUtils.cache.delete(key);
261
+ }
262
+ }
263
+ if (_IgniterCallerCacheUtils.store) {
264
+ console.warn(
265
+ "IgniterCaller: Pattern-based cache invalidation in store requires manual implementation"
266
+ );
267
+ }
268
+ }
269
+ /**
270
+ * Clears all cache entries.
271
+ */
272
+ static async clearAll() {
273
+ _IgniterCallerCacheUtils.cache.clear();
274
+ if (_IgniterCallerCacheUtils.store) {
275
+ console.warn(
276
+ "IgniterCaller: clearAll() only clears in-memory cache. Store cache requires manual cleanup."
277
+ );
278
+ }
279
+ }
280
+ /**
281
+ * Adds the configured prefix to a key.
282
+ */
283
+ static getPrefixedKey(key) {
284
+ const prefix = _IgniterCallerCacheUtils.storeOptions.keyPrefix || "";
285
+ return `${prefix}${key}`;
286
+ }
287
+ /**
288
+ * Converts a glob pattern to a RegExp.
289
+ */
290
+ static globToRegex(pattern) {
291
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
292
+ return new RegExp(`^${escaped}$`);
293
+ }
294
+ };
295
+ _IgniterCallerCacheUtils.cache = /* @__PURE__ */ new Map();
296
+ _IgniterCallerCacheUtils.store = null;
297
+ _IgniterCallerCacheUtils.storeOptions = {
298
+ ttl: 3600,
299
+ keyPrefix: "igniter:caller:",
300
+ fallbackToFetch: true
301
+ };
302
+ var IgniterCallerCacheUtils = _IgniterCallerCacheUtils;
303
+
304
+ // src/utils/schema.ts
305
+ var IgniterCallerSchemaUtils = class _IgniterCallerSchemaUtils {
306
+ /**
307
+ * Matches a URL path against schema map paths (supports path parameters).
308
+ *
309
+ * @example
310
+ * ```ts
311
+ * matchPath('/users/123', '/users/:id') // { matched: true, params: { id: '123' } }
312
+ * matchPath('/users', '/users/:id') // { matched: false }
313
+ * ```
314
+ */
315
+ static matchPath(actualPath, schemaPath) {
316
+ if (actualPath === schemaPath) {
317
+ return { matched: true, params: {} };
318
+ }
319
+ const paramNames = [];
320
+ const regexPattern = schemaPath.replace(/:[^/]+/g, (match2) => {
321
+ paramNames.push(match2.slice(1));
322
+ return "([^/]+)";
323
+ }).replace(/\//g, "\\/");
324
+ const regex = new RegExp(`^${regexPattern}$`);
325
+ const match = actualPath.match(regex);
326
+ if (!match) {
327
+ return { matched: false };
328
+ }
329
+ const params = {};
330
+ for (let i = 0; i < paramNames.length; i++) {
331
+ params[paramNames[i]] = match[i + 1];
332
+ }
333
+ return { matched: true, params };
334
+ }
335
+ /**
336
+ * Finds the schema for a given path and method from the schema map.
337
+ */
338
+ static findSchema(schemaMap, path, method) {
339
+ if (!schemaMap) {
340
+ return { schema: void 0, params: {} };
341
+ }
342
+ const exactMatch = schemaMap[path]?.[method];
343
+ if (exactMatch) {
344
+ return { schema: exactMatch, params: {} };
345
+ }
346
+ for (const [schemaPath, methods] of Object.entries(schemaMap)) {
347
+ const matchResult = _IgniterCallerSchemaUtils.matchPath(path, schemaPath);
348
+ if (matchResult.matched) {
349
+ const schema = methods?.[method];
350
+ if (schema) {
351
+ return { schema, params: matchResult.params || {} };
352
+ }
353
+ }
354
+ }
355
+ return { schema: void 0, params: {} };
356
+ }
357
+ /**
358
+ * Validates input using StandardSchemaV1.
359
+ *
360
+ * If the schema provides `~standard.validate`, it will be used.
361
+ * Otherwise, returns the input as-is.
362
+ */
363
+ static async validateWithStandardSchema(schema, input) {
364
+ const standard = schema?.["~standard"];
365
+ if (!standard?.validate) {
366
+ return input;
367
+ }
368
+ const result = await standard.validate(input);
369
+ if (result?.issues?.length) {
370
+ throw {
371
+ issues: result.issues,
372
+ _standardSchemaValidationError: true
373
+ };
374
+ }
375
+ return result?.value ?? input;
376
+ }
377
+ /**
378
+ * Validates request body against schema.
379
+ *
380
+ * @returns Validated data or throws/logs error based on validation mode
381
+ */
382
+ static async validateRequest(data, schema, options, context, logger) {
383
+ if (!schema || options?.mode === "off") {
384
+ return data;
385
+ }
386
+ try {
387
+ const validated = await _IgniterCallerSchemaUtils.validateWithStandardSchema(schema, data);
388
+ return validated;
389
+ } catch (error) {
390
+ if (options?.onValidationError && error?._standardSchemaValidationError) {
391
+ options.onValidationError(error, { ...context, statusCode: 0 });
392
+ }
393
+ if (options?.mode === "soft") {
394
+ logger?.warn("Request validation failed (soft mode)", {
395
+ url: context.url,
396
+ method: context.method,
397
+ errors: error?.issues
398
+ });
399
+ return data;
400
+ }
401
+ throw new IgniterCallerError({
402
+ code: "IGNITER_CALLER_REQUEST_VALIDATION_FAILED",
403
+ message: `Request validation failed for ${context.method} ${context.url}`,
404
+ statusCode: 400,
405
+ details: error?.issues,
406
+ operation: "validateRequest"
407
+ });
408
+ }
409
+ }
410
+ /**
411
+ * Validates response data against schema.
412
+ *
413
+ * @returns Validated data or throws/logs error based on validation mode
414
+ */
415
+ static async validateResponse(data, schema, statusCode, options, context, logger) {
416
+ if (!schema || options?.mode === "off") {
417
+ return data;
418
+ }
419
+ try {
420
+ const validated = await _IgniterCallerSchemaUtils.validateWithStandardSchema(schema, data);
421
+ return validated;
422
+ } catch (error) {
423
+ if (options?.onValidationError && error?._standardSchemaValidationError) {
424
+ options.onValidationError(error, { ...context, statusCode });
425
+ }
426
+ if (options?.mode === "soft") {
427
+ logger?.warn("Response validation failed (soft mode)", {
428
+ url: context.url,
429
+ method: context.method,
430
+ statusCode,
431
+ errors: error?.issues
432
+ });
433
+ return data;
434
+ }
435
+ throw new IgniterCallerError({
436
+ code: "IGNITER_CALLER_RESPONSE_VALIDATION_FAILED",
437
+ message: `Response validation failed for ${context.method} ${context.url} (${statusCode})`,
438
+ statusCode,
439
+ details: error?.issues,
440
+ operation: "validateResponse"
441
+ });
442
+ }
443
+ }
444
+ };
445
+
446
+ // src/utils/url.ts
447
+ var IgniterCallerUrlUtils = class {
448
+ static buildUrl(params) {
449
+ const { url, baseURL, query } = params;
450
+ let fullUrl = url;
451
+ if (baseURL && !/^https?:\/\//i.test(url)) {
452
+ fullUrl = baseURL + url;
453
+ }
454
+ if (query && Object.keys(query).length > 0) {
455
+ const queryParams = new URLSearchParams(
456
+ Object.entries(query).map(([key, value]) => [key, String(value)])
457
+ ).toString();
458
+ fullUrl += (fullUrl.includes("?") ? "&" : "?") + queryParams;
459
+ }
460
+ return fullUrl;
461
+ }
462
+ };
463
+
464
+ // src/builder/igniter-caller-request.builder.ts
465
+ var IgniterCallerRequestBuilder = class {
466
+ constructor(params) {
467
+ this.options = {
468
+ method: "GET",
469
+ url: "",
470
+ headers: {
471
+ "Content-Type": "application/json"
472
+ },
473
+ timeout: 3e4,
474
+ cache: "default"
475
+ };
476
+ if (params.baseURL) this.options.baseURL = params.baseURL;
477
+ if (params.defaultHeaders) {
478
+ this.options.headers = {
479
+ ...this.options.headers,
480
+ ...params.defaultHeaders
481
+ };
482
+ }
483
+ if (params.defaultCookies) {
484
+ const cookieStr = Object.entries(params.defaultCookies).map(([k, v]) => `${k}=${v}`).join("; ");
485
+ this.options.headers = { ...this.options.headers, Cookie: cookieStr };
486
+ }
487
+ this.logger = params.logger;
488
+ this.requestInterceptors = params.requestInterceptors;
489
+ this.responseInterceptors = params.responseInterceptors;
490
+ this.eventEmitter = params.eventEmitter;
491
+ this.schemas = params.schemas;
492
+ this.schemaValidation = params.schemaValidation;
493
+ }
494
+ /**
495
+ * Overrides the logger for this request chain.
496
+ */
497
+ withLogger(logger) {
498
+ this.logger = logger;
499
+ return this;
500
+ }
501
+ method(method) {
502
+ this.options.method = method;
503
+ return this;
504
+ }
505
+ url(url) {
506
+ this.options.url = url;
507
+ return this;
508
+ }
509
+ body(body) {
510
+ this.options.body = body;
511
+ return this;
512
+ }
513
+ params(params) {
514
+ this.options.params = params;
515
+ return this;
516
+ }
517
+ headers(headers) {
518
+ this.options.headers = { ...this.options.headers, ...headers };
519
+ return this;
520
+ }
521
+ timeout(timeout) {
522
+ this.options.timeout = timeout;
523
+ return this;
524
+ }
525
+ cache(cache, key) {
526
+ this.options.cache = cache;
527
+ this.cacheKey = key;
528
+ return this;
529
+ }
530
+ /**
531
+ * Configures retry behavior for failed requests.
532
+ */
533
+ retry(maxAttempts, options) {
534
+ this.retryOptions = { maxAttempts, ...options };
535
+ return this;
536
+ }
537
+ /**
538
+ * Provides a fallback value if the request fails.
539
+ */
540
+ fallback(fn) {
541
+ this.fallbackFn = fn;
542
+ return this;
543
+ }
544
+ /**
545
+ * Sets cache stale time in milliseconds.
546
+ */
547
+ stale(milliseconds) {
548
+ this.staleTime = milliseconds;
549
+ return this;
550
+ }
551
+ responseType(schema) {
552
+ this.options.responseSchema = schema;
553
+ return this;
554
+ }
555
+ /**
556
+ * Downloads a file via GET request.
557
+ */
558
+ getFile(url) {
559
+ this.options.method = "GET";
560
+ this.options.url = url;
561
+ return {
562
+ execute: async () => {
563
+ const {
564
+ url: finalUrl,
565
+ requestInit,
566
+ controller,
567
+ timeoutId
568
+ } = this.buildRequest();
569
+ this.logger?.debug("IgniterCaller.getFile started", { url: finalUrl });
570
+ try {
571
+ const response = await fetch(finalUrl, {
572
+ ...requestInit,
573
+ signal: controller.signal
574
+ });
575
+ clearTimeout(timeoutId);
576
+ if (!response.ok) {
577
+ const errorText = await response.text().catch(() => "");
578
+ return {
579
+ file: null,
580
+ error: new IgniterCallerError({
581
+ code: "IGNITER_CALLER_HTTP_ERROR",
582
+ operation: "download",
583
+ message: errorText || `HTTP error! status: ${response.status}`,
584
+ statusText: response.statusText,
585
+ statusCode: response.status,
586
+ logger: this.logger,
587
+ metadata: { url: finalUrl }
588
+ })
589
+ };
590
+ }
591
+ const data = await response.blob();
592
+ const contentType = response.headers.get("content-type");
593
+ const extension = contentType?.split("/")[1];
594
+ const filenameHeader = response.headers.get("content-disposition");
595
+ const filename = filenameHeader?.split("filename=")[1];
596
+ const filenameExtension = filename?.split(".").pop();
597
+ if (typeof File === "undefined") {
598
+ return {
599
+ file: null,
600
+ error: new IgniterCallerError({
601
+ code: "IGNITER_CALLER_UNKNOWN_ERROR",
602
+ operation: "download",
603
+ message: "`File` is not available in this runtime. Consider using `response.blob()` directly.",
604
+ logger: this.logger,
605
+ metadata: { url: finalUrl }
606
+ })
607
+ };
608
+ }
609
+ const file = new File(
610
+ [data],
611
+ filename || `file.${extension || filenameExtension || "unknown"}`
612
+ );
613
+ this.logger?.info("IgniterCaller.getFile success", {
614
+ url: finalUrl,
615
+ size: file.size,
616
+ type: file.type
617
+ });
618
+ return { file, error: null };
619
+ } catch (error) {
620
+ clearTimeout(timeoutId);
621
+ if (error instanceof Error && error.name === "AbortError") {
622
+ return {
623
+ file: null,
624
+ error: new IgniterCallerError({
625
+ code: "IGNITER_CALLER_TIMEOUT",
626
+ operation: "download",
627
+ message: `Request timeout after ${this.options.timeout || 3e4}ms`,
628
+ statusCode: 408,
629
+ logger: this.logger,
630
+ metadata: { url: finalUrl },
631
+ cause: error
632
+ })
633
+ };
634
+ }
635
+ return {
636
+ file: null,
637
+ error: new IgniterCallerError({
638
+ code: "IGNITER_CALLER_UNKNOWN_ERROR",
639
+ operation: "download",
640
+ message: error?.message || "Failed to download file",
641
+ logger: this.logger,
642
+ metadata: { url: finalUrl },
643
+ cause: error
644
+ })
645
+ };
646
+ }
647
+ }
648
+ };
649
+ }
650
+ async execute() {
651
+ const effectiveCacheKey = this.cacheKey || this.options.url;
652
+ if (effectiveCacheKey && this.staleTime) {
653
+ const cached = await IgniterCallerCacheUtils.get(
654
+ effectiveCacheKey,
655
+ this.staleTime
656
+ );
657
+ if (cached !== void 0) {
658
+ this.logger?.debug("IgniterCaller.execute cache hit", {
659
+ key: effectiveCacheKey
660
+ });
661
+ const cachedResult = { data: cached, error: void 0 };
662
+ await this.emitEvent(cachedResult);
663
+ return cachedResult;
664
+ }
665
+ }
666
+ const result = await this.executeWithRetry();
667
+ if (result.error && this.fallbackFn) {
668
+ this.logger?.debug("IgniterCaller.execute applying fallback", {
669
+ error: result.error
670
+ });
671
+ const fallbackResult = { data: this.fallbackFn(), error: void 0 };
672
+ await this.emitEvent(fallbackResult);
673
+ return fallbackResult;
674
+ }
675
+ if (!result.error && result.data !== void 0 && effectiveCacheKey) {
676
+ await IgniterCallerCacheUtils.set(
677
+ effectiveCacheKey,
678
+ result.data,
679
+ this.staleTime
680
+ );
681
+ }
682
+ await this.emitEvent(result);
683
+ return result;
684
+ }
685
+ async executeWithRetry() {
686
+ const maxAttempts = this.retryOptions?.maxAttempts || 1;
687
+ const baseDelay = this.retryOptions?.baseDelay || 1e3;
688
+ const backoff = this.retryOptions?.backoff || "linear";
689
+ const retryOnStatus = this.retryOptions?.retryOnStatus || [
690
+ 408,
691
+ 429,
692
+ 500,
693
+ 502,
694
+ 503,
695
+ 504
696
+ ];
697
+ let lastError;
698
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
699
+ if (attempt > 0) {
700
+ const delay = backoff === "exponential" ? baseDelay * 2 ** (attempt - 1) : baseDelay * attempt;
701
+ this.logger?.debug("IgniterCaller.execute retrying", {
702
+ attempt,
703
+ delay
704
+ });
705
+ await new Promise((resolve) => setTimeout(resolve, delay));
706
+ }
707
+ const result = await this.executeSingleRequest();
708
+ if (!result.error) {
709
+ return result;
710
+ }
711
+ const shouldRetry = result.error instanceof IgniterCallerError && result.error.statusCode && retryOnStatus.includes(result.error.statusCode);
712
+ if (!shouldRetry || attempt === maxAttempts - 1) {
713
+ return result;
714
+ }
715
+ lastError = result.error;
716
+ }
717
+ return {
718
+ data: void 0,
719
+ error: lastError
720
+ };
721
+ }
722
+ async executeSingleRequest() {
723
+ const { responseSchema } = this.options;
724
+ let { url, requestInit, controller, timeoutId } = this.buildRequest();
725
+ this.logger?.debug("IgniterCaller.execute started", {
726
+ method: this.options.method,
727
+ url
728
+ });
729
+ if (this.requestInterceptors && this.requestInterceptors.length > 0) {
730
+ let modifiedOptions = { ...this.options, url };
731
+ for (const interceptor of this.requestInterceptors) {
732
+ modifiedOptions = await interceptor(modifiedOptions);
733
+ }
734
+ this.options = modifiedOptions;
735
+ const rebuilt = this.buildRequest();
736
+ url = rebuilt.url;
737
+ requestInit = rebuilt.requestInit;
738
+ controller = rebuilt.controller;
739
+ timeoutId = rebuilt.timeoutId;
740
+ }
741
+ if (this.schemas && this.options.body) {
742
+ const { schema: endpointSchema } = IgniterCallerSchemaUtils.findSchema(
743
+ this.schemas,
744
+ url,
745
+ this.options.method
746
+ );
747
+ if (endpointSchema?.request) {
748
+ try {
749
+ await IgniterCallerSchemaUtils.validateRequest(
750
+ this.options.body,
751
+ endpointSchema.request,
752
+ this.schemaValidation,
753
+ { url, method: this.options.method },
754
+ this.logger
755
+ );
756
+ } catch (error) {
757
+ return {
758
+ data: void 0,
759
+ error
760
+ };
761
+ }
762
+ }
763
+ }
764
+ try {
765
+ const httpResponse = await fetch(url, {
766
+ ...requestInit,
767
+ signal: controller.signal
768
+ });
769
+ clearTimeout(timeoutId);
770
+ if (!httpResponse.ok) {
771
+ const errorText = await httpResponse.text().catch(() => "");
772
+ return {
773
+ data: void 0,
774
+ error: new IgniterCallerError({
775
+ code: "IGNITER_CALLER_HTTP_ERROR",
776
+ operation: "execute",
777
+ message: errorText || `HTTP error! status: ${httpResponse.status}`,
778
+ statusText: httpResponse.statusText,
779
+ statusCode: httpResponse.status,
780
+ logger: this.logger,
781
+ metadata: {
782
+ method: this.options.method,
783
+ url
784
+ }
785
+ })
786
+ };
787
+ }
788
+ const contentType = httpResponse.headers.get("content-type");
789
+ const data = contentType?.includes("application/json") ? await httpResponse.json() : await httpResponse.text();
790
+ let validatedData = data;
791
+ if (this.schemas) {
792
+ const { schema: endpointSchema } = IgniterCallerSchemaUtils.findSchema(
793
+ this.schemas,
794
+ url,
795
+ this.options.method
796
+ );
797
+ const responseSchema2 = endpointSchema?.responses?.[httpResponse.status];
798
+ if (responseSchema2) {
799
+ try {
800
+ validatedData = await IgniterCallerSchemaUtils.validateResponse(
801
+ data,
802
+ responseSchema2,
803
+ httpResponse.status,
804
+ this.schemaValidation,
805
+ { url, method: this.options.method },
806
+ this.logger
807
+ );
808
+ } catch (error) {
809
+ return {
810
+ data: void 0,
811
+ error
812
+ };
813
+ }
814
+ }
815
+ }
816
+ if (responseSchema) {
817
+ const result = responseSchema.safeParse(data);
818
+ if (!result.success) {
819
+ return {
820
+ data: void 0,
821
+ error: new IgniterCallerError({
822
+ code: "IGNITER_CALLER_RESPONSE_VALIDATION_FAILED",
823
+ operation: "parseResponse",
824
+ message: `Response validation failed: ${result.error.message}`,
825
+ logger: this.logger,
826
+ statusCode: 500,
827
+ metadata: {
828
+ method: this.options.method,
829
+ url
830
+ },
831
+ cause: result.error
832
+ })
833
+ };
834
+ }
835
+ let response2 = {
836
+ data: result.data,
837
+ error: void 0
838
+ };
839
+ if (this.responseInterceptors && this.responseInterceptors.length > 0) {
840
+ for (const interceptor of this.responseInterceptors) {
841
+ response2 = await interceptor(response2);
842
+ }
843
+ }
844
+ return response2;
845
+ }
846
+ let response = {
847
+ data: validatedData,
848
+ error: void 0
849
+ };
850
+ if (this.responseInterceptors && this.responseInterceptors.length > 0) {
851
+ for (const interceptor of this.responseInterceptors) {
852
+ response = await interceptor(response);
853
+ }
854
+ }
855
+ return response;
856
+ } catch (error) {
857
+ clearTimeout(timeoutId);
858
+ if (error instanceof Error && error.name === "AbortError") {
859
+ return {
860
+ data: void 0,
861
+ error: new IgniterCallerError({
862
+ code: "IGNITER_CALLER_TIMEOUT",
863
+ operation: "execute",
864
+ message: `Request timeout after ${this.options.timeout || 3e4}ms`,
865
+ statusCode: 408,
866
+ logger: this.logger,
867
+ metadata: {
868
+ method: this.options.method,
869
+ url
870
+ },
871
+ cause: error
872
+ })
873
+ };
874
+ }
875
+ return {
876
+ data: void 0,
877
+ error: new IgniterCallerError({
878
+ code: "IGNITER_CALLER_UNKNOWN_ERROR",
879
+ operation: "execute",
880
+ message: error?.message || "Unknown error occurred during request",
881
+ statusCode: 500,
882
+ logger: this.logger,
883
+ metadata: {
884
+ method: this.options.method,
885
+ url
886
+ },
887
+ cause: error
888
+ })
889
+ };
890
+ }
891
+ }
892
+ buildRequest() {
893
+ const { method, url, body, params, headers, timeout, baseURL, cache } = this.options;
894
+ const fullUrl = IgniterCallerUrlUtils.buildUrl({
895
+ url,
896
+ baseURL,
897
+ query: params
898
+ });
899
+ const rawBody = IgniterCallerBodyUtils.isRawBody(body);
900
+ const finalHeaders = IgniterCallerBodyUtils.normalizeHeadersForBody(
901
+ headers,
902
+ body
903
+ );
904
+ const requestInit = {
905
+ method,
906
+ headers: finalHeaders,
907
+ cache,
908
+ ...body && method !== "GET" && method !== "HEAD" ? { body: rawBody ? body : JSON.stringify(body) } : {}
909
+ };
910
+ const controller = new AbortController();
911
+ const timeoutId = setTimeout(() => controller.abort(), timeout || 3e4);
912
+ return {
913
+ url: fullUrl,
914
+ requestInit,
915
+ controller,
916
+ timeoutId
917
+ };
918
+ }
919
+ /**
920
+ * Emits event for this response using injected emitter.
921
+ */
922
+ async emitEvent(result) {
923
+ if (this.eventEmitter) {
924
+ try {
925
+ await this.eventEmitter(this.options.url, this.options.method, result);
926
+ } catch (error) {
927
+ this.logger?.debug("Failed to emit event", { error });
928
+ }
929
+ }
930
+ }
931
+ };
932
+
933
+ // src/core/igniter-caller-events.ts
934
+ var IgniterCallerEvents = class {
935
+ constructor() {
936
+ this.listeners = /* @__PURE__ */ new Map();
937
+ this.patternListeners = /* @__PURE__ */ new Map();
938
+ }
939
+ /**
940
+ * Registers a listener for a specific URL or pattern.
941
+ *
942
+ * @param pattern URL string (exact match) or RegExp pattern
943
+ * @param callback Function to execute when a response matches
944
+ * @returns Cleanup function to remove the listener
945
+ *
946
+ * @example
947
+ * ```ts
948
+ * // Listen to specific endpoint
949
+ * const cleanup = api.on('/users', (result) => {
950
+ * console.log('Users fetched:', result.data)
951
+ * })
952
+ *
953
+ * // Listen to pattern
954
+ * api.on(/^\/users\/\d+$/, (result) => {
955
+ * console.log('User detail fetched')
956
+ * })
957
+ *
958
+ * // Cleanup when done
959
+ * cleanup()
960
+ * ```
961
+ */
962
+ on(pattern, callback) {
963
+ if (typeof pattern === "string") {
964
+ if (!this.listeners.has(pattern)) {
965
+ this.listeners.set(pattern, /* @__PURE__ */ new Set());
966
+ }
967
+ const callbacks2 = this.listeners.get(pattern);
968
+ if (callbacks2) {
969
+ callbacks2.add(callback);
970
+ }
971
+ return () => {
972
+ const callbacks3 = this.listeners.get(pattern);
973
+ if (callbacks3) {
974
+ callbacks3.delete(callback);
975
+ if (callbacks3.size === 0) {
976
+ this.listeners.delete(pattern);
977
+ }
978
+ }
979
+ };
980
+ }
981
+ if (!this.patternListeners.has(pattern)) {
982
+ this.patternListeners.set(pattern, /* @__PURE__ */ new Set());
983
+ }
984
+ const callbacks = this.patternListeners.get(pattern);
985
+ if (callbacks) {
986
+ callbacks.add(callback);
987
+ }
988
+ return () => {
989
+ const callbacks2 = this.patternListeners.get(pattern);
990
+ if (callbacks2) {
991
+ callbacks2.delete(callback);
992
+ if (callbacks2.size === 0) {
993
+ this.patternListeners.delete(pattern);
994
+ }
995
+ }
996
+ };
997
+ }
998
+ /**
999
+ * Removes a specific listener or all listeners for a pattern.
1000
+ */
1001
+ off(pattern, callback) {
1002
+ if (typeof pattern === "string") {
1003
+ if (callback !== void 0) {
1004
+ this.listeners.get(pattern)?.delete(callback);
1005
+ } else {
1006
+ this.listeners.delete(pattern);
1007
+ }
1008
+ } else {
1009
+ if (callback !== void 0) {
1010
+ this.patternListeners.get(pattern)?.delete(callback);
1011
+ } else {
1012
+ this.patternListeners.delete(pattern);
1013
+ }
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Emits an event to all matching listeners.
1018
+ *
1019
+ * @internal
1020
+ */
1021
+ async emit(url, method, result) {
1022
+ const context = {
1023
+ url,
1024
+ method,
1025
+ timestamp: Date.now()
1026
+ };
1027
+ const exactListeners = this.listeners.get(url);
1028
+ if (exactListeners) {
1029
+ for (const callback of exactListeners) {
1030
+ try {
1031
+ await callback(result, context);
1032
+ } catch (error) {
1033
+ console.error("Error in IgniterCaller event listener:", error);
1034
+ }
1035
+ }
1036
+ }
1037
+ for (const [pattern, callbacks] of this.patternListeners.entries()) {
1038
+ if (pattern.test(url)) {
1039
+ for (const callback of callbacks) {
1040
+ try {
1041
+ await callback(result, context);
1042
+ } catch (error) {
1043
+ console.error("Error in IgniterCaller event listener:", error);
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+ }
1049
+ /**
1050
+ * Removes all listeners.
1051
+ */
1052
+ clear() {
1053
+ this.listeners.clear();
1054
+ this.patternListeners.clear();
1055
+ }
1056
+ };
1057
+
1058
+ // src/core/igniter-caller.ts
1059
+ var _IgniterCaller = class _IgniterCaller {
1060
+ constructor(baseURL, opts) {
1061
+ this.baseURL = baseURL;
1062
+ this.headers = opts?.headers;
1063
+ this.cookies = opts?.cookies;
1064
+ this.logger = opts?.logger;
1065
+ this.requestInterceptors = opts?.requestInterceptors;
1066
+ this.responseInterceptors = opts?.responseInterceptors;
1067
+ this.schemas = opts?.schemas;
1068
+ this.schemaValidation = opts?.schemaValidation;
1069
+ }
1070
+ /**
1071
+ * Canonical initialization entrypoint.
1072
+ *
1073
+ * This is designed to remain stable when extracted to `@igniter-js/caller`.
1074
+ */
1075
+ static create() {
1076
+ return IgniterCallerBuilder.create((state) => {
1077
+ if (state.store) {
1078
+ IgniterCallerCacheUtils.setStore(state.store, state.storeOptions);
1079
+ }
1080
+ return new _IgniterCaller(state.baseURL, {
1081
+ headers: state.headers,
1082
+ cookies: state.cookies,
1083
+ logger: state.logger,
1084
+ requestInterceptors: state.requestInterceptors,
1085
+ responseInterceptors: state.responseInterceptors,
1086
+ schemas: state.schemas,
1087
+ schemaValidation: state.schemaValidation
1088
+ });
1089
+ });
1090
+ }
1091
+ /**
1092
+ * Returns a new client with the same config and a new logger.
1093
+ */
1094
+ withLogger(logger) {
1095
+ return new _IgniterCaller(this.baseURL, {
1096
+ headers: this.headers,
1097
+ cookies: this.cookies,
1098
+ logger,
1099
+ requestInterceptors: this.requestInterceptors,
1100
+ responseInterceptors: this.responseInterceptors,
1101
+ schemas: this.schemas,
1102
+ schemaValidation: this.schemaValidation
1103
+ });
1104
+ }
1105
+ setBaseURL(baseURL) {
1106
+ this.baseURL = baseURL;
1107
+ return this;
1108
+ }
1109
+ setHeaders(headers) {
1110
+ this.headers = headers;
1111
+ return this;
1112
+ }
1113
+ setCookies(cookies) {
1114
+ this.cookies = cookies;
1115
+ return this;
1116
+ }
1117
+ get() {
1118
+ return new IgniterCallerRequestBuilder({
1119
+ baseURL: this.baseURL,
1120
+ defaultHeaders: this.headers,
1121
+ defaultCookies: this.cookies,
1122
+ logger: this.logger,
1123
+ requestInterceptors: this.requestInterceptors,
1124
+ responseInterceptors: this.responseInterceptors,
1125
+ eventEmitter: async (url, method, result) => {
1126
+ await _IgniterCaller.emitEvent(url, method, result);
1127
+ },
1128
+ schemas: this.schemas,
1129
+ schemaValidation: this.schemaValidation
1130
+ }).method("GET");
1131
+ }
1132
+ post() {
1133
+ return new IgniterCallerRequestBuilder({
1134
+ baseURL: this.baseURL,
1135
+ defaultHeaders: this.headers,
1136
+ defaultCookies: this.cookies,
1137
+ logger: this.logger,
1138
+ requestInterceptors: this.requestInterceptors,
1139
+ responseInterceptors: this.responseInterceptors,
1140
+ eventEmitter: async (url, method, result) => {
1141
+ await _IgniterCaller.emitEvent(url, method, result);
1142
+ },
1143
+ schemas: this.schemas,
1144
+ schemaValidation: this.schemaValidation
1145
+ }).method("POST");
1146
+ }
1147
+ put() {
1148
+ return new IgniterCallerRequestBuilder({
1149
+ baseURL: this.baseURL,
1150
+ defaultHeaders: this.headers,
1151
+ defaultCookies: this.cookies,
1152
+ logger: this.logger,
1153
+ requestInterceptors: this.requestInterceptors,
1154
+ responseInterceptors: this.responseInterceptors,
1155
+ eventEmitter: async (url, method, result) => {
1156
+ await _IgniterCaller.emitEvent(url, method, result);
1157
+ },
1158
+ schemas: this.schemas,
1159
+ schemaValidation: this.schemaValidation
1160
+ }).method("PUT");
1161
+ }
1162
+ patch() {
1163
+ return new IgniterCallerRequestBuilder({
1164
+ baseURL: this.baseURL,
1165
+ defaultHeaders: this.headers,
1166
+ defaultCookies: this.cookies,
1167
+ logger: this.logger,
1168
+ requestInterceptors: this.requestInterceptors,
1169
+ responseInterceptors: this.responseInterceptors,
1170
+ eventEmitter: async (url, method, result) => {
1171
+ await _IgniterCaller.emitEvent(url, method, result);
1172
+ },
1173
+ schemas: this.schemas,
1174
+ schemaValidation: this.schemaValidation
1175
+ }).method("PATCH");
1176
+ }
1177
+ delete() {
1178
+ return new IgniterCallerRequestBuilder({
1179
+ baseURL: this.baseURL,
1180
+ defaultHeaders: this.headers,
1181
+ defaultCookies: this.cookies,
1182
+ logger: this.logger,
1183
+ requestInterceptors: this.requestInterceptors,
1184
+ responseInterceptors: this.responseInterceptors,
1185
+ eventEmitter: async (url, method, result) => {
1186
+ await _IgniterCaller.emitEvent(url, method, result);
1187
+ },
1188
+ schemas: this.schemas,
1189
+ schemaValidation: this.schemaValidation
1190
+ }).method("DELETE");
1191
+ }
1192
+ request() {
1193
+ return new IgniterCallerRequestBuilder({
1194
+ baseURL: this.baseURL,
1195
+ defaultHeaders: this.headers,
1196
+ defaultCookies: this.cookies,
1197
+ logger: this.logger,
1198
+ requestInterceptors: this.requestInterceptors,
1199
+ responseInterceptors: this.responseInterceptors,
1200
+ eventEmitter: async (url, method, result) => {
1201
+ await _IgniterCaller.emitEvent(url, method, result);
1202
+ }
1203
+ });
1204
+ }
1205
+ /**
1206
+ * Executes multiple requests in parallel and returns results as an array.
1207
+ *
1208
+ * This is useful for batching independent API calls.
1209
+ */
1210
+ static async batch(requests) {
1211
+ return Promise.all(requests);
1212
+ }
1213
+ /**
1214
+ * Registers a global event listener for HTTP responses.
1215
+ *
1216
+ * This allows observing API responses across the application for:
1217
+ * - Debugging and logging
1218
+ * - Real-time monitoring
1219
+ * - Cache invalidation triggers
1220
+ * - Analytics and telemetry
1221
+ *
1222
+ * @param pattern URL string (exact match) or RegExp pattern
1223
+ * @param callback Function to execute when a response matches
1224
+ * @returns Cleanup function to remove the listener
1225
+ *
1226
+ * @example
1227
+ * ```ts
1228
+ * // Listen to all user endpoints
1229
+ * const cleanup = IgniterCaller.on(/^\/users/, (result, context) => {
1230
+ * console.log(`${context.method} ${context.url}`, result)
1231
+ * })
1232
+ *
1233
+ * // Cleanup when done
1234
+ * cleanup()
1235
+ * ```
1236
+ */
1237
+ static on(pattern, callback) {
1238
+ return _IgniterCaller.events.on(pattern, callback);
1239
+ }
1240
+ /**
1241
+ * Removes event listeners for a pattern.
1242
+ */
1243
+ static off(pattern, callback) {
1244
+ _IgniterCaller.events.off(pattern, callback);
1245
+ }
1246
+ /**
1247
+ * Invalidates a specific cache entry.
1248
+ *
1249
+ * This is useful after mutations to ensure fresh data on next fetch.
1250
+ *
1251
+ * @example
1252
+ * ```ts
1253
+ * // After creating a user
1254
+ * await api.post().url('/users').body(newUser).execute()
1255
+ * await IgniterCaller.invalidate('/users') // Clear users list cache
1256
+ * ```
1257
+ */
1258
+ static async invalidate(key) {
1259
+ await IgniterCallerCacheUtils.clear(key);
1260
+ }
1261
+ /**
1262
+ * Invalidates all cache entries matching a pattern.
1263
+ *
1264
+ * @param pattern Glob pattern (e.g., '/users/*') or exact key
1265
+ *
1266
+ * @example
1267
+ * ```ts
1268
+ * // Invalidate all user-related caches
1269
+ * await IgniterCaller.invalidatePattern('/users/*')
1270
+ * ```
1271
+ */
1272
+ static async invalidatePattern(pattern) {
1273
+ await IgniterCallerCacheUtils.clearPattern(pattern);
1274
+ }
1275
+ /**
1276
+ * Emits an event to all registered listeners.
1277
+ *
1278
+ * @internal
1279
+ */
1280
+ static async emitEvent(url, method, result) {
1281
+ await _IgniterCaller.events.emit(url, method, result);
1282
+ }
1283
+ };
1284
+ /** Global event emitter for observing HTTP responses */
1285
+ _IgniterCaller.events = new IgniterCallerEvents();
1286
+ var IgniterCaller = _IgniterCaller;
1287
+
1288
+ // src/utils/testing.ts
1289
+ var IgniterCallerMock = class {
1290
+ /**
1291
+ * Creates a successful mock response.
1292
+ */
1293
+ static mockResponse(data) {
1294
+ return { data, error: void 0 };
1295
+ }
1296
+ /**
1297
+ * Creates an error mock response.
1298
+ */
1299
+ static mockError(code, message = "Mock error") {
1300
+ return {
1301
+ data: void 0,
1302
+ error: new IgniterCallerError({
1303
+ code,
1304
+ operation: "execute",
1305
+ message
1306
+ })
1307
+ };
1308
+ }
1309
+ /**
1310
+ * Creates a successful file download mock.
1311
+ */
1312
+ static mockFile(filename, content) {
1313
+ const blob = typeof content === "string" ? new Blob([content]) : content;
1314
+ const file = new File([blob], filename);
1315
+ return { file, error: null };
1316
+ }
1317
+ /**
1318
+ * Creates a failed file download mock.
1319
+ */
1320
+ static mockFileError(message = "Mock file error") {
1321
+ return {
1322
+ file: null,
1323
+ error: new IgniterCallerError({
1324
+ code: "IGNITER_CALLER_UNKNOWN_ERROR",
1325
+ operation: "download",
1326
+ message
1327
+ })
1328
+ };
1329
+ }
1330
+ };
1331
+
1332
+ exports.IgniterCaller = IgniterCaller;
1333
+ exports.IgniterCallerBodyUtils = IgniterCallerBodyUtils;
1334
+ exports.IgniterCallerBuilder = IgniterCallerBuilder;
1335
+ exports.IgniterCallerCacheUtils = IgniterCallerCacheUtils;
1336
+ exports.IgniterCallerError = IgniterCallerError;
1337
+ exports.IgniterCallerEvents = IgniterCallerEvents;
1338
+ exports.IgniterCallerMock = IgniterCallerMock;
1339
+ exports.IgniterCallerRequestBuilder = IgniterCallerRequestBuilder;
1340
+ exports.IgniterCallerSchemaUtils = IgniterCallerSchemaUtils;
1341
+ exports.IgniterCallerUrlUtils = IgniterCallerUrlUtils;
1342
+ //# sourceMappingURL=index.js.map
1343
+ //# sourceMappingURL=index.js.map