@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.
- package/LICENSE +21 -0
- package/README.md +498 -0
- package/dist/client/index.cjs +726 -0
- package/dist/client/index.d.cts +399 -0
- package/dist/client/index.d.ts +399 -0
- package/dist/client/index.mjs +721 -0
- package/dist/index.cjs +319 -0
- package/dist/index.d.cts +437 -0
- package/dist/index.d.ts +437 -0
- package/dist/index.mjs +294 -0
- package/dist/server/index.cjs +858 -0
- package/dist/server/index.d.cts +423 -0
- package/dist/server/index.d.ts +423 -0
- package/dist/server/index.mjs +854 -0
- package/package.json +89 -0
|
@@ -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;
|