👁️
at dev 770 lines 22 kB view raw
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 };