@quikturn/logos 0.2.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,726 @@
1
+ 'use strict';
2
+
3
+ // src/constants.ts
4
+ var BASE_URL = "https://logos.getquikturn.io";
5
+ var DEFAULT_WIDTH = 128;
6
+ var MAX_WIDTH = 800;
7
+ var MAX_WIDTH_SERVER = 1200;
8
+ var SUPPORTED_FORMATS = /* @__PURE__ */ new Set([
9
+ "image/png",
10
+ "image/jpeg",
11
+ "image/webp",
12
+ "image/avif"
13
+ ]);
14
+ var FORMAT_ALIASES = {
15
+ png: "image/png",
16
+ jpeg: "image/jpeg",
17
+ webp: "image/webp",
18
+ avif: "image/avif"
19
+ };
20
+ var MAX_RETRY_AFTER_SECONDS = 300;
21
+ var MAX_RESPONSE_BODY_BYTES = 10485760;
22
+
23
+ // src/errors.ts
24
+ var LogoError = class extends Error {
25
+ constructor(message, code, status) {
26
+ super(message);
27
+ this.name = "LogoError";
28
+ this.code = code;
29
+ this.status = status;
30
+ Object.setPrototypeOf(this, new.target.prototype);
31
+ }
32
+ };
33
+ var DomainValidationError = class extends LogoError {
34
+ constructor(message, domain) {
35
+ super(message, "DOMAIN_VALIDATION_ERROR");
36
+ this.name = "DomainValidationError";
37
+ this.domain = domain;
38
+ }
39
+ };
40
+ var RateLimitError = class extends LogoError {
41
+ constructor(message, retryAfter, remaining, resetAt) {
42
+ super(message, "RATE_LIMIT_ERROR", 429);
43
+ this.name = "RateLimitError";
44
+ this.retryAfter = retryAfter;
45
+ this.remaining = remaining;
46
+ this.resetAt = resetAt;
47
+ }
48
+ };
49
+ var QuotaExceededError = class extends LogoError {
50
+ constructor(message, retryAfter, limit, used) {
51
+ super(message, "QUOTA_EXCEEDED_ERROR", 429);
52
+ this.name = "QuotaExceededError";
53
+ this.retryAfter = retryAfter;
54
+ this.limit = limit;
55
+ this.used = used;
56
+ }
57
+ };
58
+ var AuthenticationError = class extends LogoError {
59
+ constructor(message) {
60
+ super(message, "AUTHENTICATION_ERROR", 401);
61
+ this.name = "AuthenticationError";
62
+ }
63
+ };
64
+ var ForbiddenError = class extends LogoError {
65
+ constructor(message, reason) {
66
+ super(message, "FORBIDDEN_ERROR", 403);
67
+ this.name = "ForbiddenError";
68
+ this.reason = reason;
69
+ }
70
+ };
71
+ var NotFoundError = class extends LogoError {
72
+ constructor(message, domain) {
73
+ super(message, "NOT_FOUND_ERROR", 404);
74
+ this.name = "NotFoundError";
75
+ this.domain = domain;
76
+ }
77
+ };
78
+ var ScrapeTimeoutError = class extends LogoError {
79
+ constructor(message, jobId, elapsed) {
80
+ super(message, "SCRAPE_TIMEOUT_ERROR");
81
+ this.name = "ScrapeTimeoutError";
82
+ this.jobId = jobId;
83
+ this.elapsed = elapsed;
84
+ }
85
+ };
86
+ var BadRequestError = class extends LogoError {
87
+ constructor(message) {
88
+ super(message, "BAD_REQUEST_ERROR", 400);
89
+ this.name = "BadRequestError";
90
+ }
91
+ };
92
+
93
+ // src/url-builder.ts
94
+ var IP_ADDRESS_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
95
+ var LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
96
+ function validateDomain(domain) {
97
+ const normalized = domain.trim().toLowerCase();
98
+ const clean = normalized.endsWith(".") ? normalized.slice(0, -1) : normalized;
99
+ if (clean.length === 0) {
100
+ throw new DomainValidationError("Domain must not be empty", domain);
101
+ }
102
+ if (clean.includes("://")) {
103
+ throw new DomainValidationError(
104
+ 'Domain must not include a protocol scheme (e.g. remove "https://")',
105
+ domain
106
+ );
107
+ }
108
+ if (clean.includes("/")) {
109
+ throw new DomainValidationError(
110
+ "Domain must not include a path \u2014 provide only the hostname",
111
+ domain
112
+ );
113
+ }
114
+ if (IP_ADDRESS_RE.test(clean)) {
115
+ throw new DomainValidationError(
116
+ "IP addresses are not supported \u2014 provide a domain name",
117
+ domain
118
+ );
119
+ }
120
+ if (clean === "localhost") {
121
+ throw new DomainValidationError(
122
+ '"localhost" is not a valid domain',
123
+ domain
124
+ );
125
+ }
126
+ if (clean.length > 253) {
127
+ throw new DomainValidationError(
128
+ `Domain exceeds maximum length of 253 characters (got ${clean.length})`,
129
+ domain
130
+ );
131
+ }
132
+ const labels = clean.split(".");
133
+ if (labels.length < 2) {
134
+ throw new DomainValidationError(
135
+ 'Domain must contain at least two labels (e.g. "example.com")',
136
+ domain
137
+ );
138
+ }
139
+ for (const label of labels) {
140
+ if (label.length === 0) {
141
+ throw new DomainValidationError(
142
+ "Domain contains an empty label (consecutive dots)",
143
+ domain
144
+ );
145
+ }
146
+ if (label.length > 63) {
147
+ throw new DomainValidationError(
148
+ `Label "${label}" exceeds maximum length of 63 characters (got ${label.length})`,
149
+ domain
150
+ );
151
+ }
152
+ if (!LABEL_RE.test(label)) {
153
+ throw new DomainValidationError(
154
+ `Label "${label}" contains invalid characters \u2014 only letters, digits, and hyphens are allowed, and labels must not start or end with a hyphen`,
155
+ domain
156
+ );
157
+ }
158
+ }
159
+ return clean;
160
+ }
161
+ function resolveFormat(format) {
162
+ let candidate = format;
163
+ if (candidate.startsWith("image/")) {
164
+ candidate = candidate.slice(6);
165
+ }
166
+ if (candidate in FORMAT_ALIASES) {
167
+ return candidate;
168
+ }
169
+ if (SUPPORTED_FORMATS.has(format)) {
170
+ const shorthand = format.slice(6);
171
+ if (shorthand in FORMAT_ALIASES) {
172
+ return shorthand;
173
+ }
174
+ }
175
+ return void 0;
176
+ }
177
+ function logoUrl(domain, options) {
178
+ const validDomain = validateDomain(domain);
179
+ const {
180
+ token,
181
+ size,
182
+ width,
183
+ greyscale,
184
+ theme,
185
+ format,
186
+ autoScrape,
187
+ baseUrl
188
+ } = options ?? {};
189
+ const maxWidth = token?.startsWith("sk_") ? MAX_WIDTH_SERVER : MAX_WIDTH;
190
+ let resolvedSize = size ?? width ?? DEFAULT_WIDTH;
191
+ if (resolvedSize <= 0) {
192
+ resolvedSize = DEFAULT_WIDTH;
193
+ }
194
+ resolvedSize = Math.max(1, Math.min(resolvedSize, maxWidth));
195
+ const resolvedFormat = format ? resolveFormat(format) : void 0;
196
+ const effectiveBaseUrl = baseUrl ?? BASE_URL;
197
+ const url = new URL(`${effectiveBaseUrl}/${validDomain}`);
198
+ if (token !== void 0) {
199
+ url.searchParams.set("token", token);
200
+ }
201
+ if (resolvedSize !== DEFAULT_WIDTH) {
202
+ url.searchParams.set("size", String(resolvedSize));
203
+ }
204
+ if (greyscale === true) {
205
+ url.searchParams.set("greyscale", "1");
206
+ }
207
+ if (theme === "light" || theme === "dark") {
208
+ url.searchParams.set("theme", theme);
209
+ }
210
+ if (resolvedFormat !== void 0) {
211
+ url.searchParams.set("format", resolvedFormat);
212
+ }
213
+ if (autoScrape === true) {
214
+ url.searchParams.set("autoScrape", "true");
215
+ }
216
+ return url.toString();
217
+ }
218
+
219
+ // src/headers.ts
220
+ function safeParseInt(value, fallback) {
221
+ if (value === null) return fallback;
222
+ const parsed = parseInt(value, 10);
223
+ return Number.isNaN(parsed) ? fallback : parsed;
224
+ }
225
+ function safeParseFloat(value, fallback) {
226
+ if (value === null) return fallback;
227
+ const parsed = parseFloat(value);
228
+ return Number.isNaN(parsed) ? fallback : parsed;
229
+ }
230
+ function parseLogoHeaders(headers) {
231
+ const cacheStatus = headers.get("X-Cache-Status");
232
+ const rateLimitRemaining = safeParseInt(headers.get("X-RateLimit-Remaining"), 0);
233
+ const rateLimitReset = safeParseInt(headers.get("X-RateLimit-Reset"), 0);
234
+ const quotaRemaining = safeParseInt(headers.get("X-Quota-Remaining"), 0);
235
+ const quotaLimit = safeParseInt(headers.get("X-Quota-Limit"), 0);
236
+ const tokenPrefix = headers.get("X-Quikturn-Token") ?? void 0;
237
+ const transformApplied = headers.get("X-Transformation-Applied") === "true";
238
+ const VALID_TRANSFORM_STATUSES = /* @__PURE__ */ new Set([
239
+ "not-requested",
240
+ "unsupported-format",
241
+ "transformation-error"
242
+ ]);
243
+ const rawTransformStatus = headers.get("X-Transformation-Status");
244
+ const transformStatus = rawTransformStatus && VALID_TRANSFORM_STATUSES.has(rawTransformStatus) ? rawTransformStatus : void 0;
245
+ const rawTransformMethod = headers.get("X-Transformation-Method");
246
+ const transformMethod = rawTransformMethod === "images-binding" ? "images-binding" : void 0;
247
+ const transformWidth = safeParseInt(headers.get("X-Transformation-Width"), void 0);
248
+ const transformGreyscale = headers.get("X-Transformation-Greyscale") === "true" ? true : void 0;
249
+ const transformGamma = safeParseFloat(headers.get("X-Transformation-Gamma"), void 0);
250
+ return {
251
+ cache: { status: cacheStatus === "HIT" ? "HIT" : "MISS" },
252
+ rateLimit: {
253
+ remaining: rateLimitRemaining,
254
+ reset: new Date(rateLimitReset * 1e3)
255
+ },
256
+ quota: {
257
+ remaining: quotaRemaining,
258
+ limit: quotaLimit
259
+ },
260
+ transformation: {
261
+ applied: transformApplied,
262
+ ...transformStatus ? { status: transformStatus } : {},
263
+ ...transformMethod ? { method: transformMethod } : {},
264
+ ...transformWidth !== void 0 ? { width: transformWidth } : {},
265
+ ...transformGreyscale !== void 0 ? { greyscale: transformGreyscale } : {},
266
+ ...transformGamma !== void 0 ? { gamma: transformGamma } : {}
267
+ },
268
+ ...tokenPrefix !== void 0 ? { tokenPrefix } : {}
269
+ };
270
+ }
271
+ function parseRetryAfter(headers) {
272
+ const value = headers.get("Retry-After");
273
+ if (value === null) return null;
274
+ const parsed = Number(value);
275
+ return Number.isFinite(parsed) ? parsed : null;
276
+ }
277
+
278
+ // src/internal/delay.ts
279
+ function delay(ms, signal) {
280
+ if (signal?.aborted) {
281
+ return Promise.reject(new LogoError("Aborted", "ABORT_ERROR"));
282
+ }
283
+ return new Promise((resolve, reject) => {
284
+ let onAbort;
285
+ const timer = setTimeout(() => {
286
+ if (onAbort && signal) {
287
+ signal.removeEventListener("abort", onAbort);
288
+ }
289
+ resolve();
290
+ }, ms);
291
+ if (signal) {
292
+ onAbort = () => {
293
+ clearTimeout(timer);
294
+ reject(new LogoError("Aborted", "ABORT_ERROR"));
295
+ };
296
+ signal.addEventListener("abort", onAbort, { once: true });
297
+ }
298
+ });
299
+ }
300
+
301
+ // src/client/fetcher.ts
302
+ var DEFAULT_MAX_RETRIES = 2;
303
+ var DEFAULT_RETRY_AFTER_SECONDS = 60;
304
+ var SERVER_ERROR_RETRY_DELAY_MS = 1e3;
305
+ var WARNING_THRESHOLD = 0.1;
306
+ function extractDomainFromUrl(url) {
307
+ try {
308
+ const parsed = new URL(url);
309
+ const path = parsed.pathname.slice(1);
310
+ return path || "unknown";
311
+ } catch {
312
+ return "unknown";
313
+ }
314
+ }
315
+ function isAbortError(err) {
316
+ return err instanceof Error && err.name === "AbortError";
317
+ }
318
+ function emitWarnings(headers, onRateLimitWarning, onQuotaWarning) {
319
+ if (onRateLimitWarning) {
320
+ const remaining = parseInt(headers.get("X-RateLimit-Remaining") ?? "", 10);
321
+ const limit = parseInt(headers.get("X-RateLimit-Limit") ?? "", 10);
322
+ if (!Number.isNaN(remaining) && !Number.isNaN(limit) && limit > 0) {
323
+ if (remaining < limit * WARNING_THRESHOLD) {
324
+ onRateLimitWarning(remaining, limit);
325
+ }
326
+ }
327
+ }
328
+ if (onQuotaWarning) {
329
+ const remaining = parseInt(headers.get("X-Quota-Remaining") ?? "", 10);
330
+ const limit = parseInt(headers.get("X-Quota-Limit") ?? "", 10);
331
+ if (!Number.isNaN(remaining) && !Number.isNaN(limit) && limit > 0) {
332
+ if (remaining < limit * WARNING_THRESHOLD) {
333
+ onQuotaWarning(remaining, limit);
334
+ }
335
+ }
336
+ }
337
+ }
338
+ async function browserFetch(url, options) {
339
+ const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
340
+ const signal = options?.signal;
341
+ const format = options?.format;
342
+ const onRateLimitWarning = options?.onRateLimitWarning;
343
+ const onQuotaWarning = options?.onQuotaWarning;
344
+ const requestHeaders = {};
345
+ if (format) {
346
+ requestHeaders["Accept"] = format;
347
+ }
348
+ let retryCount = 0;
349
+ let serverErrorRetried = false;
350
+ while (true) {
351
+ let response;
352
+ try {
353
+ response = await globalThis.fetch(url, {
354
+ headers: requestHeaders,
355
+ signal
356
+ });
357
+ } catch (err) {
358
+ if (isAbortError(err)) {
359
+ throw new LogoError("Request aborted", "ABORT_ERROR");
360
+ }
361
+ const message = err instanceof Error ? err.message : "Unknown network error";
362
+ throw new LogoError(`Network error: ${message}`, "NETWORK_ERROR");
363
+ }
364
+ if (response.ok) {
365
+ emitWarnings(response.headers, onRateLimitWarning, onQuotaWarning);
366
+ return response;
367
+ }
368
+ if (response.status === 401) {
369
+ throw new AuthenticationError("Authentication failed");
370
+ }
371
+ if (response.status === 403) {
372
+ const body2 = await response.text();
373
+ const reason = body2.slice(0, 256) || "unknown";
374
+ throw new ForbiddenError("Access forbidden", reason);
375
+ }
376
+ if (response.status === 404) {
377
+ const domain = extractDomainFromUrl(url);
378
+ throw new NotFoundError("Logo not found", domain);
379
+ }
380
+ if (response.status === 429) {
381
+ const retryAfter = parseRetryAfter(response.headers) ?? DEFAULT_RETRY_AFTER_SECONDS;
382
+ const quotaLimitHeader = response.headers.get("X-Quota-Limit");
383
+ if (quotaLimitHeader !== null) {
384
+ const quotaLimit = parseInt(quotaLimitHeader, 10) || 0;
385
+ const quotaRemaining = parseInt(response.headers.get("X-Quota-Remaining") ?? "0", 10) || 0;
386
+ const used = quotaLimit - quotaRemaining;
387
+ throw new QuotaExceededError(
388
+ "Monthly quota exceeded",
389
+ retryAfter,
390
+ quotaLimit,
391
+ used
392
+ );
393
+ }
394
+ if (retryCount < maxRetries) {
395
+ retryCount++;
396
+ const retryDelay = Math.min(Math.max(1, retryAfter), MAX_RETRY_AFTER_SECONDS) * 1e3;
397
+ await delay(retryDelay);
398
+ continue;
399
+ }
400
+ const remaining = parseInt(
401
+ response.headers.get("X-RateLimit-Remaining") ?? "0",
402
+ 10
403
+ ) || 0;
404
+ const resetEpoch = parseInt(response.headers.get("X-RateLimit-Reset") ?? "0", 10) || 0;
405
+ throw new RateLimitError(
406
+ "Rate limit exceeded",
407
+ retryAfter,
408
+ remaining,
409
+ new Date(resetEpoch * 1e3)
410
+ );
411
+ }
412
+ if (response.status === 400) {
413
+ const body2 = await response.text();
414
+ throw new BadRequestError(body2.slice(0, 256) || "Bad request");
415
+ }
416
+ if (response.status === 500) {
417
+ if (!serverErrorRetried) {
418
+ serverErrorRetried = true;
419
+ await delay(SERVER_ERROR_RETRY_DELAY_MS);
420
+ continue;
421
+ }
422
+ const body2 = await response.text();
423
+ throw new LogoError(
424
+ body2.slice(0, 256) || "Internal server error",
425
+ "SERVER_ERROR",
426
+ 500
427
+ );
428
+ }
429
+ await response.text();
430
+ throw new LogoError(
431
+ `Unexpected response: ${response.status}`,
432
+ "UNEXPECTED_ERROR",
433
+ response.status
434
+ );
435
+ }
436
+ }
437
+
438
+ // src/client/scrape-poller.ts
439
+ var DEFAULT_SCRAPE_TIMEOUT_MS = 3e4;
440
+ var MAX_BACKOFF_MS = 5e3;
441
+ var MAX_POLL_RETRIES = 3;
442
+ function appendToken(url, token) {
443
+ const parsed = new URL(url);
444
+ parsed.searchParams.set("token", token);
445
+ return parsed.toString();
446
+ }
447
+ async function handleScrapeResponse(response, originalUrl, fetchFn, options) {
448
+ if (response.status !== 202) {
449
+ return response;
450
+ }
451
+ let body;
452
+ try {
453
+ body = await response.json();
454
+ } catch {
455
+ throw new LogoError("Failed to parse scrape response", "SCRAPE_PARSE_ERROR");
456
+ }
457
+ const { scrapeJob } = body;
458
+ const { jobId, pollUrl, estimatedWaitMs } = scrapeJob;
459
+ try {
460
+ const originalOrigin = new URL(originalUrl).origin;
461
+ const pollOrigin = new URL(pollUrl).origin;
462
+ if (pollOrigin !== originalOrigin) {
463
+ throw new LogoError(
464
+ "Poll URL origin does not match API origin",
465
+ "SCRAPE_PARSE_ERROR"
466
+ );
467
+ }
468
+ } catch (err) {
469
+ if (err instanceof LogoError) throw err;
470
+ throw new LogoError("Invalid poll URL", "SCRAPE_PARSE_ERROR");
471
+ }
472
+ const scrapeTimeout = options?.scrapeTimeout ?? DEFAULT_SCRAPE_TIMEOUT_MS;
473
+ const onScrapeProgress = options?.onScrapeProgress;
474
+ const signal = options?.signal;
475
+ const token = options?.token;
476
+ const MIN_BACKOFF_MS = 500;
477
+ let backoff = Math.min(Math.max(MIN_BACKOFF_MS, estimatedWaitMs), MAX_BACKOFF_MS);
478
+ const startTime = Date.now();
479
+ while (true) {
480
+ const elapsed = Date.now() - startTime;
481
+ const remaining = scrapeTimeout - elapsed;
482
+ if (remaining <= 0) {
483
+ throw new ScrapeTimeoutError("Scrape timed out", jobId, elapsed);
484
+ }
485
+ await delay(Math.min(backoff, remaining), signal);
486
+ let pollResponse;
487
+ let retries = 0;
488
+ while (true) {
489
+ try {
490
+ pollResponse = await fetchFn(pollUrl);
491
+ break;
492
+ } catch (err) {
493
+ retries++;
494
+ if (retries >= MAX_POLL_RETRIES) {
495
+ throw err;
496
+ }
497
+ await delay(backoff, signal);
498
+ }
499
+ }
500
+ let event;
501
+ try {
502
+ event = await pollResponse.json();
503
+ } catch {
504
+ throw new LogoError("Failed to parse poll response", "SCRAPE_PARSE_ERROR");
505
+ }
506
+ if (onScrapeProgress) {
507
+ onScrapeProgress(event);
508
+ }
509
+ if (event.status === "complete") {
510
+ const finalUrl = token ? appendToken(originalUrl, token) : originalUrl;
511
+ return fetchFn(finalUrl);
512
+ }
513
+ if (event.status === "failed") {
514
+ throw new LogoError(
515
+ event.error ?? "Scrape failed",
516
+ "SCRAPE_FAILED"
517
+ );
518
+ }
519
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
520
+ }
521
+ }
522
+
523
+ // src/client/attribution.ts
524
+ var VALID_ATTRIBUTION_STATUSES = /* @__PURE__ */ new Set([
525
+ "verified",
526
+ "pending",
527
+ "unverified",
528
+ "failed",
529
+ "grace-period",
530
+ "error"
531
+ ]);
532
+ var ALWAYS_VALID_STATUSES = /* @__PURE__ */ new Set([
533
+ "verified",
534
+ "pending"
535
+ ]);
536
+ function safeParseDate(value) {
537
+ if (value === null) return void 0;
538
+ const date = new Date(value);
539
+ return Number.isNaN(date.getTime()) ? void 0 : date;
540
+ }
541
+ function computeIsValid(status, graceDeadline) {
542
+ if (ALWAYS_VALID_STATUSES.has(status)) return true;
543
+ if (status === "grace-period") {
544
+ return graceDeadline !== void 0 && graceDeadline.getTime() > Date.now();
545
+ }
546
+ return false;
547
+ }
548
+ function parseAttributionStatus(headers) {
549
+ const rawStatus = headers.get("X-Attribution-Status");
550
+ if (rawStatus === null) return null;
551
+ if (!VALID_ATTRIBUTION_STATUSES.has(rawStatus)) {
552
+ return { status: "error", isValid: false };
553
+ }
554
+ const status = rawStatus;
555
+ const graceDeadline = safeParseDate(headers.get("X-Attribution-Grace-Deadline"));
556
+ const verifiedAt = safeParseDate(headers.get("X-Attribution-Verified-At"));
557
+ const isValid = computeIsValid(status, graceDeadline);
558
+ return {
559
+ status,
560
+ isValid,
561
+ ...graceDeadline !== void 0 ? { graceDeadline } : {},
562
+ ...verifiedAt !== void 0 ? { verifiedAt } : {}
563
+ };
564
+ }
565
+
566
+ // src/client/index.ts
567
+ var QuikturnLogos = class {
568
+ constructor(options) {
569
+ const token = options.token?.trim() ?? "";
570
+ if (!token) {
571
+ throw new AuthenticationError("Token is required");
572
+ }
573
+ if (token.startsWith("sk_")) {
574
+ throw new AuthenticationError(
575
+ "Server keys (sk_) are not allowed in the browser client"
576
+ );
577
+ }
578
+ this.token = token;
579
+ this.baseUrl = options.baseUrl;
580
+ this.maxRetries = options.maxRetries ?? 2;
581
+ this.listeners = /* @__PURE__ */ new Map();
582
+ this.objectUrls = /* @__PURE__ */ new Set();
583
+ }
584
+ /**
585
+ * Fetches a logo for the given domain and returns a {@link BrowserLogoResponse}.
586
+ *
587
+ * The returned `url` is a `blob:` object URL suitable for `<img src>`.
588
+ * The client tracks these URLs and revokes them on {@link destroy}.
589
+ *
590
+ * @param domain - The domain to fetch a logo for (e.g. "github.com").
591
+ * @param options - Optional request configuration.
592
+ * @returns A BrowserLogoResponse containing the blob URL, raw Blob, content type, and metadata.
593
+ *
594
+ * @example
595
+ * ```ts
596
+ * const client = new QuikturnLogos({ token: "qt_abc123" });
597
+ * const { url, metadata } = await client.get("github.com", { size: 256 });
598
+ * document.querySelector("img")!.src = url;
599
+ * ```
600
+ */
601
+ async get(domain, options) {
602
+ const url = logoUrl(domain, {
603
+ token: this.token,
604
+ size: options?.size,
605
+ width: options?.width,
606
+ greyscale: options?.greyscale,
607
+ theme: options?.theme,
608
+ format: options?.format,
609
+ autoScrape: options?.autoScrape,
610
+ baseUrl: this.baseUrl
611
+ });
612
+ const format = options?.format;
613
+ const acceptHeader = format ? format.startsWith("image/") ? format : `image/${format}` : void 0;
614
+ let response = await browserFetch(url, {
615
+ maxRetries: this.maxRetries,
616
+ signal: options?.signal,
617
+ format: acceptHeader,
618
+ onRateLimitWarning: (remaining, limit) => this.emit("rateLimitWarning", remaining, limit),
619
+ onQuotaWarning: (remaining, limit) => this.emit("quotaWarning", remaining, limit)
620
+ });
621
+ if (options?.autoScrape) {
622
+ response = await handleScrapeResponse(response, url, browserFetch, {
623
+ scrapeTimeout: options?.scrapeTimeout,
624
+ onScrapeProgress: options?.onScrapeProgress,
625
+ signal: options?.signal,
626
+ token: this.token
627
+ });
628
+ }
629
+ const contentLength = response.headers.get("Content-Length");
630
+ if (contentLength) {
631
+ const size = parseInt(contentLength, 10);
632
+ if (!Number.isNaN(size) && size > MAX_RESPONSE_BODY_BYTES) {
633
+ throw new LogoError(
634
+ `Response body (${size} bytes) exceeds maximum allowed size`,
635
+ "UNEXPECTED_ERROR"
636
+ );
637
+ }
638
+ }
639
+ const blob = await response.blob();
640
+ const contentType = response.headers.get("Content-Type") || "image/png";
641
+ const metadata = parseLogoHeaders(response.headers);
642
+ const objectUrl = URL.createObjectURL(blob);
643
+ this.objectUrls.add(objectUrl);
644
+ return { url: objectUrl, blob, contentType, metadata };
645
+ }
646
+ /**
647
+ * Returns a plain URL string for the given domain without making a network request.
648
+ *
649
+ * Useful for `<img>` tags, CSS `background-image`, or preloading hints where
650
+ * a direct URL is needed rather than a blob.
651
+ *
652
+ * @param domain - The domain to build a URL for.
653
+ * @param options - Optional request parameters (size, format, etc.).
654
+ * @returns A fully-qualified Logos API URL string.
655
+ *
656
+ * @example
657
+ * ```ts
658
+ * const client = new QuikturnLogos({ token: "qt_abc123" });
659
+ * const url = client.getUrl("github.com", { size: 128, format: "webp" });
660
+ * // => "https://logos.getquikturn.io/github.com?token=qt_abc123&format=webp"
661
+ * ```
662
+ */
663
+ getUrl(domain, options) {
664
+ return logoUrl(domain, {
665
+ token: this.token,
666
+ size: options?.size,
667
+ width: options?.width,
668
+ greyscale: options?.greyscale,
669
+ theme: options?.theme,
670
+ format: options?.format,
671
+ baseUrl: this.baseUrl
672
+ });
673
+ }
674
+ /**
675
+ * Registers an event listener for a client event.
676
+ *
677
+ * @param event - The event name ("rateLimitWarning" or "quotaWarning").
678
+ * @param handler - Callback invoked with (remaining, limit) when the event fires.
679
+ */
680
+ on(event, handler) {
681
+ if (!this.listeners.has(event)) {
682
+ this.listeners.set(event, /* @__PURE__ */ new Set());
683
+ }
684
+ this.listeners.get(event).add(handler);
685
+ }
686
+ /**
687
+ * Removes a previously registered event listener.
688
+ *
689
+ * @param event - The event name.
690
+ * @param handler - The handler to remove.
691
+ */
692
+ off(event, handler) {
693
+ this.listeners.get(event)?.delete(handler);
694
+ }
695
+ /**
696
+ * Cleans up client resources.
697
+ *
698
+ * Revokes all tracked `blob:` object URLs to free memory, and removes
699
+ * all registered event listeners. The client instance can still be used
700
+ * after destroy, but previously created blob URLs will no longer work.
701
+ */
702
+ destroy() {
703
+ for (const url of this.objectUrls) {
704
+ URL.revokeObjectURL(url);
705
+ }
706
+ this.objectUrls.clear();
707
+ this.listeners.clear();
708
+ }
709
+ /**
710
+ * Emits an event to all registered listeners.
711
+ *
712
+ * @param event - The event name to emit.
713
+ * @param remaining - The remaining count.
714
+ * @param limit - The tier limit.
715
+ */
716
+ emit(event, remaining, limit) {
717
+ for (const handler of this.listeners.get(event) ?? []) {
718
+ handler(remaining, limit);
719
+ }
720
+ }
721
+ };
722
+
723
+ exports.QuikturnLogos = QuikturnLogos;
724
+ exports.browserFetch = browserFetch;
725
+ exports.handleScrapeResponse = handleScrapeResponse;
726
+ exports.parseAttributionStatus = parseAttributionStatus;