@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,858 @@
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/server/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 serverFetch(url, options) {
339
+ const { token } = options;
340
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
341
+ const signal = options.signal;
342
+ const format = options.format;
343
+ const onRateLimitWarning = options.onRateLimitWarning;
344
+ const onQuotaWarning = options.onQuotaWarning;
345
+ const requestHeaders = {
346
+ Authorization: `Bearer ${token}`
347
+ };
348
+ if (format) {
349
+ requestHeaders["Accept"] = format;
350
+ }
351
+ let retryCount = 0;
352
+ let serverErrorRetried = false;
353
+ while (true) {
354
+ let response;
355
+ try {
356
+ response = await globalThis.fetch(url, {
357
+ headers: requestHeaders,
358
+ signal
359
+ });
360
+ } catch (err) {
361
+ if (isAbortError(err)) {
362
+ throw new LogoError("Request aborted", "ABORT_ERROR");
363
+ }
364
+ const message = err instanceof Error ? err.message : "Unknown network error";
365
+ throw new LogoError(`Network error: ${message}`, "NETWORK_ERROR");
366
+ }
367
+ if (response.ok) {
368
+ emitWarnings(response.headers, onRateLimitWarning, onQuotaWarning);
369
+ return response;
370
+ }
371
+ if (response.status === 401) {
372
+ throw new AuthenticationError("Authentication failed");
373
+ }
374
+ if (response.status === 403) {
375
+ const body2 = await response.text();
376
+ const reason = body2.slice(0, 256) || "unknown";
377
+ throw new ForbiddenError("Access forbidden", reason);
378
+ }
379
+ if (response.status === 404) {
380
+ const domain = extractDomainFromUrl(url);
381
+ throw new NotFoundError("Logo not found", domain);
382
+ }
383
+ if (response.status === 429) {
384
+ const retryAfter = parseRetryAfter(response.headers) ?? DEFAULT_RETRY_AFTER_SECONDS;
385
+ const quotaLimitHeader = response.headers.get("X-Quota-Limit");
386
+ if (quotaLimitHeader !== null) {
387
+ const quotaLimit = parseInt(quotaLimitHeader, 10) || 0;
388
+ const quotaRemaining = parseInt(response.headers.get("X-Quota-Remaining") ?? "0", 10) || 0;
389
+ const used = quotaLimit - quotaRemaining;
390
+ throw new QuotaExceededError(
391
+ "Monthly quota exceeded",
392
+ retryAfter,
393
+ quotaLimit,
394
+ used
395
+ );
396
+ }
397
+ if (retryCount < maxRetries) {
398
+ retryCount++;
399
+ const retryDelay = Math.min(Math.max(1, retryAfter), MAX_RETRY_AFTER_SECONDS) * 1e3;
400
+ await delay(retryDelay);
401
+ continue;
402
+ }
403
+ const remaining = parseInt(
404
+ response.headers.get("X-RateLimit-Remaining") ?? "0",
405
+ 10
406
+ ) || 0;
407
+ const resetEpoch = parseInt(response.headers.get("X-RateLimit-Reset") ?? "0", 10) || 0;
408
+ throw new RateLimitError(
409
+ "Rate limit exceeded",
410
+ retryAfter,
411
+ remaining,
412
+ new Date(resetEpoch * 1e3)
413
+ );
414
+ }
415
+ if (response.status === 400) {
416
+ const body2 = await response.text();
417
+ throw new BadRequestError(body2.slice(0, 256) || "Bad request");
418
+ }
419
+ if (response.status === 500) {
420
+ if (!serverErrorRetried) {
421
+ serverErrorRetried = true;
422
+ await delay(SERVER_ERROR_RETRY_DELAY_MS);
423
+ continue;
424
+ }
425
+ const body2 = await response.text();
426
+ throw new LogoError(
427
+ body2.slice(0, 256) || "Internal server error",
428
+ "SERVER_ERROR",
429
+ 500
430
+ );
431
+ }
432
+ await response.text();
433
+ throw new LogoError(
434
+ `Unexpected response: ${response.status}`,
435
+ "UNEXPECTED_ERROR",
436
+ response.status
437
+ );
438
+ }
439
+ }
440
+
441
+ // src/server/batch.ts
442
+ var MAX_RATE_LIMIT_RETRIES = 3;
443
+ async function* getMany(domains, fetchFn, options) {
444
+ if (domains.length === 0) return;
445
+ const concurrency = options?.concurrency ?? 5;
446
+ const continueOnError = options?.continueOnError ?? true;
447
+ const signal = options?.signal;
448
+ const results = new Array(domains.length);
449
+ const settled = /* @__PURE__ */ new Set();
450
+ let nextYieldIndex = 0;
451
+ let notifyReady = null;
452
+ let workersFinished = false;
453
+ function waitForNext() {
454
+ if (settled.has(nextYieldIndex) || workersFinished) {
455
+ return Promise.resolve();
456
+ }
457
+ return new Promise((resolve) => {
458
+ notifyReady = resolve;
459
+ });
460
+ }
461
+ function wakeGenerator() {
462
+ if (notifyReady) {
463
+ const fn = notifyReady;
464
+ notifyReady = null;
465
+ fn();
466
+ }
467
+ }
468
+ function markSettled(index) {
469
+ settled.add(index);
470
+ if (index === nextYieldIndex) {
471
+ wakeGenerator();
472
+ }
473
+ }
474
+ async function processDomain(index, domain) {
475
+ let rateLimitRetries = 0;
476
+ while (true) {
477
+ try {
478
+ const { buffer, contentType, metadata } = await fetchFn(domain);
479
+ results[index] = {
480
+ domain,
481
+ success: true,
482
+ buffer,
483
+ contentType,
484
+ metadata
485
+ };
486
+ markSettled(index);
487
+ return;
488
+ } catch (err) {
489
+ if (err instanceof RateLimitError) {
490
+ rateLimitRetries++;
491
+ if (rateLimitRetries > MAX_RATE_LIMIT_RETRIES) {
492
+ if (continueOnError) {
493
+ results[index] = { domain, success: false, error: err };
494
+ markSettled(index);
495
+ return;
496
+ }
497
+ throw err;
498
+ }
499
+ const waitMs = Math.max(1, err.retryAfter) * 1e3;
500
+ await delay(waitMs, signal);
501
+ continue;
502
+ }
503
+ if (continueOnError) {
504
+ const logoError = err instanceof LogoError ? err : new LogoError(
505
+ err instanceof Error ? err.message : "Unknown error",
506
+ "UNEXPECTED_ERROR"
507
+ );
508
+ results[index] = {
509
+ domain,
510
+ success: false,
511
+ error: logoError
512
+ };
513
+ markSettled(index);
514
+ return;
515
+ }
516
+ throw err;
517
+ }
518
+ }
519
+ }
520
+ let queueIndex = 0;
521
+ async function worker() {
522
+ while (queueIndex < domains.length) {
523
+ if (signal?.aborted) return;
524
+ const idx = queueIndex++;
525
+ const domain = domains[idx];
526
+ await processDomain(idx, domain);
527
+ }
528
+ }
529
+ const workerCount = Math.min(concurrency, domains.length);
530
+ const workers = [];
531
+ for (let i = 0; i < workerCount; i++) {
532
+ workers.push(worker());
533
+ }
534
+ void Promise.allSettled(workers).then(() => {
535
+ workersFinished = true;
536
+ wakeGenerator();
537
+ });
538
+ while (nextYieldIndex < domains.length) {
539
+ await waitForNext();
540
+ if (!settled.has(nextYieldIndex)) {
541
+ break;
542
+ }
543
+ while (nextYieldIndex < domains.length && settled.has(nextYieldIndex)) {
544
+ yield results[nextYieldIndex];
545
+ nextYieldIndex++;
546
+ }
547
+ }
548
+ }
549
+
550
+ // src/client/scrape-poller.ts
551
+ var DEFAULT_SCRAPE_TIMEOUT_MS = 3e4;
552
+ var MAX_BACKOFF_MS = 5e3;
553
+ var MAX_POLL_RETRIES = 3;
554
+ function appendToken(url, token) {
555
+ const parsed = new URL(url);
556
+ parsed.searchParams.set("token", token);
557
+ return parsed.toString();
558
+ }
559
+ async function handleScrapeResponse(response, originalUrl, fetchFn, options) {
560
+ if (response.status !== 202) {
561
+ return response;
562
+ }
563
+ let body;
564
+ try {
565
+ body = await response.json();
566
+ } catch {
567
+ throw new LogoError("Failed to parse scrape response", "SCRAPE_PARSE_ERROR");
568
+ }
569
+ const { scrapeJob } = body;
570
+ const { jobId, pollUrl, estimatedWaitMs } = scrapeJob;
571
+ try {
572
+ const originalOrigin = new URL(originalUrl).origin;
573
+ const pollOrigin = new URL(pollUrl).origin;
574
+ if (pollOrigin !== originalOrigin) {
575
+ throw new LogoError(
576
+ "Poll URL origin does not match API origin",
577
+ "SCRAPE_PARSE_ERROR"
578
+ );
579
+ }
580
+ } catch (err) {
581
+ if (err instanceof LogoError) throw err;
582
+ throw new LogoError("Invalid poll URL", "SCRAPE_PARSE_ERROR");
583
+ }
584
+ const scrapeTimeout = options?.scrapeTimeout ?? DEFAULT_SCRAPE_TIMEOUT_MS;
585
+ const onScrapeProgress = options?.onScrapeProgress;
586
+ const signal = options?.signal;
587
+ const token = options?.token;
588
+ const MIN_BACKOFF_MS = 500;
589
+ let backoff = Math.min(Math.max(MIN_BACKOFF_MS, estimatedWaitMs), MAX_BACKOFF_MS);
590
+ const startTime = Date.now();
591
+ while (true) {
592
+ const elapsed = Date.now() - startTime;
593
+ const remaining = scrapeTimeout - elapsed;
594
+ if (remaining <= 0) {
595
+ throw new ScrapeTimeoutError("Scrape timed out", jobId, elapsed);
596
+ }
597
+ await delay(Math.min(backoff, remaining), signal);
598
+ let pollResponse;
599
+ let retries = 0;
600
+ while (true) {
601
+ try {
602
+ pollResponse = await fetchFn(pollUrl);
603
+ break;
604
+ } catch (err) {
605
+ retries++;
606
+ if (retries >= MAX_POLL_RETRIES) {
607
+ throw err;
608
+ }
609
+ await delay(backoff, signal);
610
+ }
611
+ }
612
+ let event;
613
+ try {
614
+ event = await pollResponse.json();
615
+ } catch {
616
+ throw new LogoError("Failed to parse poll response", "SCRAPE_PARSE_ERROR");
617
+ }
618
+ if (onScrapeProgress) {
619
+ onScrapeProgress(event);
620
+ }
621
+ if (event.status === "complete") {
622
+ const finalUrl = token ? appendToken(originalUrl, token) : originalUrl;
623
+ return fetchFn(finalUrl);
624
+ }
625
+ if (event.status === "failed") {
626
+ throw new LogoError(
627
+ event.error ?? "Scrape failed",
628
+ "SCRAPE_FAILED"
629
+ );
630
+ }
631
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
632
+ }
633
+ }
634
+
635
+ // src/server/index.ts
636
+ var QuikturnLogos = class {
637
+ constructor(options) {
638
+ const key = options.secretKey?.trim() ?? "";
639
+ if (!key) {
640
+ throw new AuthenticationError("Secret key is required");
641
+ }
642
+ if (!key.startsWith("sk_")) {
643
+ throw new AuthenticationError(
644
+ "Server client requires a secret key (sk_ prefix)"
645
+ );
646
+ }
647
+ this.secretKey = key;
648
+ this.baseUrl = options.baseUrl;
649
+ this.maxRetries = options.maxRetries ?? 2;
650
+ this.listeners = /* @__PURE__ */ new Map();
651
+ }
652
+ /**
653
+ * Fetches a logo for the given domain and returns a {@link ServerLogoResponse}.
654
+ *
655
+ * The returned `buffer` is a Node.js Buffer containing the raw image bytes.
656
+ *
657
+ * @param domain - The domain to fetch a logo for (e.g. "github.com").
658
+ * @param options - Optional request configuration.
659
+ * @returns A ServerLogoResponse containing the Buffer, content type, and metadata.
660
+ *
661
+ * @example
662
+ * ```ts
663
+ * const client = new QuikturnLogos({ secretKey: "sk_your_key" });
664
+ * const { buffer, contentType } = await client.get("github.com", { size: 256 });
665
+ * fs.writeFileSync("logo.png", buffer);
666
+ * ```
667
+ */
668
+ async get(domain, options) {
669
+ const url = logoUrl(domain, {
670
+ size: options?.size,
671
+ width: options?.width,
672
+ greyscale: options?.greyscale,
673
+ theme: options?.theme,
674
+ format: options?.format,
675
+ autoScrape: options?.autoScrape,
676
+ baseUrl: this.baseUrl
677
+ });
678
+ const format = options?.format;
679
+ const acceptHeader = format ? format.startsWith("image/") ? format : `image/${format}` : void 0;
680
+ let response = await serverFetch(url, {
681
+ token: this.secretKey,
682
+ maxRetries: this.maxRetries,
683
+ signal: options?.signal,
684
+ format: acceptHeader,
685
+ onRateLimitWarning: (remaining, limit) => this.emit("rateLimitWarning", remaining, limit),
686
+ onQuotaWarning: (remaining, limit) => this.emit("quotaWarning", remaining, limit)
687
+ });
688
+ if (options?.autoScrape) {
689
+ const fetchForPoller = (fetchUrl) => serverFetch(fetchUrl, {
690
+ token: this.secretKey,
691
+ maxRetries: this.maxRetries,
692
+ signal: options?.signal
693
+ });
694
+ response = await handleScrapeResponse(
695
+ response,
696
+ url,
697
+ fetchForPoller,
698
+ {
699
+ scrapeTimeout: options?.scrapeTimeout,
700
+ onScrapeProgress: options?.onScrapeProgress,
701
+ signal: options?.signal
702
+ }
703
+ );
704
+ }
705
+ const contentLength = response.headers.get("Content-Length");
706
+ if (contentLength) {
707
+ const size = parseInt(contentLength, 10);
708
+ if (!Number.isNaN(size) && size > MAX_RESPONSE_BODY_BYTES) {
709
+ throw new LogoError(
710
+ `Response body (${size} bytes) exceeds maximum allowed size`,
711
+ "UNEXPECTED_ERROR"
712
+ );
713
+ }
714
+ }
715
+ const arrayBuffer = await response.arrayBuffer();
716
+ const buffer = Buffer.from(arrayBuffer);
717
+ const contentType = response.headers.get("Content-Type") || "image/png";
718
+ const metadata = parseLogoHeaders(response.headers);
719
+ return { buffer, contentType, metadata };
720
+ }
721
+ /**
722
+ * Fetches logos for multiple domains with concurrency control.
723
+ *
724
+ * Yields {@link BatchResult} items in the same order as the input domains.
725
+ * Delegates to the batch module's `getMany` with a fetch function that
726
+ * calls `this.get()` internally.
727
+ *
728
+ * @param domains - Array of domain strings to fetch logos for.
729
+ * @param options - Optional batch configuration (concurrency, format, etc.).
730
+ * @yields BatchResult items for each domain.
731
+ *
732
+ * @example
733
+ * ```ts
734
+ * const client = new QuikturnLogos({ secretKey: "sk_your_key" });
735
+ * for await (const result of client.getMany(["github.com", "gitlab.com"])) {
736
+ * if (result.success) {
737
+ * fs.writeFileSync(`${result.domain}.png`, result.buffer!);
738
+ * }
739
+ * }
740
+ * ```
741
+ */
742
+ async *getMany(domains, options) {
743
+ const fetchForBatch = async (domain) => {
744
+ const result = await this.get(domain, {
745
+ size: options?.size,
746
+ greyscale: options?.greyscale,
747
+ theme: options?.theme,
748
+ format: options?.format,
749
+ signal: options?.signal
750
+ });
751
+ return result;
752
+ };
753
+ yield* getMany(domains, fetchForBatch, {
754
+ concurrency: options?.concurrency,
755
+ signal: options?.signal,
756
+ continueOnError: options?.continueOnError
757
+ });
758
+ }
759
+ /**
760
+ * Fetches a logo and returns the raw Response body as a ReadableStream.
761
+ *
762
+ * Useful for piping to a file or HTTP response without buffering the
763
+ * entire image in memory.
764
+ *
765
+ * @param domain - The domain to fetch a logo for.
766
+ * @param options - Optional request configuration.
767
+ * @returns A ReadableStream of the response body.
768
+ * @throws {Error} If the response body is null (streaming not available).
769
+ *
770
+ * @example
771
+ * ```ts
772
+ * const client = new QuikturnLogos({ secretKey: "sk_your_key" });
773
+ * const stream = await client.getStream("github.com");
774
+ * const writable = fs.createWriteStream("logo.png");
775
+ * Readable.fromWeb(stream).pipe(writable);
776
+ * ```
777
+ */
778
+ async getStream(domain, options) {
779
+ const url = logoUrl(domain, {
780
+ size: options?.size,
781
+ width: options?.width,
782
+ greyscale: options?.greyscale,
783
+ theme: options?.theme,
784
+ format: options?.format,
785
+ autoScrape: options?.autoScrape,
786
+ baseUrl: this.baseUrl
787
+ });
788
+ const format = options?.format;
789
+ const acceptHeader = format ? format.startsWith("image/") ? format : `image/${format}` : void 0;
790
+ const response = await serverFetch(url, {
791
+ token: this.secretKey,
792
+ maxRetries: this.maxRetries,
793
+ signal: options?.signal,
794
+ format: acceptHeader
795
+ });
796
+ if (!response.body) {
797
+ throw new Error("Response body is null \u2014 streaming not available");
798
+ }
799
+ return response.body;
800
+ }
801
+ /**
802
+ * Returns a plain URL string for the given domain without making a network request.
803
+ *
804
+ * The URL does not include authentication — use Authorization: Bearer header
805
+ * when fetching. Secret keys (sk_) must never appear in URLs.
806
+ *
807
+ * @param domain - The domain to build a URL for.
808
+ * @param options - Optional request parameters (size, format, etc.).
809
+ * @returns A fully-qualified Logos API URL string (without token).
810
+ */
811
+ getUrl(domain, options) {
812
+ return logoUrl(domain, {
813
+ size: options?.size,
814
+ width: options?.width,
815
+ greyscale: options?.greyscale,
816
+ theme: options?.theme,
817
+ format: options?.format,
818
+ baseUrl: this.baseUrl
819
+ });
820
+ }
821
+ /**
822
+ * Registers an event listener for a client event.
823
+ *
824
+ * @param event - The event name ("rateLimitWarning" or "quotaWarning").
825
+ * @param handler - Callback invoked with (remaining, limit) when the event fires.
826
+ */
827
+ on(event, handler) {
828
+ if (!this.listeners.has(event)) {
829
+ this.listeners.set(event, /* @__PURE__ */ new Set());
830
+ }
831
+ this.listeners.get(event).add(handler);
832
+ }
833
+ /**
834
+ * Removes a previously registered event listener.
835
+ *
836
+ * @param event - The event name.
837
+ * @param handler - The handler to remove.
838
+ */
839
+ off(event, handler) {
840
+ this.listeners.get(event)?.delete(handler);
841
+ }
842
+ /**
843
+ * Emits an event to all registered listeners.
844
+ *
845
+ * @param event - The event name to emit.
846
+ * @param remaining - The remaining count.
847
+ * @param limit - The tier limit.
848
+ */
849
+ emit(event, remaining, limit) {
850
+ for (const handler of this.listeners.get(event) ?? []) {
851
+ handler(remaining, limit);
852
+ }
853
+ }
854
+ };
855
+
856
+ exports.QuikturnLogos = QuikturnLogos;
857
+ exports.getMany = getMany;
858
+ exports.serverFetch = serverFetch;