scrabble-solver 2.16.7 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/.nx/workspace-data/d/server-process.json +2 -2
  2. package/.nx/workspace-data/file-map.json +548 -572
  3. package/.nx/workspace-data/lockfile-dependencies.hash +1 -1
  4. package/.nx/workspace-data/lockfile-nodes.hash +1 -1
  5. package/.nx/workspace-data/nx_files.nxt +0 -0
  6. package/.nx/workspace-data/parsed-lock-file.dependencies.json +185 -160
  7. package/.nx/workspace-data/parsed-lock-file.nodes.json +406 -370
  8. package/.nx/workspace-data/project-graph.json +656 -552
  9. package/.nx/workspace-data/source-maps.json +96 -0
  10. package/.oxlintrc.json +6 -0
  11. package/CLAUDE.md +22 -17
  12. package/README.md +9 -0
  13. package/bun.lock +107 -133
  14. package/cypress/e2e/app.spec.cy.ts +4 -4
  15. package/cypress/e2e/features/solver-paths.ts +66 -0
  16. package/cypress/e2e/features.spec.cy.ts +1 -0
  17. package/cypress/support/lib/actions.ts +28 -9
  18. package/lerna.json +1 -1
  19. package/package.json +8 -7
  20. package/packages/configs/package.json +3 -3
  21. package/packages/constants/package.json +1 -1
  22. package/packages/dictionaries/README.md +1 -1
  23. package/packages/dictionaries/build/Dictionaries.d.ts +2 -2
  24. package/packages/dictionaries/build/Dictionaries.js +6 -6
  25. package/packages/dictionaries/build/lib/DiskCache.d.ts +11 -4
  26. package/packages/dictionaries/build/lib/DiskCache.js +27 -10
  27. package/packages/dictionaries/build/lib/LayeredCache.d.ts +4 -4
  28. package/packages/dictionaries/build/lib/LayeredCache.js +3 -3
  29. package/packages/dictionaries/build/lib/MemoryCache.d.ts +4 -4
  30. package/packages/dictionaries/build/lib/MemoryCache.js +2 -2
  31. package/packages/dictionaries/build/lib/createCacheTimestampComparator.d.ts +2 -2
  32. package/packages/dictionaries/build/lib/downloadDictionary.d.ts +2 -2
  33. package/packages/dictionaries/build/lib/downloadDictionary.js +4 -3
  34. package/packages/dictionaries/build/lib/getDictionaryFilepath.d.ts +3 -1
  35. package/packages/dictionaries/build/lib/getDictionaryFilepath.js +8 -3
  36. package/packages/dictionaries/package.json +7 -6
  37. package/packages/dictionaries/src/Dictionaries.ts +12 -12
  38. package/packages/dictionaries/src/lib/DiskCache.test.ts +85 -0
  39. package/packages/dictionaries/src/lib/DiskCache.ts +34 -14
  40. package/packages/dictionaries/src/lib/LayeredCache.ts +7 -7
  41. package/packages/dictionaries/src/lib/MemoryCache.ts +6 -6
  42. package/packages/dictionaries/src/lib/createCacheTimestampComparator.ts +2 -2
  43. package/packages/dictionaries/src/lib/downloadDictionary.ts +5 -4
  44. package/packages/dictionaries/src/lib/getDictionaryFilepath.ts +7 -2
  45. package/packages/gaddag/build/Gaddag.d.ts +39 -0
  46. package/packages/gaddag/build/Gaddag.js +128 -0
  47. package/packages/gaddag/build/buildGaddag.d.ts +10 -0
  48. package/packages/gaddag/build/buildGaddag.js +360 -0
  49. package/packages/gaddag/build/constants.d.ts +12 -0
  50. package/packages/gaddag/build/constants.js +15 -0
  51. package/packages/gaddag/build/index.d.ts +3 -0
  52. package/packages/gaddag/build/index.js +13 -0
  53. package/packages/logger/package.json +1 -1
  54. package/packages/scrabble-solver/.next/BUILD_ID +1 -1
  55. package/packages/scrabble-solver/.next/build-manifest.json +13 -13
  56. package/packages/scrabble-solver/.next/cache/.previewinfo +1 -1
  57. package/packages/scrabble-solver/.next/cache/.rscinfo +1 -1
  58. package/packages/scrabble-solver/.next/cache/.tsbuildinfo +1 -1
  59. package/packages/scrabble-solver/.next/cache/webpack/client-production/0.pack +0 -0
  60. package/packages/scrabble-solver/.next/cache/webpack/client-production/index.pack +0 -0
  61. package/packages/scrabble-solver/.next/cache/webpack/client-production/index.pack.old +0 -0
  62. package/packages/scrabble-solver/.next/cache/webpack/edge-server-production/index.pack +0 -0
  63. package/packages/scrabble-solver/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
  64. package/packages/scrabble-solver/.next/cache/webpack/server-production/0.pack +0 -0
  65. package/packages/scrabble-solver/.next/cache/webpack/server-production/index.pack +0 -0
  66. package/packages/scrabble-solver/.next/cache/webpack/server-production/index.pack.old +0 -0
  67. package/packages/scrabble-solver/.next/diagnostics/framework.json +1 -1
  68. package/packages/scrabble-solver/.next/next-server.js.nft.json +1 -1
  69. package/packages/scrabble-solver/.next/prerender-manifest.json +4 -4
  70. package/packages/scrabble-solver/.next/required-server-files.js +3 -2
  71. package/packages/scrabble-solver/.next/required-server-files.json +3 -2
  72. package/packages/scrabble-solver/.next/routes-manifest.json +1 -1
  73. package/packages/scrabble-solver/.next/server/chunks/129.js +1 -0
  74. package/packages/scrabble-solver/.next/server/chunks/30.js +1 -1
  75. package/packages/scrabble-solver/.next/server/chunks/392.js +1 -1
  76. package/packages/scrabble-solver/.next/server/chunks/671.js +1 -1
  77. package/packages/scrabble-solver/.next/server/chunks/712.js +1 -1
  78. package/packages/scrabble-solver/.next/server/chunks/785.js +1 -0
  79. package/packages/scrabble-solver/.next/server/chunks/791.js +1 -1
  80. package/packages/scrabble-solver/.next/server/middleware-build-manifest.js +1 -1
  81. package/packages/scrabble-solver/.next/server/pages/404.html +1 -1
  82. package/packages/scrabble-solver/.next/server/pages/500.html +1 -1
  83. package/packages/scrabble-solver/.next/server/pages/_app.js +1 -1
  84. package/packages/scrabble-solver/.next/server/pages/_app.js.nft.json +1 -1
  85. package/packages/scrabble-solver/.next/server/pages/_error.js +1 -1
  86. package/packages/scrabble-solver/.next/server/pages/_error.js.nft.json +1 -1
  87. package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
  88. package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  89. package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale].js +1 -1
  90. package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
  91. package/packages/scrabble-solver/.next/server/pages/api/solve.js +1 -1
  92. package/packages/scrabble-solver/.next/server/pages/api/solve.js.nft.json +1 -1
  93. package/packages/scrabble-solver/.next/server/pages/api/verify.js +1 -1
  94. package/packages/scrabble-solver/.next/server/pages/api/verify.js.nft.json +1 -1
  95. package/packages/scrabble-solver/.next/server/pages/index.html +1 -1
  96. package/packages/scrabble-solver/.next/server/pages/index.js +1 -1
  97. package/packages/scrabble-solver/.next/server/pages/index.js.nft.json +1 -1
  98. package/packages/scrabble-solver/.next/server/pages/index.json +1 -1
  99. package/packages/scrabble-solver/.next/server/pages/not-found.html +1 -1
  100. package/packages/scrabble-solver/.next/server/pages/not-found.js.nft.json +1 -1
  101. package/packages/scrabble-solver/.next/server/pages-manifest.json +1 -1
  102. package/packages/scrabble-solver/.next/server/webpack-runtime.js +1 -1
  103. package/packages/scrabble-solver/.next/static/{vYpMmuC_yEurZGcyGVnLf → OLE8_ZInlGH7ENU0NIOpf}/_buildManifest.js +1 -1
  104. package/packages/scrabble-solver/.next/static/chunks/583.7a46807be14c377e.js +1 -0
  105. package/packages/scrabble-solver/.next/static/chunks/830.661816bf0de8c2f1.js +1 -0
  106. package/packages/scrabble-solver/.next/static/chunks/{main-852475ca502b4e49.js → main-5b999f3f24ebfa84.js} +2 -2
  107. package/packages/scrabble-solver/.next/static/chunks/pages/_app-7024e7a27ffbb2fa.js +7 -0
  108. package/packages/scrabble-solver/.next/static/chunks/pages/{index-ec0f6aeeaf7be227.js → index-8b398b0a5d870773.js} +1 -1
  109. package/packages/scrabble-solver/.next/static/chunks/webpack-3943d624bbb47a64.js +1 -0
  110. package/packages/scrabble-solver/.next/static/css/aedeaada218d7280.css +2 -0
  111. package/packages/scrabble-solver/.next/trace +21 -21
  112. package/packages/scrabble-solver/.next/trace-build +1 -1
  113. package/packages/scrabble-solver/next.config.js +0 -6
  114. package/packages/scrabble-solver/package.json +10 -10
  115. package/packages/scrabble-solver/public/service-worker.js +2 -2
  116. package/packages/scrabble-solver/src/components/Board/components/Cell/Cell.tsx +4 -1
  117. package/packages/scrabble-solver/src/components/Board/hooks/useGrid.ts +17 -6
  118. package/packages/scrabble-solver/src/components/Dictionary/Dictionary.module.scss +7 -22
  119. package/packages/scrabble-solver/src/components/Dictionary/Dictionary.tsx +1 -8
  120. package/packages/scrabble-solver/src/components/Results/Result.tsx +0 -3
  121. package/packages/scrabble-solver/src/components/Results/Results.module.scss +0 -1
  122. package/packages/scrabble-solver/src/components/Results/Results.tsx +10 -2
  123. package/packages/scrabble-solver/src/components/Results/types.ts +3 -3
  124. package/packages/scrabble-solver/src/components/Solver/Solver.tsx +4 -3
  125. package/packages/scrabble-solver/src/components/Tooltip/useTooltip.ts +1 -1
  126. package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/[word].ts +2 -2
  127. package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/index.ts +80 -3
  128. package/packages/scrabble-solver/src/pages/api/solve.ts +2 -2
  129. package/packages/scrabble-solver/src/pages/api/verify.ts +12 -3
  130. package/packages/scrabble-solver/src/sdk/solve.ts +13 -7
  131. package/packages/scrabble-solver/src/sdk/verify.ts +12 -6
  132. package/packages/scrabble-solver/src/service-worker/appShell.test.ts +86 -0
  133. package/packages/scrabble-solver/src/service-worker/appShell.ts +39 -0
  134. package/packages/scrabble-solver/src/service-worker/index.ts +4 -5
  135. package/packages/scrabble-solver/src/serviceWorkerManager.ts +1 -18
  136. package/packages/scrabble-solver/src/solver-worker/client.test.ts +151 -0
  137. package/packages/scrabble-solver/src/solver-worker/client.ts +106 -0
  138. package/packages/scrabble-solver/src/solver-worker/dictionaries/constants.ts +9 -0
  139. package/packages/scrabble-solver/src/solver-worker/dictionaries/getDictionary.ts +21 -0
  140. package/packages/scrabble-solver/src/solver-worker/dictionaries/index.ts +2 -0
  141. package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidateDictionary.test.ts +199 -0
  142. package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidateDictionary.ts +86 -0
  143. package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidatedAt.ts +43 -0
  144. package/packages/scrabble-solver/src/solver-worker/getGaddag.test.ts +109 -0
  145. package/packages/scrabble-solver/src/solver-worker/getGaddag.ts +62 -0
  146. package/packages/scrabble-solver/src/solver-worker/index.ts +1 -0
  147. package/packages/scrabble-solver/src/solver-worker/index.worker.ts +124 -0
  148. package/packages/scrabble-solver/src/solver-worker/messages.ts +20 -0
  149. package/packages/scrabble-solver/src/state/index.ts +1 -0
  150. package/packages/scrabble-solver/src/state/sagas.ts +19 -9
  151. package/packages/scrabble-solver/src/state/useTypedStore.ts +6 -0
  152. package/packages/scrabble-solver/src/types/api.ts +1 -2
  153. package/packages/scrabble-solver/tsconfig.json +4 -0
  154. package/packages/scrabble-solver/tsconfig.tsbuildinfo +1 -1
  155. package/packages/solver/README.md +24 -0
  156. package/packages/solver/benchmarks/index.ts +15 -0
  157. package/packages/solver/benchmarks/lib.ts +31 -0
  158. package/packages/solver/benchmarks/renderChart.ts +165 -0
  159. package/packages/solver/benchmarks/results/chart.svg +65 -0
  160. package/packages/solver/benchmarks/runBenchmarks.ts +66 -0
  161. package/packages/solver/benchmarks/scenarios.ts +64 -0
  162. package/packages/solver/benchmarks/types.ts +16 -0
  163. package/packages/solver/benchmarks/updateReadme.ts +53 -0
  164. package/packages/solver/build/MoveGenerator.d.ts +116 -0
  165. package/packages/solver/build/MoveGenerator.js +686 -0
  166. package/packages/solver/build/index.d.ts +0 -13
  167. package/packages/solver/build/index.js +1 -27
  168. package/packages/solver/build/solve.d.ts +2 -2
  169. package/packages/solver/build/solve.js +3 -19
  170. package/packages/solver/package.json +7 -6
  171. package/packages/solver/src/MoveGenerator.test.ts +1009 -0
  172. package/packages/solver/src/MoveGenerator.ts +926 -0
  173. package/packages/solver/src/ReferenceSolver.ts +401 -0
  174. package/packages/solver/src/index.ts +0 -13
  175. package/packages/solver/src/solve.test.ts +21 -21
  176. package/packages/solver/src/solve.ts +4 -22
  177. package/packages/solver/tsconfig.json +1 -1
  178. package/packages/types/build/Cell.d.ts +1 -0
  179. package/packages/types/build/Cell.js +3 -0
  180. package/packages/types/build/Result.d.ts +3 -2
  181. package/packages/types/build/Result.js +11 -6
  182. package/packages/types/build/ResultJson.d.ts +5 -4
  183. package/packages/types/build/index.d.ts +0 -6
  184. package/packages/types/build/index.js +1 -9
  185. package/packages/types/build/readResultCells.d.ts +5 -0
  186. package/packages/types/build/readResultCells.js +75 -0
  187. package/packages/types/package.json +4 -3
  188. package/packages/types/src/Cell.ts +4 -0
  189. package/packages/types/src/Result.ts +15 -6
  190. package/packages/types/src/ResultJson.ts +5 -5
  191. package/packages/types/src/index.ts +0 -6
  192. package/packages/types/src/readResultCells.test.ts +140 -0
  193. package/packages/types/src/readResultCells.ts +90 -0
  194. package/packages/word-definitions/package.json +2 -2
  195. package/packages/word-lists/package.json +2 -2
  196. package/packages/scrabble-solver/.next/static/chunks/pages/_app-a5bbe02710f13d05.js +0 -7
  197. package/packages/scrabble-solver/.next/static/chunks/webpack-1a7a166b34ed8d24.js +0 -1
  198. package/packages/scrabble-solver/.next/static/css/0af35adb4bab56f0.css +0 -2
  199. package/packages/scrabble-solver/src/sdk/getDictionary.ts +0 -9
  200. package/packages/scrabble-solver/src/service-worker/average.ts +0 -7
  201. package/packages/scrabble-solver/src/service-worker/dictionaries/constants.ts +0 -5
  202. package/packages/scrabble-solver/src/service-worker/dictionaries/getDictionary.ts +0 -20
  203. package/packages/scrabble-solver/src/service-worker/dictionaries/index.ts +0 -2
  204. package/packages/scrabble-solver/src/service-worker/dictionaries/revalidateDictionary.ts +0 -33
  205. package/packages/scrabble-solver/src/service-worker/getTrie.ts +0 -24
  206. package/packages/scrabble-solver/src/service-worker/routeSolveRequests.ts +0 -91
  207. package/packages/scrabble-solver/src/service-worker/routeVerifyRequests.ts +0 -37
  208. package/packages/solver/build/areDigraphsValid.d.ts +0 -2
  209. package/packages/solver/build/areDigraphsValid.js +0 -17
  210. package/packages/solver/build/fillPattern.d.ts +0 -6
  211. package/packages/solver/build/fillPattern.js +0 -52
  212. package/packages/solver/build/generateEndIndices.d.ts +0 -2
  213. package/packages/solver/build/generateEndIndices.js +0 -17
  214. package/packages/solver/build/generateHorizontalPatterns.d.ts +0 -2
  215. package/packages/solver/build/generateHorizontalPatterns.js +0 -16
  216. package/packages/solver/build/generatePattern.d.ts +0 -7
  217. package/packages/solver/build/generatePattern.js +0 -20
  218. package/packages/solver/build/generatePatterns.d.ts +0 -2
  219. package/packages/solver/build/generatePatterns.js +0 -11
  220. package/packages/solver/build/generateStartIndices.d.ts +0 -2
  221. package/packages/solver/build/generateStartIndices.js +0 -16
  222. package/packages/solver/build/generateVectors.d.ts +0 -7
  223. package/packages/solver/build/generateVectors.js +0 -7
  224. package/packages/solver/build/generateVerticalPatterns.d.ts +0 -2
  225. package/packages/solver/build/generateVerticalPatterns.js +0 -16
  226. package/packages/solver/build/getCellsScore.d.ts +0 -2
  227. package/packages/solver/build/getCellsScore.js +0 -18
  228. package/packages/solver/build/getPatternHash.d.ts +0 -2
  229. package/packages/solver/build/getPatternHash.js +0 -14
  230. package/packages/solver/build/getPatternScore.d.ts +0 -2
  231. package/packages/solver/build/getPatternScore.js +0 -22
  232. package/packages/solver/build/getUniquePatterns.d.ts +0 -2
  233. package/packages/solver/build/getUniquePatterns.js +0 -17
  234. package/packages/solver/src/areDigraphsValid.ts +0 -18
  235. package/packages/solver/src/fillPattern.test.ts +0 -107
  236. package/packages/solver/src/fillPattern.ts +0 -68
  237. package/packages/solver/src/generateEndIndices.test.ts +0 -23
  238. package/packages/solver/src/generateEndIndices.ts +0 -19
  239. package/packages/solver/src/generateHorizontalPatterns.test.ts +0 -31
  240. package/packages/solver/src/generateHorizontalPatterns.ts +0 -15
  241. package/packages/solver/src/generatePattern.ts +0 -33
  242. package/packages/solver/src/generatePatterns.test.ts +0 -13
  243. package/packages/solver/src/generatePatterns.ts +0 -11
  244. package/packages/solver/src/generateStartIndices.test.ts +0 -19
  245. package/packages/solver/src/generateStartIndices.ts +0 -17
  246. package/packages/solver/src/generateVectors.test.ts +0 -12
  247. package/packages/solver/src/generateVectors.ts +0 -10
  248. package/packages/solver/src/generateVerticalPatterns.test.ts +0 -32
  249. package/packages/solver/src/generateVerticalPatterns.ts +0 -15
  250. package/packages/solver/src/getCellsScore.ts +0 -21
  251. package/packages/solver/src/getPatternHash.test.ts +0 -17
  252. package/packages/solver/src/getPatternHash.ts +0 -12
  253. package/packages/solver/src/getPatternScore.test.ts +0 -204
  254. package/packages/solver/src/getPatternScore.ts +0 -23
  255. package/packages/solver/src/getUniquePatterns.ts +0 -19
  256. package/packages/types/build/CollisionJson.d.ts +0 -2
  257. package/packages/types/build/CollisionJson.js +0 -2
  258. package/packages/types/build/FinalPattern.d.ts +0 -6
  259. package/packages/types/build/FinalPattern.js +0 -14
  260. package/packages/types/build/HorizontalPattern.d.ts +0 -5
  261. package/packages/types/build/HorizontalPattern.js +0 -34
  262. package/packages/types/build/Pattern.d.ts +0 -20
  263. package/packages/types/build/Pattern.js +0 -50
  264. package/packages/types/build/PatternJson.d.ts +0 -6
  265. package/packages/types/build/PatternJson.js +0 -2
  266. package/packages/types/build/VerticalPattern.d.ts +0 -5
  267. package/packages/types/build/VerticalPattern.js +0 -34
  268. package/packages/types/src/CollisionJson.ts +0 -3
  269. package/packages/types/src/FinalPattern.ts +0 -14
  270. package/packages/types/src/HorizontalPattern.ts +0 -37
  271. package/packages/types/src/Pattern.ts +0 -71
  272. package/packages/types/src/PatternJson.ts +0 -7
  273. package/packages/types/src/VerticalPattern.ts +0 -37
  274. /package/packages/scrabble-solver/.next/static/{vYpMmuC_yEurZGcyGVnLf → OLE8_ZInlGH7ENU0NIOpf}/_ssgManifest.js +0 -0
  275. /package/packages/scrabble-solver/src/{service-worker → solver-worker}/dictionaries/expirationManager.ts +0 -0
  276. /package/packages/scrabble-solver/src/{service-worker → solver-worker}/dictionaries/getDictionaryUrl.ts +0 -0
@@ -0,0 +1,106 @@
1
+ import { type Locale, type ResultJson } from '@scrabble-solver/types';
2
+
3
+ import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
4
+
5
+ import { type SolverWorkerRequest, type SolverWorkerResponse, type VerifyResult } from './messages';
6
+
7
+ /**
8
+ * A worker that never answers - killed under memory pressure, or a message lost
9
+ * with it - must not hold the UI in a loading state with no server fallback.
10
+ */
11
+ const REQUEST_TIMEOUT = 30 * 1000;
12
+
13
+ interface PendingRequest {
14
+ resolve: (response: SolverWorkerResponse) => void;
15
+ timeout: ReturnType<typeof setTimeout>;
16
+ }
17
+
18
+ let worker: Worker | undefined;
19
+ let nextId = 0;
20
+ let latestSolveId = 0;
21
+ let latestSolve: Promise<ResultJson[] | undefined> = Promise.resolve(undefined);
22
+ const pending = new Map<number, PendingRequest>();
23
+
24
+ export function solveLocally(payload: SolveRequestPayload): Promise<ResultJson[] | undefined> {
25
+ const id = ++nextId;
26
+ const solve = readSolveResponse(id, payload);
27
+ latestSolveId = id;
28
+ latestSolve = solve;
29
+ return solve;
30
+ }
31
+
32
+ export async function verifyLocally(payload: VerifyRequestPayload): Promise<VerifyResult | undefined> {
33
+ const response = await request({ id: ++nextId, type: 'verify', payload });
34
+ return response.outcome === 'answered' ? (response.data as VerifyResult) : undefined;
35
+ }
36
+
37
+ export function prefetchDictionary(locale: Locale): void {
38
+ getWorker()?.postMessage({ id: ++nextId, locale, type: 'prefetch' } satisfies SolverWorkerRequest);
39
+ }
40
+
41
+ async function readSolveResponse(id: number, payload: SolveRequestPayload): Promise<ResultJson[] | undefined> {
42
+ const response = await request({ id, type: 'solve', payload });
43
+
44
+ /**
45
+ * The worker skipped this solve for a newer one; answer with the newer result
46
+ * rather than an empty list a caller could render as "no moves found".
47
+ */
48
+ if (response.outcome === 'superseded' && id !== latestSolveId) {
49
+ return latestSolve;
50
+ }
51
+
52
+ return response.outcome === 'answered' ? (response.data as ResultJson[]) : undefined;
53
+ }
54
+
55
+ function request(message: SolverWorkerRequest): Promise<SolverWorkerResponse> {
56
+ const targetWorker = getWorker();
57
+
58
+ if (!targetWorker) {
59
+ return Promise.resolve({ id: message.id, outcome: 'unavailable' });
60
+ }
61
+
62
+ return new Promise((resolve) => {
63
+ const timeout = setTimeout(() => settle({ id: message.id, outcome: 'unavailable' }), REQUEST_TIMEOUT);
64
+ pending.set(message.id, { resolve, timeout });
65
+ targetWorker.postMessage(message);
66
+ });
67
+ }
68
+
69
+ function settle(response: SolverWorkerResponse): void {
70
+ const entry = pending.get(response.id);
71
+
72
+ if (!entry) {
73
+ return;
74
+ }
75
+
76
+ pending.delete(response.id);
77
+ clearTimeout(entry.timeout);
78
+ entry.resolve(response);
79
+ }
80
+
81
+ function getWorker(): Worker | undefined {
82
+ if (typeof Worker === 'undefined' || !('caches' in globalThis)) {
83
+ return undefined;
84
+ }
85
+
86
+ if (!worker) {
87
+ worker = new Worker(new URL('./index.worker.ts', import.meta.url));
88
+ worker.addEventListener('message', ({ data }: MessageEvent<SolverWorkerResponse>) => settle(data));
89
+ worker.addEventListener('error', handleWorkerFailure);
90
+ }
91
+
92
+ return worker;
93
+ }
94
+
95
+ /**
96
+ * Settles every in-flight request as locally unanswerable - callers fall back
97
+ * to the server - and discards the worker so the next call starts a fresh one.
98
+ */
99
+ function handleWorkerFailure(): void {
100
+ for (const id of pending.keys()) {
101
+ settle({ id, outcome: 'unavailable' });
102
+ }
103
+
104
+ worker?.terminate();
105
+ worker = undefined;
106
+ }
@@ -0,0 +1,9 @@
1
+ export const DICTIONARY_CACHE = 'dictionary-api-cache';
2
+
3
+ export const REVALIDATED_AT_CACHE = 'dictionary-revalidated-at';
4
+
5
+ export const DAY = 24 * 60 * 60 * 1000;
6
+
7
+ // Staleness is handled by conditional revalidation; expiration only cleans up
8
+ // dictionaries of locales the user no longer plays.
9
+ export const DICTIONARY_CACHE_MAX_AGE = 30 * DAY;
@@ -0,0 +1,21 @@
1
+ import { type Locale } from '@scrabble-solver/types';
2
+
3
+ import { DICTIONARY_CACHE } from './constants';
4
+ import { getDictionaryUrl } from './getDictionaryUrl';
5
+ import { resetRevalidationThrottle } from './revalidateDictionary';
6
+
7
+ export async function getDictionary(locale: Locale): Promise<Response | undefined> {
8
+ const cache = await caches.open(DICTIONARY_CACHE);
9
+ return cache.match(getDictionaryUrl(locale));
10
+ }
11
+
12
+ export async function deleteDictionary(locale: Locale): Promise<void> {
13
+ const cache = await caches.open(DICTIONARY_CACHE);
14
+ await cache.delete(getDictionaryUrl(locale));
15
+ /**
16
+ * Reset last: resetting first leaves a window in which a revalidation starts,
17
+ * reads the doomed entry's ETag, takes the 304 it answers with, and arms the
18
+ * throttle against a dictionary this call is about to delete.
19
+ */
20
+ await resetRevalidationThrottle(locale);
21
+ }
@@ -0,0 +1,2 @@
1
+ export { deleteDictionary, getDictionary } from './getDictionary';
2
+ export { revalidateDictionary } from './revalidateDictionary';
@@ -0,0 +1,199 @@
1
+ import { Locale } from '@scrabble-solver/types';
2
+ import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test';
3
+
4
+ import { DICTIONARY_CACHE, REVALIDATED_AT_CACHE } from './constants';
5
+ import type * as getDictionaryModule from './getDictionary';
6
+ import { getDictionaryUrl } from './getDictionaryUrl';
7
+ import type * as revalidateDictionaryModule from './revalidateDictionary';
8
+
9
+ const HOUR = 60 * 60 * 1000;
10
+
11
+ const fetchCalls: { cache: RequestCache | undefined; ifNoneMatch: string | undefined; url: string }[] = [];
12
+ const updatedTimestamps: string[] = [];
13
+ let respond: () => Promise<Response> = () => Promise.resolve(notModified());
14
+
15
+ await mock.module('./expirationManager', () => ({
16
+ expirationManager: {
17
+ expireEntries: () => Promise.resolve(),
18
+ updateTimestamp: (url: string) => {
19
+ updatedTimestamps.push(url);
20
+ return Promise.resolve();
21
+ },
22
+ },
23
+ }));
24
+
25
+ const globals = { caches: globalThis.caches, fetch: globalThis.fetch };
26
+
27
+ Object.assign(globalThis, {
28
+ caches: createCacheStorage(),
29
+ fetch: (url: string, { cache, headers }: RequestInit) => {
30
+ fetchCalls.push({ cache, ifNoneMatch: (headers as Record<string, string>)['If-None-Match'], url });
31
+ return respond();
32
+ },
33
+ });
34
+
35
+ afterAll(() => {
36
+ Object.assign(globalThis, globals);
37
+ });
38
+
39
+ const { resetRevalidationThrottle, revalidateDictionary }: typeof revalidateDictionaryModule =
40
+ await import('./revalidateDictionary');
41
+ const { deleteDictionary }: typeof getDictionaryModule = await import('./getDictionary');
42
+
43
+ describe('revalidateDictionary', () => {
44
+ beforeEach(() => {
45
+ fetchCalls.length = 0;
46
+ updatedTimestamps.length = 0;
47
+ respond = () => Promise.resolve(notModified());
48
+ });
49
+
50
+ /**
51
+ * `no-store` would look equivalent, but makes the browser send
52
+ * `Cache-Control: no-cache`, which Next.js answers with the whole body.
53
+ */
54
+ it('sends a conditional request the HTTP cache cannot answer', async () => {
55
+ const locale = await unthrottled(Locale.EN_US);
56
+ await cacheDictionary(locale, 'dictionary', '"v1"');
57
+ await revalidateDictionary(locale);
58
+ expect(fetchCalls).toEqual([{ cache: 'no-cache', ifNoneMatch: '"v1"', url: '/api/dictionary/en-US' }]);
59
+ });
60
+
61
+ it('keeps the cached body on 304', async () => {
62
+ const locale = await unthrottled(Locale.EN_GB);
63
+ await cacheDictionary(locale, 'dictionary', '"v1"');
64
+ await revalidateDictionary(locale);
65
+ expect(await readCachedDictionary(locale)).toBe('dictionary');
66
+ expect(updatedTimestamps).toEqual([getDictionaryUrl(locale)]);
67
+ });
68
+
69
+ it('replaces the cached body on 200', async () => {
70
+ const locale = await unthrottled(Locale.DE_DE);
71
+ await cacheDictionary(locale, 'old', '"v1"');
72
+ respond = () => Promise.resolve(new Response('new', { headers: { etag: '"v2"' } }));
73
+ await revalidateDictionary(locale);
74
+ expect(await readCachedDictionary(locale)).toBe('new');
75
+ expect(updatedTimestamps).toEqual([getDictionaryUrl(locale)]);
76
+ });
77
+
78
+ it('omits If-None-Match when nothing is cached', async () => {
79
+ const locale = await unthrottled(Locale.ES_ES);
80
+ respond = () => Promise.resolve(new Response('dictionary'));
81
+ await revalidateDictionary(locale);
82
+ expect(fetchCalls[0].ifNoneMatch).toBeUndefined();
83
+ expect(await readCachedDictionary(locale)).toBe('dictionary');
84
+ });
85
+
86
+ it('rejects a failed response without arming the throttle', async () => {
87
+ const locale = await unthrottled(Locale.FA_IR);
88
+ respond = () => Promise.resolve(new Response(null, { status: 503, statusText: 'Service Unavailable' }));
89
+ const failure = await revalidateDictionary(locale).catch((error: Error) => error.message);
90
+ expect(failure).toBe('HTTP 503: Service Unavailable');
91
+ respond = () => Promise.resolve(notModified());
92
+ await revalidateDictionary(locale);
93
+ expect(fetchCalls).toHaveLength(2);
94
+ });
95
+
96
+ it('revalidates at most once per interval', async () => {
97
+ const locale = await unthrottled(Locale.FR_FR);
98
+ await revalidateDictionary(locale);
99
+ await revalidateDictionary(locale);
100
+ expect(fetchCalls).toHaveLength(1);
101
+ });
102
+
103
+ it('shares one request between concurrent callers', async () => {
104
+ const locale = await unthrottled(Locale.EN_US);
105
+ await Promise.all([revalidateDictionary(locale), revalidateDictionary(locale), revalidateDictionary(locale)]);
106
+ expect(fetchCalls).toHaveLength(1);
107
+ });
108
+
109
+ it('persists the throttle, so a later page load can honour it', async () => {
110
+ const locale = await unthrottled(Locale.PL_PL);
111
+ await revalidateDictionary(locale);
112
+ expect(await readPersistedRevalidatedAt(locale)).toBeGreaterThan(Date.now() - HOUR);
113
+ });
114
+
115
+ /**
116
+ * This locale and the next are used nowhere else: the worker is created per
117
+ * page, so only a locale unseen in memory exercises a fresh page's read.
118
+ */
119
+ it('honours a throttle persisted by an earlier page load', async () => {
120
+ await persistRevalidatedAt(Locale.RO_RO, Date.now() - 1000);
121
+ await revalidateDictionary(Locale.RO_RO);
122
+ expect(fetchCalls).toHaveLength(0);
123
+ });
124
+
125
+ it('revalidates when a throttle persisted by an earlier page load has expired', async () => {
126
+ await persistRevalidatedAt(Locale.TR_TR, Date.now() - 2 * HOUR);
127
+ await revalidateDictionary(Locale.TR_TR);
128
+ expect(fetchCalls).toHaveLength(1);
129
+ });
130
+
131
+ it('lets the next access revalidate after the dictionary is deleted', async () => {
132
+ const locale = await unthrottled(Locale.DE_DE);
133
+ await revalidateDictionary(locale);
134
+ await revalidateDictionary(locale);
135
+ await deleteDictionary(locale);
136
+ await revalidateDictionary(locale);
137
+ expect(fetchCalls).toHaveLength(2);
138
+ });
139
+
140
+ it('does not arm the throttle when the dictionary is deleted mid-revalidation', async () => {
141
+ const locale = await unthrottled(Locale.EN_GB);
142
+ let release: (response: Response) => void = () => undefined;
143
+ respond = () => new Promise((resolve) => (release = resolve));
144
+ const revalidation = revalidateDictionary(locale);
145
+ await new Promise((resolve) => setTimeout(resolve, 0));
146
+ await resetRevalidationThrottle(locale);
147
+ release(notModified());
148
+ await revalidation;
149
+ respond = () => Promise.resolve(notModified());
150
+ await revalidateDictionary(locale);
151
+ expect(fetchCalls).toHaveLength(2);
152
+ });
153
+ });
154
+
155
+ /** Throttle state outlives a test, so tests sharing a locale start it un-throttled. */
156
+ async function unthrottled(locale: Locale): Promise<Locale> {
157
+ await resetRevalidationThrottle(locale);
158
+ return locale;
159
+ }
160
+
161
+ async function cacheDictionary(locale: Locale, body: string, etag: string): Promise<void> {
162
+ const cache = await caches.open(DICTIONARY_CACHE);
163
+ await cache.put(getDictionaryUrl(locale), new Response(body, { headers: { etag } }));
164
+ }
165
+
166
+ async function readCachedDictionary(locale: Locale): Promise<string | undefined> {
167
+ const cache = await caches.open(DICTIONARY_CACHE);
168
+ return (await cache.match(getDictionaryUrl(locale)))?.text();
169
+ }
170
+
171
+ async function persistRevalidatedAt(locale: Locale, timestamp: number): Promise<void> {
172
+ const cache = await caches.open(REVALIDATED_AT_CACHE);
173
+ await cache.put(getDictionaryUrl(locale), new Response(String(timestamp)));
174
+ }
175
+
176
+ async function readPersistedRevalidatedAt(locale: Locale): Promise<number> {
177
+ const cache = await caches.open(REVALIDATED_AT_CACHE);
178
+ return Number(await (await cache.match(getDictionaryUrl(locale)))?.text());
179
+ }
180
+
181
+ function notModified(): Response {
182
+ return new Response(null, { status: 304 });
183
+ }
184
+
185
+ function createCacheStorage(): CacheStorage {
186
+ const entries = new Map<string, Response>();
187
+
188
+ return {
189
+ open: (name: string) =>
190
+ Promise.resolve({
191
+ delete: (url: string) => Promise.resolve(entries.delete(`${name} ${url}`)),
192
+ match: (url: string) => Promise.resolve(entries.get(`${name} ${url}`)?.clone()),
193
+ put: (url: string, response: Response) => {
194
+ entries.set(`${name} ${url}`, response);
195
+ return Promise.resolve();
196
+ },
197
+ }),
198
+ } as unknown as CacheStorage;
199
+ }
@@ -0,0 +1,86 @@
1
+ import { type Locale } from '@scrabble-solver/types';
2
+
3
+ import { DICTIONARY_CACHE } from './constants';
4
+ import { expirationManager } from './expirationManager';
5
+ import { getDictionaryUrl } from './getDictionaryUrl';
6
+ import { clearRevalidatedAt, readRevalidatedAt, writeRevalidatedAt } from './revalidatedAt';
7
+
8
+ const REVALIDATION_INTERVAL = 60 * 60 * 1000;
9
+
10
+ const requests: Partial<Record<Locale, Promise<void> | undefined>> = {};
11
+ const resetWhileRevalidating = new Set<Locale>();
12
+
13
+ export function revalidateDictionary(locale: Locale): Promise<void> {
14
+ const inFlight = requests[locale];
15
+
16
+ if (inFlight) {
17
+ return inFlight;
18
+ }
19
+
20
+ // Registered before the first await, so concurrent callers share this request.
21
+ const request = revalidateWhenStale(locale).finally(() => {
22
+ requests[locale] = undefined;
23
+ });
24
+
25
+ requests[locale] = request;
26
+ return request;
27
+ }
28
+
29
+ /**
30
+ * Lets the next access revalidate immediately - used when the cached dictionary
31
+ * is deleted, so its replacement is not stuck behind the throttle.
32
+ * A revalidation already in flight when this runs saw the deleted entry, so it
33
+ * must not arm the throttle when it finishes.
34
+ * Both in-memory guards are raised before the first await, so a caller that
35
+ * runs this straight after deleting the entry leaves no gap for a revalidation
36
+ * to slip into.
37
+ */
38
+ export async function resetRevalidationThrottle(locale: Locale): Promise<void> {
39
+ resetWhileRevalidating.add(locale);
40
+ await clearRevalidatedAt(locale);
41
+ }
42
+
43
+ async function revalidateWhenStale(locale: Locale): Promise<void> {
44
+ if (Date.now() - (await readRevalidatedAt(locale)) < REVALIDATION_INTERVAL) {
45
+ return;
46
+ }
47
+
48
+ resetWhileRevalidating.delete(locale);
49
+ await revalidate(locale);
50
+
51
+ if (!resetWhileRevalidating.has(locale)) {
52
+ await writeRevalidatedAt(locale, Date.now());
53
+ }
54
+ }
55
+
56
+ /**
57
+ * `no-cache` revalidates against the server without letting the HTTP cache
58
+ * answer, while still allowing a 304 that keeps the multi-megabyte cached body
59
+ * untouched; only a real 200 rewrites it. `no-store` would look equivalent but
60
+ * makes the browser send `Cache-Control: no-cache`, which Next.js reads as an
61
+ * unconditional request and answers with the whole body every time.
62
+ */
63
+ async function revalidate(locale: Locale): Promise<void> {
64
+ await expirationManager.expireEntries();
65
+
66
+ const url = getDictionaryUrl(locale);
67
+ const cache = await caches.open(DICTIONARY_CACHE);
68
+ const cached = await cache.match(url);
69
+ const etag = cached?.headers.get('etag');
70
+ const response = await fetch(url, {
71
+ cache: 'no-cache',
72
+ headers: etag ? { 'If-None-Match': etag } : {},
73
+ });
74
+
75
+ if (response.status === 304) {
76
+ await expirationManager.updateTimestamp(url);
77
+ return;
78
+ }
79
+
80
+ if (!response.ok) {
81
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
82
+ }
83
+
84
+ await cache.put(url, response);
85
+ await expirationManager.updateTimestamp(url);
86
+ }
@@ -0,0 +1,43 @@
1
+ import { type Locale } from '@scrabble-solver/types';
2
+
3
+ import { REVALIDATED_AT_CACHE } from './constants';
4
+ import { getDictionaryUrl } from './getDictionaryUrl';
5
+
6
+ const timestamps: Partial<Record<Locale, number>> = {};
7
+
8
+ /**
9
+ * A dedicated worker is created per page, so a timestamp kept only in memory
10
+ * would let every navigation revalidate again. The Cache API is the only
11
+ * storage a worker shares with other tabs, so the throttle lives there and is
12
+ * read at most once per locale per worker.
13
+ */
14
+ export async function readRevalidatedAt(locale: Locale): Promise<number> {
15
+ const known = timestamps[locale];
16
+
17
+ if (typeof known !== 'undefined') {
18
+ return known;
19
+ }
20
+
21
+ const persisted = await readPersistedRevalidatedAt(locale);
22
+ // A reset that landed while reading wins over the value it invalidated.
23
+ return (timestamps[locale] ??= persisted);
24
+ }
25
+
26
+ export async function writeRevalidatedAt(locale: Locale, timestamp: number): Promise<void> {
27
+ timestamps[locale] = timestamp;
28
+ const cache = await caches.open(REVALIDATED_AT_CACHE);
29
+ await cache.put(getDictionaryUrl(locale), new Response(String(timestamp)));
30
+ }
31
+
32
+ export async function clearRevalidatedAt(locale: Locale): Promise<void> {
33
+ timestamps[locale] = 0;
34
+ const cache = await caches.open(REVALIDATED_AT_CACHE);
35
+ await cache.delete(getDictionaryUrl(locale));
36
+ }
37
+
38
+ async function readPersistedRevalidatedAt(locale: Locale): Promise<number> {
39
+ const cache = await caches.open(REVALIDATED_AT_CACHE);
40
+ const response = await cache.match(getDictionaryUrl(locale));
41
+ const timestamp = Number(await response?.text());
42
+ return Number.isFinite(timestamp) ? timestamp : 0;
43
+ }
@@ -0,0 +1,109 @@
1
+ import { Gaddag } from '@kamilmielnik/gaddag';
2
+ import { Locale } from '@scrabble-solver/types';
3
+ import { describe, expect, it, mock } from 'bun:test';
4
+
5
+ import type * as getGaddagModule from './getGaddag';
6
+
7
+ let dictionary: Response | undefined;
8
+ const deletedLocales: Locale[] = [];
9
+
10
+ await mock.module('./dictionaries', () => ({
11
+ deleteDictionary: (locale: Locale) => {
12
+ deletedLocales.push(locale);
13
+ return Promise.resolve();
14
+ },
15
+ getDictionary: () => Promise.resolve(dictionary),
16
+ }));
17
+
18
+ const { getGaddag }: typeof getGaddagModule = await import('./getGaddag');
19
+
20
+ describe('getGaddag', () => {
21
+ it('returns undefined when there is no cached dictionary', async () => {
22
+ dictionary = undefined;
23
+ expect(await getGaddag(Locale.EN_US)).toBeUndefined();
24
+ });
25
+
26
+ it('deserializes a cached dictionary', async () => {
27
+ dictionary = createDictionary(['scrabble', 'solver']);
28
+ const gaddag = await getGaddag(Locale.EN_US);
29
+
30
+ expect(gaddag?.has('scrabble')).toBe(true);
31
+ expect(gaddag?.has('trie')).toBe(false);
32
+ });
33
+
34
+ it('deletes a dictionary cached by an app version with an incompatible format', async () => {
35
+ dictionary = new Response('serialized trie from a previous app version');
36
+
37
+ expect(await getGaddag(Locale.PL_PL)).toBeUndefined();
38
+ expect(deletedLocales).toContain(Locale.PL_PL);
39
+ });
40
+
41
+ it('deletes a corrupted dictionary', async () => {
42
+ dictionary = new Response(Gaddag.fromArray(['scrabble']).serialize().subarray(0, 10));
43
+
44
+ expect(await getGaddag(Locale.FA_IR)).toBeUndefined();
45
+ expect(deletedLocales).toContain(Locale.FA_IR);
46
+ });
47
+
48
+ it('reuses the deserialized dictionary while the cached response is unchanged', async () => {
49
+ dictionary = createDictionary(['scrabble'], { etag: '"v1"' });
50
+ const first = await getGaddag(Locale.EN_GB);
51
+ dictionary = createDictionary(['scrabble'], { etag: '"v1"' });
52
+ const second = await getGaddag(Locale.EN_GB);
53
+
54
+ expect(first?.has('scrabble')).toBe(true);
55
+ expect(second).toBe(first);
56
+ });
57
+
58
+ it('reuses the deserialized dictionary when a revalidation only refreshes the Date header', async () => {
59
+ dictionary = createDictionary(['scrabble'], { date: 'Thu, 06 Aug 2026 00:00:00 GMT', etag: '"v1"' });
60
+ const first = await getGaddag(Locale.FR_FR);
61
+ dictionary = createDictionary(['scrabble'], { date: 'Fri, 07 Aug 2026 00:00:00 GMT', etag: '"v1"' });
62
+ const second = await getGaddag(Locale.FR_FR);
63
+
64
+ expect(first?.has('scrabble')).toBe(true);
65
+ expect(second).toBe(first);
66
+ });
67
+
68
+ it('picks up a dictionary another tab wrote into the shared cache', async () => {
69
+ dictionary = createDictionary(['scrabble'], { etag: '"v1"' });
70
+ const first = await getGaddag(Locale.TR_TR);
71
+ dictionary = createDictionary(['solver'], { etag: '"v2"' });
72
+ const second = await getGaddag(Locale.TR_TR);
73
+
74
+ expect(first?.has('scrabble')).toBe(true);
75
+ expect(second).not.toBe(first);
76
+ expect(second?.has('solver')).toBe(true);
77
+ });
78
+
79
+ it('falls back to the Date header for responses without an ETag', async () => {
80
+ dictionary = createDictionary(['scrabble'], { date: 'Thu, 06 Aug 2026 00:00:00 GMT' });
81
+ const first = await getGaddag(Locale.DE_DE);
82
+ dictionary = createDictionary(['scrabble'], { date: 'Thu, 06 Aug 2026 00:00:00 GMT' });
83
+ const second = await getGaddag(Locale.DE_DE);
84
+ dictionary = createDictionary(['scrabble'], { date: 'Fri, 07 Aug 2026 00:00:00 GMT' });
85
+ const third = await getGaddag(Locale.DE_DE);
86
+
87
+ expect(first?.has('scrabble')).toBe(true);
88
+ expect(second).toBe(first);
89
+ expect(third).not.toBe(first);
90
+ expect(third?.has('scrabble')).toBe(true);
91
+ });
92
+
93
+ it('memoizes each locale independently', async () => {
94
+ dictionary = createDictionary(['scrabble'], { etag: '"es"' });
95
+ const spanish = await getGaddag(Locale.ES_ES);
96
+ dictionary = createDictionary(['solver'], { etag: '"ro"' });
97
+ const romanian = await getGaddag(Locale.RO_RO);
98
+ dictionary = createDictionary(['scrabble'], { etag: '"es"' });
99
+ const spanishAgain = await getGaddag(Locale.ES_ES);
100
+
101
+ expect(spanish?.has('scrabble')).toBe(true);
102
+ expect(romanian?.has('solver')).toBe(true);
103
+ expect(spanishAgain).toBe(spanish);
104
+ });
105
+ });
106
+
107
+ function createDictionary(words: string[], headers: Record<string, string> = {}): Response {
108
+ return new Response(Gaddag.fromArray(words).serialize(), { headers });
109
+ }
@@ -0,0 +1,62 @@
1
+ import { Gaddag } from '@kamilmielnik/gaddag';
2
+ import { type Locale } from '@scrabble-solver/types';
3
+
4
+ import { deleteDictionary, getDictionary } from './dictionaries';
5
+
6
+ interface DeserializedDictionary {
7
+ gaddag: Gaddag;
8
+ version: string;
9
+ }
10
+
11
+ const deserializedDictionaries: Partial<Record<Locale, DeserializedDictionary>> = {};
12
+
13
+ /**
14
+ * Returns undefined when there is no cached dictionary or when the cached
15
+ * dictionary cannot be deserialized (it was downloaded by an app version with
16
+ * an incompatible format). An undeserializable entry is deleted before
17
+ * returning, so the revalidation the caller triggers next downloads a fresh
18
+ * dictionary instead of getting a 304 for the broken bytes.
19
+ */
20
+ export async function getGaddag(locale: Locale): Promise<Gaddag | undefined> {
21
+ const response = await getDictionary(locale);
22
+
23
+ if (typeof response === 'undefined') {
24
+ return undefined;
25
+ }
26
+
27
+ const version = readVersion(response);
28
+ const deserialized = deserializedDictionaries[locale];
29
+
30
+ if (deserialized && deserialized.version === version) {
31
+ return deserialized.gaddag;
32
+ }
33
+
34
+ try {
35
+ const gaddag = Gaddag.deserialize(new Uint8Array(await response.arrayBuffer()));
36
+
37
+ if (typeof version !== 'undefined') {
38
+ deserializedDictionaries[locale] = { gaddag, version };
39
+ }
40
+
41
+ return gaddag;
42
+ } catch {
43
+ delete deserializedDictionaries[locale];
44
+ await deleteDictionary(locale);
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Read from the cached response rather than tracked in memory, so a dictionary
51
+ * another tab downloaded into the shared cache is picked up here too.
52
+ */
53
+ function readVersion(response: Response): string | undefined {
54
+ const etag = response.headers.get('etag');
55
+
56
+ if (etag !== null) {
57
+ return etag;
58
+ }
59
+
60
+ const parts = ['date', 'content-length'].map((name) => response.headers.get(name) ?? '');
61
+ return parts.every((part) => part === '') ? undefined : parts.join('|');
62
+ }
@@ -0,0 +1 @@
1
+ export { prefetchDictionary, solveLocally, verifyLocally } from './client';