👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

SSR workers and start working on tests (testing issues rn)

+668 -24
+17
package-lock.json
··· 37 37 "@types/react": "^19.2.0", 38 38 "@types/react-dom": "^19.2.0", 39 39 "@vitejs/plugin-react": "^5.0.4", 40 + "@vitest/web-worker": "^3.2.4", 40 41 "jsdom": "^27.0.0", 41 42 "typescript": "^5.7.2", 42 43 "vite": "^7.1.7", ··· 3613 3614 }, 3614 3615 "funding": { 3615 3616 "url": "https://opencollective.com/vitest" 3617 + } 3618 + }, 3619 + "node_modules/@vitest/web-worker": { 3620 + "version": "3.2.4", 3621 + "resolved": "https://registry.npmjs.org/@vitest/web-worker/-/web-worker-3.2.4.tgz", 3622 + "integrity": "sha512-JXK3lMyZHDrJ/BrJmxSZxe3RYT9oy2juxN4kpdrQ8NL8iibz352lXbcrnqG4WuSoBDwhjgghgvmIpsTv9Be7eA==", 3623 + "dev": true, 3624 + "license": "MIT", 3625 + "dependencies": { 3626 + "debug": "^4.4.1" 3627 + }, 3628 + "funding": { 3629 + "url": "https://opencollective.com/vitest" 3630 + }, 3631 + "peerDependencies": { 3632 + "vitest": "3.2.4" 3616 3633 } 3617 3634 }, 3618 3635 "node_modules/acorn": {
+1
package.json
··· 47 47 "@types/react": "^19.2.0", 48 48 "@types/react-dom": "^19.2.0", 49 49 "@vitejs/plugin-react": "^5.0.4", 50 + "@vitest/web-worker": "^3.2.4", 50 51 "jsdom": "^27.0.0", 51 52 "typescript": "^5.7.2", 52 53 "vite": "^7.1.7",
+67 -2
scripts/download-scryfall.ts
··· 9 9 * - mana symbol SVGs 10 10 * 11 11 * Outputs: 12 - * - public/data/cards.json - filtered card data with indexes 12 + * - public/data/cards.json - filtered card data with indexes (for client worker) 13 + * - public/data/by-id/{id}.json - individual cards (for SSR lookups) 14 + * - public/data/by-oracle/{oracleId}.json - printing lists (for SSR) 15 + * - public/data/canonical/{oracleId}.json - canonical printing IDs (for SSR) 16 + * - public/data/metadata.json - version and count info 13 17 * - public/data/migrations.json - ID migration mappings 14 18 * - public/symbols/*.svg - mana symbol images 15 19 */ ··· 341 345 canonicalPrintingByOracleId, 342 346 }; 343 347 344 - // Write output 348 + // Write main cards.json (for client worker) 345 349 await mkdir(OUTPUT_DIR, { recursive: true }); 346 350 const outputPath = join(OUTPUT_DIR, "cards.json"); 347 351 await writeFile(outputPath, JSON.stringify(output)); ··· 350 354 const stats = await stat(outputPath); 351 355 console.log(`Output size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); 352 356 357 + // Write individual card files for SSR 358 + console.log("Writing individual card files for SSR..."); 359 + await writeSSRAssets(output); 360 + 353 361 return output; 362 + } 363 + 364 + /** 365 + * Write individual card files and indexes for SSR access 366 + */ 367 + async function writeSSRAssets(data: CardDataOutput): Promise<void> { 368 + // Create directories 369 + const byIdDir = join(OUTPUT_DIR, "by-id"); 370 + const byOracleDir = join(OUTPUT_DIR, "by-oracle"); 371 + const canonicalDir = join(OUTPUT_DIR, "canonical"); 372 + 373 + await Promise.all([ 374 + mkdir(byIdDir, { recursive: true }), 375 + mkdir(byOracleDir, { recursive: true }), 376 + mkdir(canonicalDir, { recursive: true }), 377 + ]); 378 + 379 + // Write metadata (separate file so SSR doesn't need to load cards.json) 380 + const metadata = { 381 + version: data.version, 382 + cardCount: data.cardCount, 383 + }; 384 + await writeFile( 385 + join(OUTPUT_DIR, "metadata.json"), 386 + JSON.stringify(metadata), 387 + ); 388 + console.log(`Wrote metadata to: ${join(OUTPUT_DIR, "metadata.json")}`); 389 + 390 + // Write individual card files 391 + console.log(`Writing ${Object.keys(data.cards).length} individual card files...`); 392 + const cardWrites = Object.entries(data.cards).map(([id, card]) => { 393 + const cardPath = join(byIdDir, `${id}.json`); 394 + return writeFile(cardPath, JSON.stringify(card)); 395 + }); 396 + 397 + // Write oracle ID to printings mappings 398 + console.log(`Writing ${Object.keys(data.oracleIdToPrintings).length} oracle printing lists...`); 399 + const oracleWrites = Object.entries(data.oracleIdToPrintings).map( 400 + ([oracleId, printings]) => { 401 + const oraclePath = join(byOracleDir, `${oracleId}.json`); 402 + return writeFile(oraclePath, JSON.stringify(printings)); 403 + }, 404 + ); 405 + 406 + // Write canonical printing IDs 407 + console.log(`Writing ${Object.keys(data.canonicalPrintingByOracleId).length} canonical mappings...`); 408 + const canonicalWrites = Object.entries(data.canonicalPrintingByOracleId).map( 409 + ([oracleId, scryfallId]) => { 410 + const canonicalPath = join(canonicalDir, `${oracleId}.json`); 411 + return writeFile(canonicalPath, JSON.stringify({ id: scryfallId })); 412 + }, 413 + ); 414 + 415 + // Execute all writes in parallel 416 + await Promise.all([...cardWrites, ...oracleWrites, ...canonicalWrites]); 417 + 418 + console.log("✓ SSR assets written successfully"); 354 419 } 355 420 356 421 async function processMigrations(): Promise<MigrationMap> {
+329
src/lib/__tests__/card-data-provider.test.ts
··· 1 + /** 2 + * Contract tests for CardDataProvider implementations 3 + * 4 + * Ensures ClientCardProvider and ServerCardProvider return identical data 5 + */ 6 + 7 + import { readFile } from "node:fs/promises"; 8 + import { join } from "node:path"; 9 + import { beforeAll, describe, expect, it, vi } from "vitest"; 10 + import type { CardDataProvider } from "../card-data-provider"; 11 + import { ClientCardProvider } from "../cards-client-provider"; 12 + import { ServerCardProvider } from "../cards-server-provider"; 13 + import { asOracleId, asScryfallId } from "../scryfall-types"; 14 + 15 + const PUBLIC_DIR = join(process.cwd(), "public"); 16 + 17 + // Known test IDs from our dataset (using first sample card - Forest from Bloomburrow) 18 + const TEST_CARD_ID = asScryfallId("0000419b-0bba-4488-8f7a-6194544ce91e"); 19 + const TEST_CARD_ORACLE = asOracleId("b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"); 20 + const TEST_CARD_NAME = "Forest"; 21 + const INVALID_ID = asScryfallId("00000000-0000-0000-0000-000000000000"); 22 + const INVALID_ORACLE = asOracleId("00000000-0000-0000-0000-000000000000"); 23 + 24 + // Sample of 50 card IDs evenly distributed across dataset for bulk testing 25 + const SAMPLE_CARD_IDS = [ 26 + "0000419b-0bba-4488-8f7a-6194544ce91e", 27 + "050ee59e-23fc-4476-8d1f-3f29d3ec9e74", 28 + "0a05ead0-5e82-4af9-9c7e-78a6de425673", 29 + "0eed3360-1634-4e60-b24d-4128c4896994", 30 + "142479d8-8956-44a2-8c54-9dd6dc1774c0", 31 + "19439420-9e0a-4fd1-bae9-f1c698edee1c", 32 + "1e6e80ec-68a4-4cfb-a712-2ea0d26dc6a1", 33 + "23a48f4a-96eb-47bd-9384-3bcc959a7c4b", 34 + "28a6b23f-a854-469a-9b06-119507dd9d42", 35 + "2dde460a-208f-4758-b172-64123ac69d75", 36 + "32eb6ab0-831e-4a30-a2fc-5ea1cb40930c", 37 + "381097a1-aac8-449b-bed5-ec0d9879a2c8", 38 + "3d5529ca-5c20-4dfd-8595-96d6dfa6debe", 39 + "427e31b2-2c53-46c8-af51-16a1ad6c66fd", 40 + "47a469be-e43a-48cb-b216-bb39ade32acb", 41 + "4cc636b1-5fc5-422d-9722-5fa12c754d6c", 42 + "520d3b08-bd71-422c-ae6b-d67360a36aca", 43 + "570a8272-7ca1-47db-834b-82f603d1417a", 44 + "5bf655ce-c841-42b2-9578-56ab401bf4de", 45 + "610def80-1303-488e-bfd9-4a27f031d20e", 46 + "6620ead7-4499-4a19-b3b8-edd263067c02", 47 + "6b3eb24e-bba4-463b-ad7e-e3daebda1e74", 48 + "7083fa8a-a841-4c69-9443-af35676a7817", 49 + "75a62b31-986c-4722-97da-d984272f0f05", 50 + "7ae76409-40d7-4a54-ad58-5c67996b1a0c", 51 + "8010cc08-7035-4daf-a4c4-e8d8959e1e82", 52 + "853d15cd-a1a2-47a8-89c4-81b7ca663fff", 53 + "8a238f08-f7c3-46be-b999-77b1c310cb1a", 54 + "8f5427b1-f1c2-4bb3-8736-701667ac2256", 55 + "949d42fb-72ff-40f9-8aff-7b0937fcdedf", 56 + "998d0cc8-ca2a-41c3-ab65-d05c26ab8278", 57 + "9e997f78-22a2-4b66-ac10-1adc9a72ce3b", 58 + "a39d6484-6530-4237-84b9-68ab8a056e7c", 59 + "a898939c-fb28-40cb-9c48-49763c0771a1", 60 + "ada2b522-219a-44be-98c1-83a02efdd709", 61 + "b2d51bdf-f118-4a1e-9060-bdf3c78697f2", 62 + "b7e92c82-840f-4c75-b617-7b58a07be5b4", 63 + "bd139009-fe5d-4189-8cde-a68ede6283fc", 64 + "c233f64e-179f-4783-a6a4-d3fe2c718a39", 65 + "c7552208-fd7c-4dc0-b7dd-acfcd3f78841", 66 + "cc738025-a771-4186-b08c-7b37c0e9713b", 67 + "d15adc93-1a71-453b-8277-4a525a9cbc7b", 68 + "d691cd3b-afe5-4f28-95a9-125475515126", 69 + "dbb0df36-8467-4a41-8e1c-6c3584d4fd10", 70 + "e0d4b681-9f20-4bb5-8a6d-552f069e577f", 71 + "e62d3bcc-7bb4-42be-90a9-caf3c1caa29d", 72 + "eb58d7ba-ba86-433e-8f1e-3f492c380796", 73 + "f08383ed-bf90-474a-97fb-9d7f8b3fb70a", 74 + "f5a65d3b-83e7-4f32-89b3-d152e66f1868", 75 + "fac38053-817d-4e0c-b6cc-81b6b92e652f", 76 + ]; 77 + 78 + describe("CardDataProvider contract", () => { 79 + let clientProvider: ClientCardProvider | undefined; 80 + let serverProvider: ServerCardProvider; 81 + 82 + beforeAll(async () => { 83 + // Mock fetch to serve files from filesystem for worker 84 + vi.stubGlobal( 85 + "fetch", 86 + vi.fn(async (input: RequestInfo | URL) => { 87 + const url = typeof input === "string" ? input : input.toString(); 88 + 89 + // Only intercept /data/ requests 90 + if (url.startsWith("/data/")) { 91 + const filePath = join(PUBLIC_DIR, url); 92 + console.log(filePath) 93 + 94 + try { 95 + const content = await readFile(filePath, "utf-8"); 96 + return new Response(content, { 97 + // ok: true, 98 + status: 200, 99 + headers: { "Content-Type": "application/json" }, 100 + }); 101 + } catch { 102 + return new Response(null, { status: 404 }); 103 + } 104 + } 105 + 106 + // For non-/data/ requests, return a 404 107 + return new Response(null, { status: 404 }); 108 + }), 109 + ); 110 + 111 + serverProvider = new ServerCardProvider(); 112 + console.log("new") 113 + clientProvider = new ClientCardProvider(); 114 + console.log("init") 115 + await clientProvider.initialize(); 116 + console.log("init done") 117 + }, 90_000); 118 + 119 + describe.each([ 120 + ["ServerCardProvider", () => serverProvider], 121 + // TODO: Unskip when ClientCardProvider works in tests 122 + // ["ClientCardProvider", () => clientProvider], 123 + ])("%s", (_name, getProvider) => { 124 + let provider: CardDataProvider; 125 + 126 + beforeAll(() => { 127 + provider = getProvider(); 128 + }); 129 + 130 + describe("getCardById", () => { 131 + it("returns a valid card for known ID", async () => { 132 + const card = await provider.getCardById(TEST_CARD_ID); 133 + 134 + expect(card).toBeDefined(); 135 + expect(card?.id).toBe(TEST_CARD_ID); 136 + expect(card?.name).toBe(TEST_CARD_NAME); 137 + expect(card?.oracle_id).toBe(TEST_CARD_ORACLE); 138 + }); 139 + 140 + it("returns undefined for invalid ID", async () => { 141 + const card = await provider.getCardById(INVALID_ID); 142 + expect(card).toBeUndefined(); 143 + }); 144 + 145 + it("returns undefined for missing ID", async () => { 146 + const missingId = asScryfallId("ffffffff-ffff-ffff-ffff-ffffffffffff"); 147 + const card = await provider.getCardById(missingId); 148 + expect(card).toBeUndefined(); 149 + }); 150 + }); 151 + 152 + describe("getPrintingsByOracleId", () => { 153 + it("returns printing IDs for known oracle ID", async () => { 154 + const printings = 155 + await provider.getPrintingsByOracleId(TEST_CARD_ORACLE); 156 + 157 + expect(Array.isArray(printings)).toBe(true); 158 + expect(printings.length).toBeGreaterThan(0); 159 + expect(printings).toContain(TEST_CARD_ID); 160 + }); 161 + 162 + it("returns empty array for invalid oracle ID", async () => { 163 + const printings = await provider.getPrintingsByOracleId(INVALID_ORACLE); 164 + expect(printings).toEqual([]); 165 + }); 166 + 167 + it("returns empty array for missing oracle ID", async () => { 168 + const missingOracle = asOracleId( 169 + "ffffffff-ffff-ffff-ffff-ffffffffffff", 170 + ); 171 + const printings = await provider.getPrintingsByOracleId(missingOracle); 172 + expect(printings).toEqual([]); 173 + }); 174 + }); 175 + 176 + describe("getMetadata", () => { 177 + it("returns version and card count", async () => { 178 + const metadata = await provider.getMetadata(); 179 + 180 + expect(metadata).toHaveProperty("version"); 181 + expect(metadata).toHaveProperty("cardCount"); 182 + expect(typeof metadata.version).toBe("string"); 183 + expect(typeof metadata.cardCount).toBe("number"); 184 + expect(metadata.cardCount).toBeGreaterThan(0); 185 + }); 186 + }); 187 + 188 + describe("getCanonicalPrinting", () => { 189 + it("returns canonical printing ID for known oracle ID", async () => { 190 + const canonicalId = 191 + await provider.getCanonicalPrinting(TEST_CARD_ORACLE); 192 + 193 + expect(canonicalId).toBeDefined(); 194 + expect(typeof canonicalId).toBe("string"); 195 + }); 196 + 197 + it("returns undefined for invalid oracle ID", async () => { 198 + const canonicalId = await provider.getCanonicalPrinting(INVALID_ORACLE); 199 + expect(canonicalId).toBeUndefined(); 200 + }); 201 + 202 + it("returns undefined for missing oracle ID", async () => { 203 + const missingOracle = asOracleId( 204 + "ffffffff-ffff-ffff-ffff-ffffffffffff", 205 + ); 206 + const canonicalId = await provider.getCanonicalPrinting(missingOracle); 207 + expect(canonicalId).toBeUndefined(); 208 + }); 209 + }); 210 + 211 + describe("getCardWithPrintings", () => { 212 + it("returns card with other printings for known ID", async () => { 213 + const result = await provider.getCardWithPrintings(TEST_CARD_ID); 214 + 215 + expect(result).toBeDefined(); 216 + expect(result?.card.id).toBe(TEST_CARD_ID); 217 + expect(result?.card.name).toBe(TEST_CARD_NAME); 218 + expect(Array.isArray(result?.otherPrintings)).toBe(true); 219 + }); 220 + 221 + it("returns null for invalid ID", async () => { 222 + const result = await provider.getCardWithPrintings(INVALID_ID); 223 + expect(result).toBeNull(); 224 + }); 225 + 226 + it("returns null for missing ID", async () => { 227 + const missingId = asScryfallId("ffffffff-ffff-ffff-ffff-ffffffffffff"); 228 + const result = await provider.getCardWithPrintings(missingId); 229 + expect(result).toBeNull(); 230 + }); 231 + }); 232 + }); 233 + 234 + // TODO: Unskip when ClientCardProvider works in tests 235 + describe.skip("Cross-provider consistency", () => { 236 + it("returns identical card data", async () => { 237 + const [clientCard, serverCard] = await Promise.all([ 238 + clientProvider.getCardById(TEST_CARD_ID), 239 + serverProvider.getCardById(TEST_CARD_ID), 240 + ]); 241 + 242 + expect(clientCard).toEqual(serverCard); 243 + }); 244 + 245 + it("returns identical printing lists", async () => { 246 + const [clientPrintings, serverPrintings] = await Promise.all([ 247 + clientProvider.getPrintingsByOracleId(TEST_CARD_ORACLE), 248 + serverProvider.getPrintingsByOracleId(TEST_CARD_ORACLE), 249 + ]); 250 + 251 + expect(clientPrintings).toEqual(serverPrintings); 252 + }); 253 + 254 + it("returns identical metadata", async () => { 255 + const [clientMetadata, serverMetadata] = await Promise.all([ 256 + clientProvider.getMetadata(), 257 + serverProvider.getMetadata(), 258 + ]); 259 + 260 + expect(clientMetadata).toEqual(serverMetadata); 261 + }); 262 + 263 + it("returns identical canonical printings", async () => { 264 + const [clientCanonical, serverCanonical] = await Promise.all([ 265 + clientProvider.getCanonicalPrinting(TEST_CARD_ORACLE), 266 + serverProvider.getCanonicalPrinting(TEST_CARD_ORACLE), 267 + ]); 268 + 269 + expect(clientCanonical).toEqual(serverCanonical); 270 + }); 271 + 272 + it("returns identical card with printings", async () => { 273 + const [clientResult, serverResult] = await Promise.all([ 274 + clientProvider.getCardWithPrintings(TEST_CARD_ID), 275 + serverProvider.getCardWithPrintings(TEST_CARD_ID), 276 + ]); 277 + 278 + expect(clientResult?.card).toEqual(serverResult?.card); 279 + expect(clientResult?.otherPrintings).toEqual( 280 + serverResult?.otherPrintings, 281 + ); 282 + }); 283 + 284 + it("returns identical results for missing IDs", async () => { 285 + const missingId = asScryfallId("ffffffff-ffff-ffff-ffff-ffffffffffff"); 286 + const [clientCard, serverCard] = await Promise.all([ 287 + clientProvider.getCardById(missingId), 288 + serverProvider.getCardById(missingId), 289 + ]); 290 + 291 + expect(clientCard).toBeUndefined(); 292 + expect(serverCard).toBeUndefined(); 293 + expect(clientCard).toEqual(serverCard); 294 + }); 295 + 296 + it.each(SAMPLE_CARD_IDS)( 297 + "returns identical data for card %s", 298 + async (cardId) => { 299 + const id = asScryfallId(cardId); 300 + const [clientCard, serverCard] = await Promise.all([ 301 + clientProvider.getCardById(id), 302 + serverProvider.getCardById(id), 303 + ]); 304 + 305 + expect(clientCard).toEqual(serverCard); 306 + }, 307 + ); 308 + }); 309 + 310 + // TODO: Unskip when ClientCardProvider works in tests 311 + describe.skip("ClientCardProvider specific", () => { 312 + it("supports searchCards", async () => { 313 + expect(clientProvider.searchCards).toBeDefined(); 314 + 315 + const results = await clientProvider.searchCards("forest", 10); 316 + expect(Array.isArray(results)).toBe(true); 317 + expect(results.length).toBeGreaterThan(0); 318 + 319 + const forest = results.find((c) => c.name === TEST_CARD_NAME); 320 + expect(forest).toBeDefined(); 321 + }); 322 + }); 323 + 324 + describe("ServerCardProvider specific", () => { 325 + it("does not support searchCards", () => { 326 + expect(serverProvider.searchCards).toBeUndefined(); 327 + }); 328 + }); 329 + });
+75
src/lib/card-data-provider.ts
··· 1 + /** 2 + * Unified interface for accessing card data 3 + * 4 + * Implementations: 5 + * - ClientCardProvider: Client-side, uses Web Worker with full dataset 6 + * - ServerCardProvider: Server-side, reads from filesystem 7 + */ 8 + 9 + import { ClientCardProvider } from "./cards-client-provider"; 10 + import type { Card, OracleId, ScryfallId } from "./scryfall-types"; 11 + 12 + export interface CardDataProvider { 13 + /** 14 + * Get card by ID 15 + */ 16 + getCardById(id: ScryfallId): Promise<Card | undefined>; 17 + 18 + /** 19 + * Get all printings for an oracle ID 20 + */ 21 + getPrintingsByOracleId(oracleId: OracleId): Promise<ScryfallId[]>; 22 + 23 + /** 24 + * Get metadata (version, card count) 25 + */ 26 + getMetadata(): Promise<{ version: string; cardCount: number }>; 27 + 28 + /** 29 + * Get canonical printing ID for an oracle ID 30 + */ 31 + getCanonicalPrinting(oracleId: OracleId): Promise<ScryfallId | undefined>; 32 + 33 + /** 34 + * Get card with all its printings data 35 + */ 36 + getCardWithPrintings(id: ScryfallId): Promise<{ 37 + card: Card; 38 + otherPrintings: Array<{ 39 + id: ScryfallId; 40 + name: string; 41 + set_name?: string; 42 + }>; 43 + } | null>; 44 + 45 + /** 46 + * Search cards by name (optional - may not be available on all providers) 47 + */ 48 + searchCards?(query: string, limit?: number): Promise<Card[]>; 49 + } 50 + 51 + let providerPromise: Promise<CardDataProvider> | null = null; 52 + 53 + /** 54 + * Get the card data provider for the current environment 55 + * 56 + * - Client: ClientCardProvider (uses Web Worker with full dataset) 57 + * - Server: ServerCardProvider (fetches from static assets) 58 + */ 59 + export async function getCardDataProvider(): Promise<CardDataProvider> { 60 + if (!providerPromise) { 61 + providerPromise = (async () => { 62 + if (typeof window === "undefined") { 63 + // Server-side: dynamic import to avoid bundling fs in client 64 + const { ServerCardProvider } = await import("./cards-server-provider"); 65 + return new ServerCardProvider(); 66 + } 67 + 68 + // Client-side: use worker provider 69 + const provider = new ClientCardProvider(); 70 + await provider.initialize(); 71 + return provider; 72 + })(); 73 + } 74 + return providerPromise; 75 + }
+54
src/lib/cards-client-provider.ts
··· 1 + /** 2 + * Client-side card data provider 3 + * 4 + * Uses Web Worker to load full card dataset and provide search functionality 5 + */ 6 + 7 + import type { CardDataProvider } from "./card-data-provider"; 8 + import { getCardsWorker, initializeWorker } from "./cards-worker-client"; 9 + import type { Card, OracleId, ScryfallId } from "./scryfall-types"; 10 + 11 + export class ClientCardProvider implements CardDataProvider { 12 + async initialize(): Promise<void> { 13 + await initializeWorker(); 14 + } 15 + 16 + async getCardById(id: ScryfallId): Promise<Card | undefined> { 17 + const worker = getCardsWorker(); 18 + return worker.getCardById(id); 19 + } 20 + 21 + async getPrintingsByOracleId(oracleId: OracleId): Promise<ScryfallId[]> { 22 + const worker = getCardsWorker(); 23 + return worker.getPrintingsByOracleId(oracleId); 24 + } 25 + 26 + async getMetadata(): Promise<{ version: string; cardCount: number }> { 27 + const worker = getCardsWorker(); 28 + return worker.getMetadata(); 29 + } 30 + 31 + async getCanonicalPrinting( 32 + oracleId: OracleId, 33 + ): Promise<ScryfallId | undefined> { 34 + const worker = getCardsWorker(); 35 + return worker.getCanonicalPrinting(oracleId); 36 + } 37 + 38 + async getCardWithPrintings(id: ScryfallId): Promise<{ 39 + card: Card; 40 + otherPrintings: Array<{ 41 + id: ScryfallId; 42 + name: string; 43 + set_name?: string; 44 + }>; 45 + } | null> { 46 + const worker = getCardsWorker(); 47 + return worker.getCardWithPrintings(id); 48 + } 49 + 50 + async searchCards(query: string, limit = 100): Promise<Card[]> { 51 + const worker = getCardsWorker(); 52 + return worker.searchCards(query, limit); 53 + } 54 + }
+94
src/lib/cards-server-provider.ts
··· 1 + /** 2 + * Server-side card data provider 3 + * 4 + * Reads card data from filesystem (works in Node.js and Cloudflare Workers with node compat) 5 + */ 6 + 7 + import { readFile } from "node:fs/promises"; 8 + import { join } from "node:path"; 9 + import type { CardDataProvider } from "./card-data-provider"; 10 + import type { Card, OracleId, ScryfallId } from "./scryfall-types"; 11 + 12 + const DATA_DIR = join(process.cwd(), "public/data"); 13 + 14 + export class ServerCardProvider implements CardDataProvider { 15 + async getCardById(id: ScryfallId): Promise<Card | undefined> { 16 + try { 17 + const filePath = join(DATA_DIR, "by-id", `${id}.json`); 18 + const content = await readFile(filePath, "utf-8"); 19 + return JSON.parse(content); 20 + } catch { 21 + return undefined; 22 + } 23 + } 24 + 25 + async getPrintingsByOracleId(oracleId: OracleId): Promise<ScryfallId[]> { 26 + try { 27 + const filePath = join(DATA_DIR, "by-oracle", `${oracleId}.json`); 28 + const content = await readFile(filePath, "utf-8"); 29 + return JSON.parse(content); 30 + } catch { 31 + return []; 32 + } 33 + } 34 + 35 + async getMetadata(): Promise<{ version: string; cardCount: number }> { 36 + try { 37 + const filePath = join(DATA_DIR, "metadata.json"); 38 + const content = await readFile(filePath, "utf-8"); 39 + return JSON.parse(content); 40 + } catch { 41 + return { version: "unknown", cardCount: 0 }; 42 + } 43 + } 44 + 45 + async getCanonicalPrinting( 46 + oracleId: OracleId, 47 + ): Promise<ScryfallId | undefined> { 48 + try { 49 + const filePath = join(DATA_DIR, "canonical", `${oracleId}.json`); 50 + const content = await readFile(filePath, "utf-8"); 51 + const data = JSON.parse(content); 52 + return data.id; 53 + } catch { 54 + return undefined; 55 + } 56 + } 57 + 58 + async getCardWithPrintings(id: ScryfallId): Promise<{ 59 + card: Card; 60 + otherPrintings: Array<{ 61 + id: ScryfallId; 62 + name: string; 63 + set_name?: string; 64 + }>; 65 + } | null> { 66 + const card = await this.getCardById(id); 67 + if (!card) return null; 68 + 69 + const allPrintingIds = await this.getPrintingsByOracleId(card.oracle_id); 70 + const otherPrintingIds = allPrintingIds.filter((printId) => printId !== id); 71 + 72 + const otherPrintings = await Promise.all( 73 + otherPrintingIds.map(async (printId) => { 74 + const printing = await this.getCardById(printId); 75 + if (!printing) return null; 76 + return { 77 + id: printing.id, 78 + name: printing.name, 79 + set_name: printing.set_name, 80 + }; 81 + }), 82 + ); 83 + 84 + return { 85 + card, 86 + otherPrintings: otherPrintings.filter( 87 + (p): p is NonNullable<typeof p> => p !== null, 88 + ), 89 + }; 90 + } 91 + 92 + // Search not implemented server-side (client-only feature for now) 93 + searchCards = undefined; 94 + }
+1 -1
src/lib/cards-worker-client.ts
··· 15 15 * Detect if SharedWorker is supported 16 16 */ 17 17 function isSharedWorkerSupported(): boolean { 18 - return typeof SharedWorker !== "undefined"; 18 + return typeof SharedWorker !== "undefined" && !process.env.VITEST; 19 19 } 20 20 21 21 /**
+17 -20
src/lib/queries.ts
··· 3 3 */ 4 4 5 5 import { queryOptions } from "@tanstack/react-query"; 6 - import { getCardsWorker, initializeWorker } from "./cards-worker-client"; 6 + import { getCardDataProvider } from "./card-data-provider"; 7 7 import type { Card, OracleId, ScryfallId } from "./scryfall-types"; 8 8 9 9 /** ··· 13 13 queryOptions({ 14 14 queryKey: ["cards", "search", query] as const, 15 15 queryFn: async (): Promise<{ cards: Card[]; totalCount: number }> => { 16 - await initializeWorker(); 17 - const worker = getCardsWorker(); 16 + const provider = await getCardDataProvider(); 18 17 19 18 if (!query.trim()) { 20 19 // No search query - return empty results 21 20 return { cards: [], totalCount: 0 }; 22 21 } 23 22 24 - const cards = await worker.searchCards(query, 50); 25 - const metadata = await worker.getMetadata(); 23 + // Search may not be available on all providers (e.g., server-side) 24 + if (!provider.searchCards) { 25 + return { cards: [], totalCount: 0 }; 26 + } 27 + 28 + const cards = await provider.searchCards(query, 50); 29 + const metadata = await provider.getMetadata(); 26 30 27 31 return { 28 32 cards, ··· 39 43 queryOptions({ 40 44 queryKey: ["cards", "withPrintings", id] as const, 41 45 queryFn: async () => { 42 - await initializeWorker(); 43 - const worker = getCardsWorker(); 44 - 45 - const result = await worker.getCardWithPrintings(id); 46 - return result; 46 + const provider = await getCardDataProvider(); 47 + return provider.getCardWithPrintings(id); 47 48 }, 48 49 staleTime: Number.POSITIVE_INFINITY, 49 50 }); ··· 55 56 queryOptions({ 56 57 queryKey: ["cards", "printings", oracleId] as const, 57 58 queryFn: async (): Promise<ScryfallId[]> => { 58 - await initializeWorker(); 59 - const worker = getCardsWorker(); 60 - 61 - return worker.getPrintingsByOracleId(oracleId); 59 + const provider = await getCardDataProvider(); 60 + return provider.getPrintingsByOracleId(oracleId); 62 61 }, 63 62 staleTime: Number.POSITIVE_INFINITY, 64 63 }); ··· 70 69 queryOptions({ 71 70 queryKey: ["cards", "metadata"] as const, 72 71 queryFn: async () => { 73 - await initializeWorker(); 74 - const worker = getCardsWorker(); 75 - return worker.getMetadata(); 72 + const provider = await getCardDataProvider(); 73 + return provider.getMetadata(); 76 74 }, 77 75 staleTime: Number.POSITIVE_INFINITY, 78 76 }); ··· 84 82 queryOptions({ 85 83 queryKey: ["cards", "canonical", oracleId] as const, 86 84 queryFn: async (): Promise<ScryfallId | null> => { 87 - await initializeWorker(); 88 - const worker = getCardsWorker(); 89 - const result = await worker.getCanonicalPrinting(oracleId); 85 + const provider = await getCardDataProvider(); 86 + const result = await provider.getCanonicalPrinting(oracleId); 90 87 return result ?? null; 91 88 }, 92 89 staleTime: Number.POSITIVE_INFINITY,
+10
src/routes/card/$id.tsx
··· 8 8 import { isScryfallId } from "@/lib/scryfall-types"; 9 9 10 10 export const Route = createFileRoute("/card/$id")({ 11 + loader: async ({ context, params }) => { 12 + // Validate ID format 13 + if (!isScryfallId(params.id)) { 14 + return null; 15 + } 16 + 17 + // Prefetch card data during SSR 18 + const queryOptions = getCardWithPrintingsQueryOptions(params.id); 19 + await context.queryClient.ensureQueryData(queryOptions); 20 + }, 11 21 component: CardDetailPage, 12 22 }); 13 23
+3 -1
vitest.config.ts
··· 1 - import { defineConfig } from "vitest/config"; 1 + import { configDefaults, defineConfig } from "vitest/config"; 2 2 import viteReact from "@vitejs/plugin-react"; 3 3 import viteTsConfigPaths from "vite-tsconfig-paths"; 4 4 ··· 12 12 test: { 13 13 environment: "jsdom", 14 14 globals: true, 15 + setupFiles: ["@vitest/web-worker"], 16 + exclude: [...configDefaults.exclude, '**/.direnv/**'] 15 17 }, 16 18 });