@tsproxy/api 0.0.1

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,1208 @@
1
+ // src/index.ts
2
+ import { Hono as Hono5 } from "hono";
3
+ import { cors } from "hono/cors";
4
+ import { logger } from "hono/logger";
5
+
6
+ // src/config.ts
7
+ import { readFileSync } from "fs";
8
+ import { resolve } from "path";
9
+ function loadCollectionsConfig() {
10
+ const envConfig = process.env["COLLECTIONS_CONFIG"];
11
+ if (envConfig) {
12
+ try {
13
+ return JSON.parse(envConfig);
14
+ } catch {
15
+ console.warn("Failed to parse COLLECTIONS_CONFIG env var, using empty config");
16
+ }
17
+ }
18
+ try {
19
+ const filePath = resolve(process.cwd(), "collections.json");
20
+ const content = readFileSync(filePath, "utf-8");
21
+ return JSON.parse(content);
22
+ } catch {
23
+ }
24
+ return { collections: {} };
25
+ }
26
+ function requiredEnv(name, fallback) {
27
+ const value = process.env[name] ?? fallback;
28
+ if (!value) {
29
+ throw new Error(`Missing required environment variable: ${name}`);
30
+ }
31
+ return value;
32
+ }
33
+ function optionalEnv(name, fallback) {
34
+ return process.env[name] ?? fallback;
35
+ }
36
+ function numEnv(name, fallback) {
37
+ const value = process.env[name];
38
+ if (!value) return fallback;
39
+ const parsed = parseInt(value, 10);
40
+ return isNaN(parsed) ? fallback : parsed;
41
+ }
42
+ function loadConfig() {
43
+ return {
44
+ typesense: {
45
+ host: optionalEnv("TYPESENSE_HOST", "localhost"),
46
+ port: numEnv("TYPESENSE_PORT", 8108),
47
+ protocol: optionalEnv("TYPESENSE_PROTOCOL", "http"),
48
+ apiKey: requiredEnv("TYPESENSE_API_KEY")
49
+ },
50
+ proxy: {
51
+ port: numEnv("PROXY_PORT", 3e3),
52
+ apiKey: requiredEnv("PROXY_API_KEY", "")
53
+ },
54
+ cache: {
55
+ ttl: numEnv("CACHE_TTL", 60),
56
+ maxSize: numEnv("CACHE_MAX_SIZE", 1e3)
57
+ },
58
+ queue: {
59
+ concurrency: numEnv("QUEUE_CONCURRENCY", 5),
60
+ maxSize: numEnv("QUEUE_MAX_SIZE", 1e4),
61
+ ...process.env["REDIS_HOST"] ? {
62
+ redis: {
63
+ host: optionalEnv("REDIS_HOST", "localhost"),
64
+ port: numEnv("REDIS_PORT", 6379)
65
+ }
66
+ } : {}
67
+ },
68
+ rateLimit: {
69
+ search: numEnv("RATE_LIMIT_SEARCH", 100),
70
+ ingest: numEnv("RATE_LIMIT_INGEST", 30)
71
+ },
72
+ collections: loadCollectionsConfig()
73
+ };
74
+ }
75
+ function resolveCollection(config, collection, locale) {
76
+ const collectionConfig = config.collections[collection];
77
+ if (!collectionConfig?.locales || !locale) {
78
+ return collection;
79
+ }
80
+ return collectionConfig.locales[locale] ?? collection;
81
+ }
82
+
83
+ // src/middleware/error-handler.ts
84
+ function errorHandler(err, c) {
85
+ console.error(`[Error] ${err.message}`, err.stack);
86
+ const status = err.status ?? err.statusCode ?? 500;
87
+ return c.json(
88
+ {
89
+ error: status === 500 ? "Internal Server Error" : err.message,
90
+ ...process.env["NODE_ENV"] === "development" && { stack: err.stack }
91
+ },
92
+ status
93
+ );
94
+ }
95
+
96
+ // src/routes/search.ts
97
+ import { Hono } from "hono";
98
+
99
+ // src/lib/cache.ts
100
+ var LRUCache = class {
101
+ cache;
102
+ maxSize;
103
+ ttl;
104
+ hits = 0;
105
+ misses = 0;
106
+ constructor(options) {
107
+ this.maxSize = options.maxSize;
108
+ this.ttl = options.ttl * 1e3;
109
+ this.cache = /* @__PURE__ */ new Map();
110
+ }
111
+ get(key) {
112
+ const entry = this.cache.get(key);
113
+ if (!entry) {
114
+ this.misses++;
115
+ return void 0;
116
+ }
117
+ if (Date.now() > entry.expiresAt) {
118
+ this.cache.delete(key);
119
+ this.misses++;
120
+ return void 0;
121
+ }
122
+ this.cache.delete(key);
123
+ this.cache.set(key, entry);
124
+ this.hits++;
125
+ return entry.value;
126
+ }
127
+ set(key, value) {
128
+ this.cache.delete(key);
129
+ while (this.cache.size >= this.maxSize) {
130
+ const firstKey = this.cache.keys().next().value;
131
+ if (firstKey !== void 0) {
132
+ this.cache.delete(firstKey);
133
+ }
134
+ }
135
+ this.cache.set(key, {
136
+ value,
137
+ expiresAt: Date.now() + this.ttl
138
+ });
139
+ }
140
+ invalidate(key) {
141
+ if (key) {
142
+ this.cache.delete(key);
143
+ } else {
144
+ this.cache.clear();
145
+ }
146
+ }
147
+ stats() {
148
+ const now = Date.now();
149
+ for (const [key, entry] of this.cache) {
150
+ if (now > entry.expiresAt) {
151
+ this.cache.delete(key);
152
+ }
153
+ }
154
+ const total = this.hits + this.misses;
155
+ return {
156
+ size: this.cache.size,
157
+ maxSize: this.maxSize,
158
+ ttl: this.ttl / 1e3,
159
+ hits: this.hits,
160
+ misses: this.misses,
161
+ hitRate: total === 0 ? 0 : this.hits / total
162
+ };
163
+ }
164
+ };
165
+
166
+ // src/lib/transform.ts
167
+ function parseAlgoliaParams(params) {
168
+ if (!params) return {};
169
+ if (typeof params === "object") {
170
+ const result2 = {};
171
+ for (const [key, value] of Object.entries(params)) {
172
+ if (value === void 0 || value === null) continue;
173
+ result2[key] = typeof value === "string" ? value : JSON.stringify(value);
174
+ }
175
+ return result2;
176
+ }
177
+ const result = {};
178
+ const searchParams = new URLSearchParams(params);
179
+ for (const [key, value] of searchParams.entries()) {
180
+ result[key] = value;
181
+ }
182
+ return result;
183
+ }
184
+ function transformAlgoliaFilters(facetFilters, numericFilters, filters) {
185
+ const parts = [];
186
+ if (filters) {
187
+ parts.push(filters);
188
+ }
189
+ if (facetFilters) {
190
+ try {
191
+ const parsed = JSON.parse(facetFilters);
192
+ const conditions = [];
193
+ for (const filter of parsed) {
194
+ if (Array.isArray(filter)) {
195
+ const orParts = filter.map(parseOneFacetFilter).filter(Boolean);
196
+ if (orParts.length > 0) {
197
+ conditions.push(`(${orParts.join(" || ")})`);
198
+ }
199
+ } else {
200
+ const part = parseOneFacetFilter(filter);
201
+ if (part) conditions.push(part);
202
+ }
203
+ }
204
+ if (conditions.length > 0) {
205
+ parts.push(conditions.join(" && "));
206
+ }
207
+ } catch {
208
+ }
209
+ }
210
+ if (numericFilters) {
211
+ try {
212
+ const parsed = JSON.parse(numericFilters);
213
+ const conditions = parsed.map((f) => {
214
+ const match = f.match(/^(\w+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
215
+ if (match) {
216
+ const [, field, op, val] = match;
217
+ return `${field}:${op}${val}`;
218
+ }
219
+ return null;
220
+ }).filter(Boolean);
221
+ if (conditions.length > 0) {
222
+ parts.push(conditions.join(" && "));
223
+ }
224
+ } catch {
225
+ }
226
+ }
227
+ return parts.length > 0 ? parts.join(" && ") : void 0;
228
+ }
229
+ function parseOneFacetFilter(filter) {
230
+ const negated = filter.startsWith("-");
231
+ const clean = negated ? filter.slice(1) : filter;
232
+ const colonIdx = clean.indexOf(":");
233
+ if (colonIdx === -1) return null;
234
+ const field = clean.slice(0, colonIdx);
235
+ const value = clean.slice(colonIdx + 1);
236
+ if (negated) {
237
+ return `${field}:!=${value}`;
238
+ }
239
+ return `${field}:=${value}`;
240
+ }
241
+ function transformAlgoliaRequestToTypesense(request, resolvedCollection) {
242
+ const params = parseAlgoliaParams(request.params);
243
+ const query = params["query"] ?? "";
244
+ const hitsPerPage = parseInt(params["hitsPerPage"] ?? "20", 10);
245
+ const page = parseInt(params["page"] ?? "0", 10);
246
+ const result = {
247
+ collection: resolvedCollection,
248
+ q: query || "*",
249
+ per_page: hitsPerPage,
250
+ page: page + 1,
251
+ // Algolia is 0-based, Typesense is 1-based
252
+ highlight_start_tag: "<mark>",
253
+ highlight_end_tag: "</mark>"
254
+ };
255
+ if (params["facets"]) {
256
+ try {
257
+ const facets = JSON.parse(params["facets"]);
258
+ if (facets.length > 0) {
259
+ result.facet_by = facets.join(",");
260
+ }
261
+ } catch {
262
+ result.facet_by = params["facets"];
263
+ }
264
+ }
265
+ if (params["maxValuesPerFacet"]) {
266
+ result.max_facet_values = parseInt(params["maxValuesPerFacet"], 10);
267
+ }
268
+ const filterBy = transformAlgoliaFilters(
269
+ params["facetFilters"],
270
+ params["numericFilters"],
271
+ params["filters"]
272
+ );
273
+ if (filterBy) {
274
+ result.filter_by = filterBy;
275
+ }
276
+ if (params["restrictSearchableAttributes"]) {
277
+ try {
278
+ const attrs = JSON.parse(params["restrictSearchableAttributes"]);
279
+ result.query_by = attrs.join(",");
280
+ } catch {
281
+ result.query_by = params["restrictSearchableAttributes"];
282
+ }
283
+ }
284
+ if (params["sortBy"]) {
285
+ result.sort_by = params["sortBy"];
286
+ }
287
+ return result;
288
+ }
289
+ function transformHighlights(highlights, document) {
290
+ const result = {};
291
+ for (const [key, value] of Object.entries(document)) {
292
+ if (key === "id") continue;
293
+ result[key] = {
294
+ value: String(value ?? ""),
295
+ matchLevel: "none",
296
+ matchedWords: []
297
+ };
298
+ }
299
+ if (highlights) {
300
+ for (const hl of highlights) {
301
+ const snippetValue = hl.snippet ?? hl.value ?? "";
302
+ const matchedTokens = hl.matched_tokens ?? [];
303
+ if (hl.snippets && hl.snippets.length > 0) {
304
+ const firstSnippet = hl.snippets[0];
305
+ if (firstSnippet) {
306
+ result[hl.field] = {
307
+ value: firstSnippet.value,
308
+ matchLevel: (firstSnippet.matched_tokens?.length ?? 0) > 0 ? "full" : "none",
309
+ matchedWords: firstSnippet.matched_tokens ?? []
310
+ };
311
+ continue;
312
+ }
313
+ }
314
+ result[hl.field] = {
315
+ value: snippetValue,
316
+ matchLevel: matchedTokens.length > 0 ? "full" : "none",
317
+ matchedWords: matchedTokens
318
+ };
319
+ }
320
+ }
321
+ return result;
322
+ }
323
+ function transformHit(hit) {
324
+ const document = hit.document;
325
+ const id = document["id"];
326
+ return {
327
+ ...document,
328
+ objectID: String(id ?? ""),
329
+ _highlightResult: transformHighlights(hit.highlights, document)
330
+ };
331
+ }
332
+ function transformFacets(facetCounts) {
333
+ const facets = {};
334
+ const facetsStats = {};
335
+ if (!facetCounts) {
336
+ return { facets, facets_stats: facetsStats };
337
+ }
338
+ for (const facet of facetCounts) {
339
+ const values = {};
340
+ for (const count of facet.counts) {
341
+ values[count.value] = count.count;
342
+ }
343
+ facets[facet.field_name] = values;
344
+ if (facet.stats) {
345
+ facetsStats[facet.field_name] = facet.stats;
346
+ }
347
+ }
348
+ return { facets, facets_stats: facetsStats };
349
+ }
350
+ function transformTypesenseResponseToAlgolia(tsResponse, originalRequest) {
351
+ const params = parseAlgoliaParams(originalRequest.params);
352
+ const hitsPerPage = parseInt(params["hitsPerPage"] ?? "20", 10);
353
+ const page = parseInt(params["page"] ?? "0", 10);
354
+ const query = params["query"] ?? "";
355
+ const hits = (tsResponse.hits ?? []).map(transformHit);
356
+ const { facets, facets_stats } = transformFacets(tsResponse.facet_counts);
357
+ const nbHits = tsResponse.found ?? 0;
358
+ const nbPages = nbHits > 0 ? Math.ceil(nbHits / hitsPerPage) : 0;
359
+ const paramsString = typeof originalRequest.params === "string" ? originalRequest.params : new URLSearchParams(Object.entries(params)).toString();
360
+ return {
361
+ hits,
362
+ nbHits,
363
+ page,
364
+ nbPages,
365
+ hitsPerPage,
366
+ processingTimeMS: tsResponse.search_time_ms,
367
+ query,
368
+ params: paramsString,
369
+ facets,
370
+ facets_stats,
371
+ exhaustiveNbHits: true
372
+ };
373
+ }
374
+ function transformMultiSearchResponse(tsResponse, originalRequests) {
375
+ return {
376
+ results: tsResponse.results.map((result, i) => {
377
+ const originalRequest = originalRequests[i];
378
+ if (!originalRequest) {
379
+ return transformTypesenseResponseToAlgolia(result, { indexName: "", params: "" });
380
+ }
381
+ return transformTypesenseResponseToAlgolia(result, originalRequest);
382
+ })
383
+ };
384
+ }
385
+
386
+ // src/lib/typesense.ts
387
+ import { Client } from "typesense";
388
+ var client = null;
389
+ function getTypesenseClient(config) {
390
+ if (!client) {
391
+ client = new Client({
392
+ nodes: [
393
+ {
394
+ host: config.typesense.host,
395
+ port: config.typesense.port,
396
+ protocol: config.typesense.protocol
397
+ }
398
+ ],
399
+ apiKey: config.typesense.apiKey,
400
+ connectionTimeoutSeconds: 5
401
+ });
402
+ }
403
+ return client;
404
+ }
405
+
406
+ // src/proxy-config.ts
407
+ function defineConfig(config) {
408
+ return config;
409
+ }
410
+ function proxyConfigToConfig(proxyConfig) {
411
+ const collectionsConfig = { collections: {} };
412
+ if (proxyConfig.collections) {
413
+ for (const [name, def] of Object.entries(proxyConfig.collections)) {
414
+ if (def.locales && def.locales.length > 0) {
415
+ const localeMap = {};
416
+ for (const locale of def.locales) {
417
+ localeMap[locale] = `${name}_${locale}`;
418
+ }
419
+ collectionsConfig.collections[name] = { locales: localeMap };
420
+ } else {
421
+ collectionsConfig.collections[name] = {};
422
+ }
423
+ }
424
+ }
425
+ return {
426
+ typesense: {
427
+ host: proxyConfig.typesense?.host || process.env["TYPESENSE_HOST"] || "localhost",
428
+ port: proxyConfig.typesense?.port || parseInt(process.env["TYPESENSE_PORT"] || "8108", 10),
429
+ protocol: proxyConfig.typesense?.protocol || process.env["TYPESENSE_PROTOCOL"] || "http",
430
+ apiKey: proxyConfig.typesense?.apiKey || process.env["TYPESENSE_API_KEY"] || ""
431
+ },
432
+ proxy: {
433
+ port: proxyConfig.server?.port || parseInt(process.env["PROXY_PORT"] || "3000", 10),
434
+ apiKey: proxyConfig.server?.apiKey || process.env["PROXY_API_KEY"] || ""
435
+ },
436
+ cache: {
437
+ ttl: proxyConfig.cache?.ttl ?? parseInt(process.env["CACHE_TTL"] || "60", 10),
438
+ maxSize: proxyConfig.cache?.maxSize ?? parseInt(process.env["CACHE_MAX_SIZE"] || "1000", 10)
439
+ },
440
+ queue: {
441
+ concurrency: proxyConfig.queue?.concurrency ?? parseInt(process.env["QUEUE_CONCURRENCY"] || "5", 10),
442
+ maxSize: proxyConfig.queue?.maxSize ?? parseInt(process.env["QUEUE_MAX_SIZE"] || "10000", 10),
443
+ ...proxyConfig.queue?.redis || process.env["REDIS_HOST"] ? {
444
+ redis: {
445
+ host: proxyConfig.queue?.redis?.host || process.env["REDIS_HOST"] || "localhost",
446
+ port: proxyConfig.queue?.redis?.port || parseInt(process.env["REDIS_PORT"] || "6379", 10)
447
+ }
448
+ } : {}
449
+ },
450
+ rateLimit: {
451
+ search: proxyConfig.rateLimit?.search ?? parseInt(process.env["RATE_LIMIT_SEARCH"] || "100", 10),
452
+ ingest: proxyConfig.rateLimit?.ingest ?? parseInt(process.env["RATE_LIMIT_INGEST"] || "30", 10)
453
+ },
454
+ collections: collectionsConfig
455
+ };
456
+ }
457
+ function getSearchableFields(def) {
458
+ return Object.entries(def.fields).filter(([_, field]) => field.searchable).map(([name]) => name);
459
+ }
460
+ function getFacetFields(def) {
461
+ return Object.entries(def.fields).filter(([_, field]) => field.facet).map(([name]) => name);
462
+ }
463
+ function getSortableFields(def) {
464
+ return Object.entries(def.fields).filter(([_, field]) => field.sortable).map(([name]) => name);
465
+ }
466
+ function toTypesenseSchema(name, def) {
467
+ return {
468
+ name,
469
+ fields: Object.entries(def.fields).map(([fieldName, field]) => {
470
+ const f = {
471
+ name: fieldName,
472
+ type: field.type
473
+ };
474
+ if (field.facet) f.facet = true;
475
+ if (field.optional) f.optional = true;
476
+ if (field.sortable && !["int32", "int64", "float", "bool"].includes(field.type)) {
477
+ f.sort = true;
478
+ }
479
+ if (field.infix) f.infix = true;
480
+ if (field.locale) f.locale = field.locale;
481
+ return f;
482
+ }),
483
+ ...def.defaultSortBy ? { default_sorting_field: def.defaultSortBy } : {},
484
+ ...def.tokenSeparators ? { token_separators: def.tokenSeparators } : {},
485
+ ...def.symbolsToIndex ? { symbols_to_index: def.symbolsToIndex } : {},
486
+ ...def.enableNestedFields ? { enable_nested_fields: true } : {}
487
+ };
488
+ }
489
+ function getComputedFields(def) {
490
+ return Object.entries(def.fields).filter(([_, field]) => field.compute !== void 0).map(([name, field]) => ({ name, compute: field.compute }));
491
+ }
492
+ function applyComputedFields(doc, collectionDef, locale) {
493
+ const computedFields = getComputedFields(collectionDef);
494
+ if (computedFields.length === 0) return doc;
495
+ const result = { ...doc };
496
+ for (const { name, compute } of computedFields) {
497
+ try {
498
+ result[name] = compute(result, locale);
499
+ } catch (error) {
500
+ console.warn(`Computed field '${name}' failed:`, error);
501
+ }
502
+ }
503
+ return result;
504
+ }
505
+ function applyComputedFieldsBatch(docs, collectionDef, locale) {
506
+ const computedFields = getComputedFields(collectionDef);
507
+ if (computedFields.length === 0) return docs;
508
+ return docs.map((doc) => applyComputedFields(doc, collectionDef, locale));
509
+ }
510
+
511
+ // src/routes/search.ts
512
+ function createSearchRoutes(config, collectionDefs) {
513
+ const app = new Hono();
514
+ const cache = new LRUCache({ maxSize: config.cache.maxSize, ttl: config.cache.ttl });
515
+ const typesense = getTypesenseClient(config);
516
+ const rateLimitMap = /* @__PURE__ */ new Map();
517
+ const rateLimit = config.rateLimit.search;
518
+ function checkRateLimit(ip) {
519
+ const now = Date.now();
520
+ const entry = rateLimitMap.get(ip);
521
+ if (!entry || now > entry.resetAt) {
522
+ rateLimitMap.set(ip, { count: 1, resetAt: now + 6e4 });
523
+ return true;
524
+ }
525
+ if (entry.count >= rateLimit) {
526
+ return false;
527
+ }
528
+ entry.count++;
529
+ return true;
530
+ }
531
+ app.post("/api/search", async (c) => {
532
+ const ip = c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "unknown";
533
+ if (!checkRateLimit(ip)) {
534
+ return c.json({ error: "Rate limit exceeded" }, 429);
535
+ }
536
+ const body = await c.req.json();
537
+ if (!body.requests || !Array.isArray(body.requests)) {
538
+ return c.json({ error: "Invalid request: 'requests' array is required" }, 400);
539
+ }
540
+ const locale = c.req.header("X-Locale") ?? c.req.query("locale") ?? void 0;
541
+ const cacheKey = JSON.stringify({ requests: body.requests, locale });
542
+ const cached = cache.get(cacheKey);
543
+ if (cached) {
544
+ return c.json(cached);
545
+ }
546
+ const searches = body.requests.map((req) => {
547
+ const resolvedCollection = resolveCollection(config.collections, req.indexName, locale);
548
+ const tsParams = transformAlgoliaRequestToTypesense(req, resolvedCollection);
549
+ if (!tsParams.query_by && collectionDefs) {
550
+ const def = collectionDefs[req.indexName];
551
+ if (def) {
552
+ const searchableFields = getSearchableFields(def);
553
+ if (searchableFields.length > 0) {
554
+ tsParams.query_by = searchableFields.join(",");
555
+ }
556
+ }
557
+ }
558
+ return tsParams;
559
+ });
560
+ let tsResponse;
561
+ try {
562
+ tsResponse = await typesense.multiSearch.perform(
563
+ { searches },
564
+ {}
565
+ );
566
+ } catch (err) {
567
+ console.error("[search] Typesense request failed:", err.message);
568
+ return c.json({ error: "Search backend unavailable" }, 502);
569
+ }
570
+ const errors = [];
571
+ for (const result of tsResponse.results) {
572
+ if ("error" in result && result.error) {
573
+ errors.push(String(result.error));
574
+ }
575
+ }
576
+ const algoliaResponse = transformMultiSearchResponse(tsResponse, body.requests);
577
+ if (errors.length > 0) {
578
+ algoliaResponse._errors = errors;
579
+ }
580
+ if (errors.length === 0) {
581
+ cache.set(cacheKey, algoliaResponse);
582
+ }
583
+ return c.json(algoliaResponse);
584
+ });
585
+ return { app, cache };
586
+ }
587
+
588
+ // src/routes/ingest.ts
589
+ import { Hono as Hono2 } from "hono";
590
+
591
+ // src/lib/queue.ts
592
+ import { Queue, Worker } from "bullmq";
593
+ var IngestionQueue = class {
594
+ concurrency;
595
+ maxSize;
596
+ backend = "memory";
597
+ // BullMQ (Redis) state
598
+ bullQueue = null;
599
+ bullWorker = null;
600
+ jobHandlers = /* @__PURE__ */ new Map();
601
+ // In-memory fallback state
602
+ memPending = [];
603
+ memActiveCount = 0;
604
+ memCompletedCount = 0;
605
+ memFailedCount = 0;
606
+ constructor(options) {
607
+ this.concurrency = options.concurrency;
608
+ this.maxSize = options.maxSize;
609
+ if (options.redis) {
610
+ this.initRedis(options.redis);
611
+ }
612
+ }
613
+ initRedis(redis) {
614
+ const connection = {
615
+ host: redis.host || "localhost",
616
+ port: redis.port || 6379
617
+ };
618
+ try {
619
+ this.bullQueue = new Queue("tsproxy:ingest", { connection });
620
+ this.bullWorker = new Worker(
621
+ "tsproxy:ingest",
622
+ async (job) => {
623
+ const handler = this.jobHandlers.get(job.id);
624
+ if (!handler) {
625
+ throw new Error(`No handler for job ${job.id}`);
626
+ }
627
+ try {
628
+ const result = await handler.fn();
629
+ handler.resolve(result);
630
+ return result;
631
+ } catch (err) {
632
+ handler.reject(err);
633
+ throw err;
634
+ } finally {
635
+ this.jobHandlers.delete(job.id);
636
+ }
637
+ },
638
+ { connection, concurrency: this.concurrency }
639
+ );
640
+ this.bullWorker.on("error", (err) => {
641
+ console.warn("[queue] BullMQ worker error, falling back to memory:", err.message);
642
+ this.fallbackToMemory();
643
+ });
644
+ this.bullQueue.on("error", (err) => {
645
+ console.warn("[queue] BullMQ queue error, falling back to memory:", err.message);
646
+ this.fallbackToMemory();
647
+ });
648
+ this.backend = "redis";
649
+ console.log(`[queue] Using Redis at ${connection.host}:${connection.port}`);
650
+ } catch (err) {
651
+ console.warn("[queue] Failed to connect to Redis, using in-memory queue:", err.message);
652
+ this.backend = "memory";
653
+ }
654
+ }
655
+ fallbackToMemory() {
656
+ if (this.backend === "memory") return;
657
+ this.backend = "memory";
658
+ for (const [, handler] of this.jobHandlers) {
659
+ handler.reject(new Error("Queue backend switched to memory"));
660
+ }
661
+ this.jobHandlers.clear();
662
+ this.bullWorker?.close().catch(() => {
663
+ });
664
+ this.bullQueue?.close().catch(() => {
665
+ });
666
+ this.bullWorker = null;
667
+ this.bullQueue = null;
668
+ console.log("[queue] Fell back to in-memory queue");
669
+ }
670
+ async enqueue(fn) {
671
+ if (this.backend === "redis" && this.bullQueue) {
672
+ return this.enqueueRedis(fn);
673
+ }
674
+ return this.enqueueMemory(fn);
675
+ }
676
+ async enqueueRedis(fn) {
677
+ const count = await this.bullQueue.getJobCountByTypes("waiting", "active");
678
+ if (count >= this.maxSize) {
679
+ const error = new Error("Queue is full");
680
+ error.status = 429;
681
+ throw error;
682
+ }
683
+ return new Promise((resolve2, reject) => {
684
+ const jobId = `ingest-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
685
+ this.jobHandlers.set(jobId, {
686
+ fn,
687
+ resolve: resolve2,
688
+ reject
689
+ });
690
+ this.bullQueue.add("ingest", {}, { jobId }).catch((err) => {
691
+ this.jobHandlers.delete(jobId);
692
+ reject(err);
693
+ });
694
+ });
695
+ }
696
+ async enqueueMemory(fn) {
697
+ if (this.memPending.length >= this.maxSize) {
698
+ const error = new Error("Queue is full");
699
+ error.status = 429;
700
+ throw error;
701
+ }
702
+ return new Promise((resolve2, reject) => {
703
+ this.memPending.push({
704
+ fn,
705
+ resolve: resolve2,
706
+ reject
707
+ });
708
+ this.processMemory();
709
+ });
710
+ }
711
+ async stats() {
712
+ if (this.backend === "redis" && this.bullQueue) {
713
+ const counts = await this.bullQueue.getJobCounts("waiting", "active", "completed", "failed");
714
+ return {
715
+ pending: counts.waiting || 0,
716
+ active: counts.active || 0,
717
+ completed: counts.completed || 0,
718
+ failed: counts.failed || 0,
719
+ maxSize: this.maxSize,
720
+ concurrency: this.concurrency,
721
+ backend: "redis"
722
+ };
723
+ }
724
+ return {
725
+ pending: this.memPending.length,
726
+ active: this.memActiveCount,
727
+ completed: this.memCompletedCount,
728
+ failed: this.memFailedCount,
729
+ maxSize: this.maxSize,
730
+ concurrency: this.concurrency,
731
+ backend: "memory"
732
+ };
733
+ }
734
+ async close() {
735
+ await this.bullWorker?.close();
736
+ await this.bullQueue?.close();
737
+ }
738
+ processMemory() {
739
+ while (this.memActiveCount < this.concurrency && this.memPending.length > 0) {
740
+ const item = this.memPending.shift();
741
+ if (!item) break;
742
+ this.memActiveCount++;
743
+ item.fn().then((result) => {
744
+ this.memCompletedCount++;
745
+ item.resolve(result);
746
+ }).catch((error) => {
747
+ this.memFailedCount++;
748
+ item.reject(error);
749
+ }).finally(() => {
750
+ this.memActiveCount--;
751
+ this.processMemory();
752
+ });
753
+ }
754
+ }
755
+ };
756
+
757
+ // src/middleware/auth.ts
758
+ function authMiddleware(proxyApiKey) {
759
+ return async (c, next) => {
760
+ if (!proxyApiKey) {
761
+ return c.json({ error: "Server misconfiguration: PROXY_API_KEY not set" }, 500);
762
+ }
763
+ const apiKey = c.req.header("X-API-Key");
764
+ if (!apiKey) {
765
+ return c.json({ error: "Missing X-API-Key header" }, 401);
766
+ }
767
+ if (apiKey !== proxyApiKey) {
768
+ return c.json({ error: "Invalid API key" }, 401);
769
+ }
770
+ await next();
771
+ };
772
+ }
773
+
774
+ // src/routes/ingest.ts
775
+ function createIngestRoutes(config, collectionDefs) {
776
+ const app = new Hono2();
777
+ const queue = new IngestionQueue({
778
+ concurrency: config.queue.concurrency,
779
+ maxSize: config.queue.maxSize,
780
+ redis: config.queue.redis
781
+ });
782
+ const typesense = getTypesenseClient(config);
783
+ const rateLimitMap = /* @__PURE__ */ new Map();
784
+ const rateLimit = config.rateLimit.ingest;
785
+ function checkRateLimit(ip) {
786
+ const now = Date.now();
787
+ const entry = rateLimitMap.get(ip);
788
+ if (!entry || now > entry.resetAt) {
789
+ rateLimitMap.set(ip, { count: 1, resetAt: now + 6e4 });
790
+ return true;
791
+ }
792
+ if (entry.count >= rateLimit) {
793
+ return false;
794
+ }
795
+ entry.count++;
796
+ return true;
797
+ }
798
+ app.use("/api/ingest/*", authMiddleware(config.proxy.apiKey));
799
+ app.use("/api/ingest/:collection/documents/*", async (c, next) => {
800
+ const ip = c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "unknown";
801
+ if (!checkRateLimit(ip)) {
802
+ return c.json({ error: "Rate limit exceeded" }, 429);
803
+ }
804
+ await next();
805
+ });
806
+ app.use("/api/ingest/:collection/documents", async (c, next) => {
807
+ if (c.req.method === "POST" || c.req.method === "DELETE") {
808
+ const ip = c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "unknown";
809
+ if (!checkRateLimit(ip)) {
810
+ return c.json({ error: "Rate limit exceeded" }, 429);
811
+ }
812
+ }
813
+ await next();
814
+ });
815
+ function getLocale(c) {
816
+ return c.req.header("X-Locale") ?? c.req.query("locale") ?? void 0;
817
+ }
818
+ function getCollectionDef(collectionName) {
819
+ return collectionDefs?.[collectionName];
820
+ }
821
+ function processDoc(doc, collectionName, locale) {
822
+ const def = getCollectionDef(collectionName);
823
+ if (!def) return doc;
824
+ return applyComputedFields(doc, def, locale);
825
+ }
826
+ function processDocs(docs, collectionName, locale) {
827
+ const def = getCollectionDef(collectionName);
828
+ if (!def) return docs;
829
+ return applyComputedFieldsBatch(docs, def, locale);
830
+ }
831
+ app.post("/api/ingest/:collection/documents", async (c) => {
832
+ const collectionName = c.req.param("collection");
833
+ if (!collectionName) {
834
+ return c.json({ error: "Collection name is required" }, 400);
835
+ }
836
+ const locale = getLocale(c);
837
+ const resolved = resolveCollection(config.collections, collectionName, locale);
838
+ const body = await c.req.json();
839
+ const processed = processDoc(body, collectionName, locale);
840
+ const result = await queue.enqueue(async () => {
841
+ return typesense.collections(resolved).documents().upsert(processed);
842
+ });
843
+ return c.json(result, 201);
844
+ });
845
+ app.post("/api/ingest/:collection/documents/import", async (c) => {
846
+ const collectionName = c.req.param("collection");
847
+ if (!collectionName) {
848
+ return c.json({ error: "Collection name is required" }, 400);
849
+ }
850
+ const locale = getLocale(c);
851
+ const resolved = resolveCollection(config.collections, collectionName, locale);
852
+ const documents = await c.req.json();
853
+ if (!Array.isArray(documents)) {
854
+ return c.json({ error: "Request body must be a JSON array" }, 400);
855
+ }
856
+ const processed = processDocs(documents, collectionName, locale);
857
+ const result = await queue.enqueue(async () => {
858
+ return typesense.collections(resolved).documents().import(processed, { action: "upsert" });
859
+ });
860
+ return c.json(result);
861
+ });
862
+ app.patch("/api/ingest/:collection/documents/:id", async (c) => {
863
+ const collectionName = c.req.param("collection");
864
+ const docId = c.req.param("id");
865
+ if (!collectionName || !docId) {
866
+ return c.json({ error: "Collection name and document ID are required" }, 400);
867
+ }
868
+ const locale = getLocale(c);
869
+ const resolved = resolveCollection(config.collections, collectionName, locale);
870
+ const body = await c.req.json();
871
+ const processed = processDoc(body, collectionName, locale);
872
+ const result = await queue.enqueue(async () => {
873
+ return typesense.collections(resolved).documents(docId).update(processed);
874
+ });
875
+ return c.json(result);
876
+ });
877
+ app.delete("/api/ingest/:collection/documents/:id", async (c) => {
878
+ const collectionName = c.req.param("collection");
879
+ const docId = c.req.param("id");
880
+ if (!collectionName || !docId) {
881
+ return c.json({ error: "Collection name and document ID are required" }, 400);
882
+ }
883
+ const locale = getLocale(c);
884
+ const resolved = resolveCollection(config.collections, collectionName, locale);
885
+ const result = await queue.enqueue(async () => {
886
+ return typesense.collections(resolved).documents(docId).delete();
887
+ });
888
+ return c.json(result);
889
+ });
890
+ app.delete("/api/ingest/:collection/documents", async (c) => {
891
+ const collectionName = c.req.param("collection");
892
+ if (!collectionName) {
893
+ return c.json({ error: "Collection name is required" }, 400);
894
+ }
895
+ const filterBy = c.req.query("filter_by");
896
+ if (!filterBy) {
897
+ return c.json({ error: "filter_by query parameter is required" }, 400);
898
+ }
899
+ const locale = getLocale(c);
900
+ const resolved = resolveCollection(config.collections, collectionName, locale);
901
+ const result = await queue.enqueue(async () => {
902
+ return typesense.collections(resolved).documents().delete({ filter_by: filterBy });
903
+ });
904
+ return c.json(result);
905
+ });
906
+ app.get("/api/ingest/queue/status", async (c) => {
907
+ return c.json(await queue.stats());
908
+ });
909
+ return { app, queue };
910
+ }
911
+
912
+ // src/routes/health.ts
913
+ import { Hono as Hono3 } from "hono";
914
+ function createHealthRoutes(config) {
915
+ const app = new Hono3();
916
+ const typesense = getTypesenseClient(config);
917
+ app.get("/api/health", async (c) => {
918
+ let typesenseStatus = "error";
919
+ let typesenseMessage = "";
920
+ try {
921
+ const health = await typesense.health.retrieve();
922
+ typesenseStatus = health.ok ? "ok" : "error";
923
+ } catch (err) {
924
+ typesenseMessage = err instanceof Error ? err.message : "Unknown error";
925
+ }
926
+ let redisStatus = "not_configured";
927
+ let redisMessage = "";
928
+ if (config.queue.redis) {
929
+ const { host, port } = config.queue.redis;
930
+ try {
931
+ const net = await import("net");
932
+ await new Promise((resolve2, reject) => {
933
+ const socket = net.createConnection({ host, port }, () => {
934
+ socket.end();
935
+ resolve2();
936
+ });
937
+ socket.on("error", reject);
938
+ socket.setTimeout(2e3, () => {
939
+ socket.destroy();
940
+ reject(new Error("Connection timeout"));
941
+ });
942
+ });
943
+ redisStatus = "ok";
944
+ } catch (err) {
945
+ redisStatus = "error";
946
+ redisMessage = err instanceof Error ? err.message : "Unknown error";
947
+ }
948
+ }
949
+ const allOk = typesenseStatus === "ok" && redisStatus !== "error";
950
+ const status = allOk ? 200 : 503;
951
+ return c.json(
952
+ {
953
+ status: allOk ? "healthy" : "degraded",
954
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
955
+ proxy: { status: "ok" },
956
+ typesense: {
957
+ status: typesenseStatus,
958
+ host: `${config.typesense.protocol}://${config.typesense.host}:${config.typesense.port}`,
959
+ ...typesenseMessage && { error: typesenseMessage }
960
+ },
961
+ redis: {
962
+ status: redisStatus,
963
+ ...config.queue.redis && {
964
+ host: `${config.queue.redis.host}:${config.queue.redis.port}`
965
+ },
966
+ ...redisMessage && { error: redisMessage }
967
+ }
968
+ },
969
+ status
970
+ );
971
+ });
972
+ return app;
973
+ }
974
+
975
+ // src/routes/docs.ts
976
+ import { Hono as Hono4 } from "hono";
977
+ var OPENAPI_SPEC = {
978
+ openapi: "3.1.0",
979
+ info: {
980
+ title: "Typesense Proxy API",
981
+ description: "A proxy server for Typesense with Algolia-compatible search, caching, rate limiting, and ingestion queue.",
982
+ version: "0.1.0"
983
+ },
984
+ paths: {
985
+ "/api/search": {
986
+ post: {
987
+ summary: "Multi-search (Algolia-compatible)",
988
+ description: "Accepts Algolia InstantSearch multi-search format, transforms to Typesense, and returns Algolia-formatted results.",
989
+ tags: ["Search"],
990
+ parameters: [
991
+ {
992
+ name: "X-Locale",
993
+ in: "header",
994
+ description: "Locale for multilingual collection routing",
995
+ schema: { type: "string" }
996
+ },
997
+ {
998
+ name: "locale",
999
+ in: "query",
1000
+ description: "Locale for multilingual collection routing (alternative to header)",
1001
+ schema: { type: "string" }
1002
+ }
1003
+ ],
1004
+ requestBody: {
1005
+ required: true,
1006
+ content: {
1007
+ "application/json": {
1008
+ schema: {
1009
+ type: "object",
1010
+ required: ["requests"],
1011
+ properties: {
1012
+ requests: {
1013
+ type: "array",
1014
+ items: {
1015
+ type: "object",
1016
+ required: ["indexName", "params"],
1017
+ properties: {
1018
+ indexName: { type: "string", description: "Collection/index name" },
1019
+ params: { type: "string", description: "URL-encoded search parameters" }
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+ },
1028
+ responses: {
1029
+ "200": { description: "Algolia-formatted search results" },
1030
+ "429": { description: "Rate limit exceeded" }
1031
+ }
1032
+ }
1033
+ },
1034
+ "/api/ingest/{collection}/documents": {
1035
+ post: {
1036
+ summary: "Upsert a single document",
1037
+ tags: ["Ingest"],
1038
+ security: [{ apiKey: [] }],
1039
+ parameters: [
1040
+ { name: "collection", in: "path", required: true, schema: { type: "string" } },
1041
+ { name: "X-Locale", in: "header", schema: { type: "string" } }
1042
+ ],
1043
+ requestBody: {
1044
+ required: true,
1045
+ content: { "application/json": { schema: { type: "object" } } }
1046
+ },
1047
+ responses: {
1048
+ "201": { description: "Document upserted" },
1049
+ "401": { description: "Unauthorized" },
1050
+ "429": { description: "Rate limit or queue full" }
1051
+ }
1052
+ },
1053
+ delete: {
1054
+ summary: "Delete documents by filter",
1055
+ tags: ["Ingest"],
1056
+ security: [{ apiKey: [] }],
1057
+ parameters: [
1058
+ { name: "collection", in: "path", required: true, schema: { type: "string" } },
1059
+ { name: "filter_by", in: "query", required: true, schema: { type: "string" } }
1060
+ ],
1061
+ responses: {
1062
+ "200": { description: "Documents deleted" },
1063
+ "401": { description: "Unauthorized" }
1064
+ }
1065
+ }
1066
+ },
1067
+ "/api/ingest/{collection}/documents/import": {
1068
+ post: {
1069
+ summary: "Bulk import documents",
1070
+ tags: ["Ingest"],
1071
+ security: [{ apiKey: [] }],
1072
+ parameters: [
1073
+ { name: "collection", in: "path", required: true, schema: { type: "string" } }
1074
+ ],
1075
+ requestBody: {
1076
+ required: true,
1077
+ content: { "application/json": { schema: { type: "array", items: { type: "object" } } } }
1078
+ },
1079
+ responses: {
1080
+ "200": { description: "Import results" },
1081
+ "401": { description: "Unauthorized" }
1082
+ }
1083
+ }
1084
+ },
1085
+ "/api/ingest/{collection}/documents/{id}": {
1086
+ patch: {
1087
+ summary: "Partial update a document",
1088
+ tags: ["Ingest"],
1089
+ security: [{ apiKey: [] }],
1090
+ parameters: [
1091
+ { name: "collection", in: "path", required: true, schema: { type: "string" } },
1092
+ { name: "id", in: "path", required: true, schema: { type: "string" } }
1093
+ ],
1094
+ requestBody: {
1095
+ required: true,
1096
+ content: { "application/json": { schema: { type: "object" } } }
1097
+ },
1098
+ responses: {
1099
+ "200": { description: "Document updated" },
1100
+ "401": { description: "Unauthorized" }
1101
+ }
1102
+ },
1103
+ delete: {
1104
+ summary: "Delete a single document",
1105
+ tags: ["Ingest"],
1106
+ security: [{ apiKey: [] }],
1107
+ parameters: [
1108
+ { name: "collection", in: "path", required: true, schema: { type: "string" } },
1109
+ { name: "id", in: "path", required: true, schema: { type: "string" } }
1110
+ ],
1111
+ responses: {
1112
+ "200": { description: "Document deleted" },
1113
+ "401": { description: "Unauthorized" }
1114
+ }
1115
+ }
1116
+ },
1117
+ "/api/ingest/queue/status": {
1118
+ get: {
1119
+ summary: "Get ingestion queue status",
1120
+ tags: ["Ingest"],
1121
+ security: [{ apiKey: [] }],
1122
+ responses: {
1123
+ "200": { description: "Queue statistics" },
1124
+ "401": { description: "Unauthorized" }
1125
+ }
1126
+ }
1127
+ },
1128
+ "/api/health": {
1129
+ get: {
1130
+ summary: "Health check",
1131
+ tags: ["System"],
1132
+ responses: {
1133
+ "200": { description: "System healthy" },
1134
+ "503": { description: "System degraded" }
1135
+ }
1136
+ }
1137
+ }
1138
+ },
1139
+ components: {
1140
+ securitySchemes: {
1141
+ apiKey: {
1142
+ type: "apiKey",
1143
+ in: "header",
1144
+ name: "X-API-Key"
1145
+ }
1146
+ }
1147
+ }
1148
+ };
1149
+ function createDocsRoutes() {
1150
+ const app = new Hono4();
1151
+ app.get("/api/openapi.json", (c) => {
1152
+ return c.json(OPENAPI_SPEC);
1153
+ });
1154
+ app.get("/api/docs", (c) => {
1155
+ const html = `<!DOCTYPE html>
1156
+ <html>
1157
+ <head>
1158
+ <title>Typesense Proxy API - Documentation</title>
1159
+ <meta charset="utf-8" />
1160
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1161
+ </head>
1162
+ <body>
1163
+ <script id="api-reference" data-url="/api/openapi.json"></script>
1164
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
1165
+ </body>
1166
+ </html>`;
1167
+ return c.html(html);
1168
+ });
1169
+ return app;
1170
+ }
1171
+
1172
+ // src/index.ts
1173
+ function createApp(config, collectionDefs) {
1174
+ const cfg = config ?? loadConfig();
1175
+ const app = new Hono5();
1176
+ app.use("*", cors());
1177
+ app.use("*", logger());
1178
+ app.onError(errorHandler);
1179
+ const { app: searchApp, cache: searchCache } = createSearchRoutes(cfg, collectionDefs);
1180
+ const { app: ingestApp, queue: ingestQueue } = createIngestRoutes(cfg, collectionDefs);
1181
+ const healthApp = createHealthRoutes(cfg);
1182
+ const docsApp = createDocsRoutes();
1183
+ app.route("/", searchApp);
1184
+ app.route("/", ingestApp);
1185
+ app.route("/", healthApp);
1186
+ app.route("/", docsApp);
1187
+ return { app, config: cfg, searchCache, ingestQueue };
1188
+ }
1189
+
1190
+ export {
1191
+ loadConfig,
1192
+ resolveCollection,
1193
+ LRUCache,
1194
+ transformAlgoliaRequestToTypesense,
1195
+ transformTypesenseResponseToAlgolia,
1196
+ transformMultiSearchResponse,
1197
+ defineConfig,
1198
+ proxyConfigToConfig,
1199
+ getSearchableFields,
1200
+ getFacetFields,
1201
+ getSortableFields,
1202
+ toTypesenseSchema,
1203
+ getComputedFields,
1204
+ applyComputedFields,
1205
+ applyComputedFieldsBatch,
1206
+ IngestionQueue,
1207
+ createApp
1208
+ };