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