👁️
1/**
2 * Web Worker for managing card data
3 *
4 * Loads card data in background thread to keep main thread responsive.
5 * Exposes RPC API via Comlink for querying cards.
6 */
7
8import * as Comlink from "comlink";
9import MiniSearch from "minisearch";
10import { CARD_CHUNKS, CARD_INDEXES, CARD_VOLATILE } from "../lib/card-manifest";
11import { LRUCache } from "../lib/lru-cache";
12import { stripDiacritics } from "../lib/normalize-text";
13import type {
14 CardDataOutput,
15 ManaColor,
16 OracleId,
17 ScryfallId,
18 VolatileData,
19} from "../lib/scryfall-types";
20import {
21 type CardPredicate,
22 describeQuery,
23 hasSearchOperators,
24 search as parseSearch,
25 type SearchNode,
26 someNode,
27} from "../lib/search";
28import type {
29 CachedSearchResult,
30 Card,
31 PaginatedSearchResult,
32 SearchRestrictions,
33 SortDirection,
34 SortField,
35 SortOption,
36 UnifiedSearchResult,
37} from "../lib/search-types";
38
39export type { SortField, SortDirection, SortOption };
40
41// Rarity ordering (higher = more rare, matches fields.ts RARITY_ORDER)
42const RARITY_ORDER: Record<string, number> = {
43 common: 0,
44 uncommon: 1,
45 rare: 2,
46 mythic: 3,
47 special: 4,
48 bonus: 5,
49};
50
51/**
52 * Check if a card is a "non-game" card (token, art series, memorabilia).
53 * These are excluded from search results unless explicitly queried.
54 * For cards with both game and non-game printings (e.g., Ancestral Recall),
55 * the canonical printing is sorted to prefer game printings in download-scryfall.ts.
56 */
57function isNonGameCard(card: Card): boolean {
58 return (
59 card.layout === "art_series" ||
60 card.layout === "token" ||
61 card.layout === "double_faced_token" ||
62 card.set_type === "token" ||
63 card.set_type === "memorabilia"
64 );
65}
66
67// WUBRG ordering
68const WUBRG_ORDER = ["W", "U", "B", "R", "G"];
69
70function resolveDirection(
71 field: SortField,
72 dir: SortDirection,
73): "asc" | "desc" {
74 if (dir !== "auto") return dir;
75 switch (field) {
76 case "name":
77 return "asc";
78 case "mv":
79 return "asc";
80 case "released":
81 return "desc";
82 case "rarity":
83 return "desc";
84 case "color":
85 return "asc";
86 }
87}
88
89function colorIdentityRank(colors: string[] | undefined): number {
90 if (!colors || colors.length === 0) return 100; // colorless last
91 // Primary sort by number of colors, secondary by first color in WUBRG
92 return (
93 colors.length * 10 +
94 Math.min(...colors.map((c) => WUBRG_ORDER.indexOf(c)).filter((i) => i >= 0))
95 );
96}
97
98function getSortableName(name: string): string {
99 return name.startsWith("A-") ? name.slice(2) : name;
100}
101
102type CardComparator = (a: Card, b: Card) => number;
103
104function buildComparator(sort: SortOption): CardComparator {
105 const dir = resolveDirection(sort.field, sort.direction);
106 const mult = dir === "desc" ? -1 : 1;
107
108 switch (sort.field) {
109 case "name":
110 return (a, b) =>
111 mult * getSortableName(a.name).localeCompare(getSortableName(b.name));
112 case "mv":
113 return (a, b) => mult * ((a.cmc ?? 0) - (b.cmc ?? 0));
114 case "released":
115 return (a, b) =>
116 mult * (a.released_at ?? "").localeCompare(b.released_at ?? "");
117 case "rarity":
118 return (a, b) =>
119 mult *
120 ((RARITY_ORDER[a.rarity ?? ""] ?? 99) -
121 (RARITY_ORDER[b.rarity ?? ""] ?? 99));
122 case "color":
123 return (a, b) =>
124 mult *
125 (colorIdentityRank(a.color_identity) -
126 colorIdentityRank(b.color_identity));
127 }
128}
129
130function buildChainedComparator(sorts: SortOption[]): CardComparator {
131 const comparators = sorts.map(buildComparator);
132 const nameTiebreaker: CardComparator = (a, b) =>
133 getSortableName(a.name).localeCompare(getSortableName(b.name));
134
135 return (a, b) => {
136 for (const cmp of comparators) {
137 const result = cmp(a, b);
138 if (result !== 0) return result;
139 }
140 return nameTiebreaker(a, b);
141 };
142}
143
144function sortCards(cards: Card[], sorts: SortOption[]): void {
145 if (sorts.length === 0) return;
146 cards.sort(buildChainedComparator(sorts));
147}
148
149const VOLATILE_RECORD_SIZE = 44; // 16 (UUID) + 4 (rank) + 6*4 (prices)
150const NULL_VALUE = 0xffffffff;
151
152function bytesToUuid(bytes: Uint8Array): string {
153 const hex = Array.from(bytes)
154 .map((b) => b.toString(16).padStart(2, "0"))
155 .join("");
156 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
157}
158
159// NOTE: We always search all printings and dedup to canonical.
160// If this proves slow, we could reintroduce hasPrintingQuery() to optimize
161// queries without printing-specific fields (set, rarity, artist, etc.)
162// by searching only canonical cards. The dedup logic would remain unchanged.
163
164interface CardsWorkerAPI {
165 /**
166 * Initialize worker by loading all card data
167 */
168 initialize(): Promise<void>;
169
170 /**
171 * Search cards by name with optional restrictions
172 */
173 searchCards(
174 query: string,
175 restrictions?: SearchRestrictions,
176 maxResults?: number,
177 ): Card[];
178
179 /**
180 * Get card by ID
181 */
182 getCardById(id: ScryfallId): Card | undefined;
183
184 /**
185 * Get all printings for an oracle ID
186 */
187 getPrintingsByOracleId(oracleId: OracleId): ScryfallId[];
188
189 /**
190 * Get metadata (version, card count)
191 */
192 getMetadata(): { version: string; cardCount: number };
193
194 /**
195 * Get canonical printing ID for an oracle ID
196 */
197 getCanonicalPrinting(oracleId: OracleId): ScryfallId | undefined;
198
199 /**
200 * Search cards using Scryfall-like syntax
201 */
202 syntaxSearch(
203 query: string,
204 maxResults?: number,
205 sort?: SortOption[],
206 ):
207 | { ok: true; cards: Card[] }
208 | { ok: false; error: { message: string; start: number; end: number } };
209
210 /**
211 * Get volatile data (prices, EDHREC rank) for a card
212 * Waits for volatile data to load if not ready yet
213 * Returns null if card not found
214 */
215 getVolatileData(id: ScryfallId): Promise<VolatileData | null>;
216
217 /**
218 * Unified search that routes to fuzzy or syntax search based on query complexity
219 */
220 unifiedSearch(
221 query: string,
222 restrictions?: SearchRestrictions,
223 maxResults?: number,
224 sort?: SortOption[],
225 ): UnifiedSearchResult;
226
227 /**
228 * Paginated unified search with caching for virtual scroll
229 * Caches full result set in LRU cache, returns requested slice
230 */
231 paginatedUnifiedSearch(
232 query: string,
233 restrictions: SearchRestrictions | undefined,
234 sort: SortOption[],
235 offset: number,
236 limit: number,
237 ): Promise<PaginatedSearchResult>;
238}
239
240class CardsWorker implements CardsWorkerAPI {
241 private data: CardDataOutput | null = null;
242 private canonicalCards: Card[] = [];
243 private canonicalRank: Map<ScryfallId, number> = new Map();
244 private searchIndex: MiniSearch<Card> | null = null;
245 private volatileDataPromise: Promise<Map<string, VolatileData>> | null = null;
246 private searchCache = new LRUCache<string, CachedSearchResult>(5);
247
248 async initialize(): Promise<void> {
249 // Prevent re-initialization in SharedWorker mode (shared across tabs)
250 if (this.data) {
251 console.log("[CardsWorker] Already initialized, skipping");
252 return;
253 }
254
255 console.log("[CardsWorker] Initializing card data...");
256 console.log(
257 `[CardsWorker] Loading ${CARD_CHUNKS.length} chunks + indexes...`,
258 );
259
260 // Fetch card data in parallel (from immutable cache subfolder)
261 const [indexes, ...chunks] = await Promise.all([
262 fetch(`/data/cards/${CARD_INDEXES}`).then((r) => {
263 if (!r.ok) throw new Error("Failed to load card indexes");
264 return r.json() as Promise<
265 Pick<CardDataOutput, "version" | "cardCount" | "oracleIdToPrintings">
266 >;
267 }),
268 ...CARD_CHUNKS.map((filename) =>
269 fetch(`/data/cards/${filename}`).then((r) => {
270 if (!r.ok) throw new Error(`Failed to load chunk: ${filename}`);
271 return r.json() as Promise<{ cards: Record<string, Card> }>;
272 }),
273 ),
274 ]);
275
276 // Merge all chunks into single cards object
277 const cards = Object.assign({}, ...chunks.map((c) => c.cards));
278
279 console.log(
280 `[CardsWorker] Loaded ${Object.keys(cards).length} cards from ${CARD_CHUNKS.length} chunks`,
281 );
282
283 this.data = {
284 version: indexes.version,
285 cardCount: indexes.cardCount,
286 cards,
287 oracleIdToPrintings: indexes.oracleIdToPrintings,
288 };
289
290 // Build canonical cards array (one per oracle ID, excluding art cards)
291 // First element of each oracleIdToPrintings array is the canonical printing
292 this.canonicalCards = Object.values(this.data.oracleIdToPrintings)
293 .map((printingIds) => this.data?.cards[printingIds[0]])
294 .filter((card): card is Card => card !== undefined)
295 .filter((card) => card.layout !== "art_series");
296
297 // Build canonical rank map for O(1) lookup during search dedup
298 // Lower rank = more canonical (first in oracleIdToPrintings = rank 0)
299 this.canonicalRank.clear();
300 for (const printingIds of Object.values(this.data.oracleIdToPrintings)) {
301 for (let rank = 0; rank < printingIds.length; rank++) {
302 this.canonicalRank.set(printingIds[rank], rank);
303 }
304 }
305
306 // Build fuzzy search index
307 console.log("[CardsWorker] Building search index...");
308
309 // MiniSearch's default tokenizer splits on /[\n\r\p{Z}\p{P}]+/u (Unicode
310 // separators and punctuation), which strips "&" from card names like
311 // "Minsc & Boo". We use a negated version of the same pattern but carve out
312 // "&" so processTerm can normalize it to "and". This is uglier than a
313 // positive match like /[\p{L}\p{N}\p{M}]+|&/gu but correct by construction
314 // (guaranteed to match exactly what the default tokenizer would, plus &).
315 const SEARCH_TOKEN = /&|[^\n\r\p{Z}\p{P}]+/gu;
316
317 this.searchIndex = new MiniSearch<Card>({
318 fields: ["name"],
319 storeFields: ["id", "oracle_id", "name"],
320 tokenize: (text) => text.match(SEARCH_TOKEN) ?? [],
321 processTerm: (term) => {
322 if (term === "&") return "and";
323 return stripDiacritics(term).toLowerCase();
324 },
325 searchOptions: {
326 prefix: true, // "bol" matches "bolt"
327 fuzzy: 0.3, // ~2 char tolerance
328 combineWith: "AND", // all terms must match
329 weights: {
330 prefix: 0.7, // exact (1.0) > prefix (0.7) > fuzzy
331 fuzzy: 0.4,
332 },
333 },
334 });
335
336 this.searchIndex.addAll(this.canonicalCards);
337
338 console.log(
339 `[CardsWorker] Initialized: ${this.data.cardCount.toLocaleString()} cards, ${this.canonicalCards.length.toLocaleString()} unique`,
340 );
341
342 // Load volatile data in background (non-blocking)
343 this.volatileDataPromise = this.loadVolatileData();
344 }
345
346 private async loadVolatileData(): Promise<Map<string, VolatileData>> {
347 console.log("[CardsWorker] Loading volatile data...");
348
349 try {
350 const response = await fetch(`/data/cards/${CARD_VOLATILE}`);
351 if (!response.ok) {
352 console.warn("[CardsWorker] Failed to load volatile data");
353 return new Map();
354 }
355
356 const buffer = await response.arrayBuffer();
357 const view = new DataView(buffer);
358 const recordCount = buffer.byteLength / VOLATILE_RECORD_SIZE;
359
360 const volatileMap = new Map<string, VolatileData>();
361
362 for (let i = 0; i < recordCount; i++) {
363 const offset = i * VOLATILE_RECORD_SIZE;
364
365 // Read UUID (16 bytes)
366 const uuidBytes = new Uint8Array(buffer, offset, 16);
367 const id = bytesToUuid(uuidBytes);
368
369 // Read values (little-endian uint32)
370 const readValue = (fieldOffset: number): number | null => {
371 const val = view.getUint32(offset + fieldOffset, true);
372 return val === NULL_VALUE ? null : val;
373 };
374
375 // Convert cents back to dollars for prices
376 const centsToPrice = (cents: number | null): number | null =>
377 cents === null ? null : cents / 100;
378
379 volatileMap.set(id, {
380 edhrecRank: readValue(16),
381 usd: centsToPrice(readValue(20)),
382 usdFoil: centsToPrice(readValue(24)),
383 usdEtched: centsToPrice(readValue(28)),
384 eur: centsToPrice(readValue(32)),
385 eurFoil: centsToPrice(readValue(36)),
386 tix: centsToPrice(readValue(40)),
387 });
388 }
389
390 console.log(
391 `[CardsWorker] Loaded volatile data for ${volatileMap.size.toLocaleString()} cards`,
392 );
393 return volatileMap;
394 } catch (error) {
395 console.warn("[CardsWorker] Error loading volatile data:", error);
396 return new Map();
397 }
398 }
399
400 searchCards(
401 query: string,
402 restrictions?: SearchRestrictions,
403 maxResults = 50,
404 ): Card[] {
405 if (!this.data || !this.searchIndex) {
406 throw new Error("Worker not initialized - call initialize() first");
407 }
408
409 if (!query.trim()) {
410 return [];
411 }
412
413 const searchResults = this.searchIndex.search(query);
414 const restrictionCheck = this.buildRestrictionCheck(restrictions);
415 const results: Card[] = [];
416
417 for (const result of searchResults) {
418 const card = this.data.cards[result.id as ScryfallId];
419 if (!card) continue;
420 if (isNonGameCard(card)) continue;
421 if (!restrictionCheck(card)) continue;
422
423 results.push(card);
424 if (results.length >= maxResults) break;
425 }
426
427 return results;
428 }
429
430 getCardById(id: ScryfallId): Card | undefined {
431 if (!this.data) {
432 throw new Error("Worker not initialized - call initialize() first");
433 }
434 return this.data.cards[id];
435 }
436
437 getPrintingsByOracleId(oracleId: OracleId): ScryfallId[] {
438 if (!this.data) {
439 throw new Error("Worker not initialized - call initialize() first");
440 }
441 return this.data.oracleIdToPrintings[oracleId] ?? [];
442 }
443
444 getMetadata(): { version: string; cardCount: number } {
445 if (!this.data) {
446 throw new Error("Worker not initialized - call initialize() first");
447 }
448 return {
449 version: this.data.version,
450 cardCount: this.data.cardCount,
451 };
452 }
453
454 getCanonicalPrinting(oracleId: OracleId): ScryfallId | undefined {
455 if (!this.data) {
456 throw new Error("Worker not initialized - call initialize() first");
457 }
458 // First element of oracleIdToPrintings is the canonical printing
459 return this.data.oracleIdToPrintings[oracleId]?.[0];
460 }
461
462 syntaxSearch(
463 query: string,
464 maxResults = 100,
465 sort: SortOption[] = [{ field: "name", direction: "auto" }],
466 ):
467 | { ok: true; cards: Card[] }
468 | { ok: false; error: { message: string; start: number; end: number } } {
469 if (!this.data) {
470 throw new Error("Worker not initialized - call initialize() first");
471 }
472
473 if (!query.trim()) {
474 return { ok: true, cards: [] };
475 }
476
477 const parseResult = parseSearch(query);
478
479 if (!parseResult.ok) {
480 return {
481 ok: false,
482 error: {
483 message: parseResult.error.message,
484 start: parseResult.error.span.start,
485 end: parseResult.error.span.end,
486 },
487 };
488 }
489
490 const { match, ast } = parseResult.value;
491 const cards = this.runParsedQuery(ast, match, maxResults, sort);
492 return { ok: true, cards };
493 }
494
495 async getVolatileData(id: ScryfallId): Promise<VolatileData | null> {
496 if (!this.volatileDataPromise) {
497 return null;
498 }
499 const volatileData = await this.volatileDataPromise;
500 return volatileData.get(id) ?? null;
501 }
502
503 unifiedSearch(
504 query: string,
505 restrictions?: SearchRestrictions,
506 maxResults = 50,
507 sort: SortOption[] = [{ field: "name", direction: "auto" }],
508 ): UnifiedSearchResult {
509 if (!this.data || !this.searchIndex) {
510 throw new Error("Worker not initialized - call initialize() first");
511 }
512
513 const trimmed = query.trim();
514 if (!trimmed) {
515 return { mode: "fuzzy", cards: [], description: null, error: null };
516 }
517
518 // Simple query - use fuzzy search (no parsing needed)
519 if (!hasSearchOperators(trimmed)) {
520 const cards = this.searchCards(trimmed, restrictions, maxResults);
521 return { mode: "fuzzy", cards, description: null, error: null };
522 }
523
524 // Complex query - parse and run syntax search
525 const parseResult = parseSearch(trimmed);
526
527 if (!parseResult.ok) {
528 return {
529 mode: "syntax",
530 cards: [],
531 description: null,
532 error: {
533 message: parseResult.error.message,
534 start: parseResult.error.span.start,
535 end: parseResult.error.span.end,
536 },
537 };
538 }
539
540 const { match, ast } = parseResult.value;
541 const description = describeQuery(ast);
542 const cards = this.runParsedQuery(
543 ast,
544 match,
545 maxResults,
546 sort,
547 restrictions,
548 );
549 return { mode: "syntax", cards, description, error: null };
550 }
551
552 async paginatedUnifiedSearch(
553 query: string,
554 restrictions: SearchRestrictions | undefined,
555 sort: SortOption[],
556 offset: number,
557 limit: number,
558 ): Promise<PaginatedSearchResult> {
559 if (!this.data || !this.searchIndex) {
560 throw new Error("Worker not initialized - call initialize() first");
561 }
562
563 if (!query.trim()) {
564 return {
565 mode: "fuzzy",
566 cards: [],
567 totalCount: 0,
568 description: null,
569 error: null,
570 };
571 }
572
573 const cacheKey = JSON.stringify({ query, restrictions, sort });
574 const cached = await this.searchCache.getOrSet(cacheKey, async () =>
575 this.executeFullUnifiedSearch(query, restrictions, sort),
576 );
577
578 return {
579 mode: cached.mode,
580 cards: cached.cards.slice(offset, offset + limit),
581 totalCount: cached.cards.length,
582 description: cached.description,
583 error: cached.error,
584 };
585 }
586
587 /**
588 * Execute full unified search without pagination (for caching)
589 */
590 private executeFullUnifiedSearch(
591 query: string,
592 restrictions: SearchRestrictions | undefined,
593 sort: SortOption[],
594 ): CachedSearchResult {
595 if (!this.data || !this.searchIndex) {
596 return { mode: "fuzzy", cards: [], description: null, error: null };
597 }
598
599 // Simple query - use fuzzy search
600 if (!hasSearchOperators(query)) {
601 const restrictionCheck = this.buildRestrictionCheck(restrictions);
602 const searchResults = this.searchIndex.search(query);
603 const cards: Card[] = [];
604
605 for (const result of searchResults) {
606 const card = this.data.cards[result.id as ScryfallId];
607 if (!card) continue;
608 if (isNonGameCard(card)) continue;
609 if (!restrictionCheck(card)) continue;
610 cards.push(card);
611 }
612
613 return { mode: "fuzzy", cards, description: null, error: null };
614 }
615
616 // Complex query - parse and run syntax search
617 const parseResult = parseSearch(query);
618
619 if (!parseResult.ok) {
620 return {
621 mode: "syntax",
622 cards: [],
623 description: null,
624 error: {
625 message: parseResult.error.message,
626 start: parseResult.error.span.start,
627 end: parseResult.error.span.end,
628 },
629 };
630 }
631
632 const { match, ast } = parseResult.value;
633 const description = describeQuery(ast);
634 const cards = this.runFullParsedQuery(ast, match, sort, restrictions);
635 return { mode: "syntax", cards, description, error: null };
636 }
637
638 /**
639 * Run a parsed query without result limit (for caching)
640 */
641 private runFullParsedQuery(
642 ast: SearchNode,
643 match: CardPredicate,
644 sort: SortOption[],
645 restrictions?: SearchRestrictions,
646 ): Card[] {
647 if (!this.data) return [];
648
649 const includesNonGameCards = someNode(
650 ast,
651 (n) =>
652 n.type === "FIELD" && (n.field === "settype" || n.field === "layout"),
653 );
654
655 const restrictionCheck = this.buildRestrictionCheck(restrictions);
656
657 const allMatches: Card[] = [];
658 for (const card of Object.values(this.data.cards)) {
659 if (!includesNonGameCards && isNonGameCard(card)) continue;
660 if (!restrictionCheck(card)) continue;
661 if (!match(card)) continue;
662 allMatches.push(card);
663 }
664
665 const dedupedCards = this.collapseToCanonical(allMatches);
666 sortCards(dedupedCards, sort);
667 return dedupedCards;
668 }
669
670 /**
671 * Run a parsed query: filter cards, collapse to canonical, sort.
672 */
673 private runParsedQuery(
674 ast: SearchNode,
675 match: CardPredicate,
676 maxResults: number,
677 sort: SortOption[],
678 restrictions?: SearchRestrictions,
679 ): Card[] {
680 if (!this.data) return [];
681
682 // Check if query explicitly references layout/set-type (don't filter non-game cards)
683 const includesNonGameCards = someNode(
684 ast,
685 (n) =>
686 n.type === "FIELD" && (n.field === "settype" || n.field === "layout"),
687 );
688
689 const restrictionCheck = this.buildRestrictionCheck(restrictions);
690
691 // Filter cards, skipping non-game cards unless explicitly queried
692 const allMatches: Card[] = [];
693 for (const card of Object.values(this.data.cards)) {
694 if (!includesNonGameCards && isNonGameCard(card)) continue;
695 if (!restrictionCheck(card)) continue;
696 if (!match(card)) continue;
697 allMatches.push(card);
698 }
699
700 // Collapse to one per oracle_id, sort, and limit
701 const dedupedCards = this.collapseToCanonical(allMatches);
702 sortCards(dedupedCards, sort);
703 return dedupedCards.slice(0, maxResults);
704 }
705
706 /**
707 * Collapse multiple printings to one per oracle_id.
708 * Picks the most canonical (lowest rank) match for each oracle.
709 */
710 private collapseToCanonical(cards: Card[]): Card[] {
711 const best = new Map<OracleId, { card: Card; rank: number }>();
712
713 for (const card of cards) {
714 const rank = this.canonicalRank.get(card.id) ?? Number.MAX_SAFE_INTEGER;
715 const existing = best.get(card.oracle_id);
716 if (!existing || rank < existing.rank) {
717 best.set(card.oracle_id, { card, rank });
718 }
719 }
720
721 return Array.from(best.values()).map((b) => b.card);
722 }
723
724 private buildRestrictionCheck(
725 restrictions?: SearchRestrictions,
726 ): (card: Card) => boolean {
727 if (!restrictions) return () => true;
728
729 const { format, colorIdentity } = restrictions;
730 const allowedSet = colorIdentity ? new Set(colorIdentity) : null;
731
732 return (card: Card) => {
733 if (format) {
734 const legality = card.legalities?.[format];
735 if (legality !== "legal" && legality !== "restricted") {
736 return false;
737 }
738 }
739 if (allowedSet) {
740 const cardIdentity = card.color_identity ?? [];
741 if (!cardIdentity.every((c) => allowedSet.has(c as ManaColor))) {
742 return false;
743 }
744 }
745 return true;
746 };
747 }
748}
749
750const worker = new CardsWorker();
751
752// Support both SharedWorker and regular Worker modes
753if ("SharedWorkerGlobalScope" in self) {
754 // SharedWorker mode - handle multiple connections
755 console.log("[CardsWorker] Running in SharedWorker mode");
756 (self as unknown as { onconnect: (e: MessageEvent) => void }).onconnect = (
757 e: MessageEvent,
758 ) => {
759 const port = e.ports[0];
760 console.log("[CardsWorker] New tab connected");
761 Comlink.expose(worker, port);
762 };
763} else {
764 // Regular Worker mode - single connection
765 console.log("[CardsWorker] Running in Worker mode");
766 Comlink.expose(worker);
767}
768
769export type { CardsWorkerAPI };
770export { CardsWorker as __CardsWorkerForTestingOnly };