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
@@ -1,15 +1,15 @@
1
1
  import { type Result } from '@scrabble-solver/types';
2
- import { type FocusEvent, type MouseEvent } from 'react';
2
+ import { type FocusEvent, type MouseEvent, type MouseEventHandler } from 'react';
3
3
 
4
4
  export interface ResultCallbacks {
5
5
  onBlur?: (result: Result, event: FocusEvent) => void;
6
6
  onClick?: (result: Result, event: MouseEvent) => void;
7
7
  onFocus?: (result: Result, event: FocusEvent) => void;
8
8
  onMouseEnter?: (result: Result, event: MouseEvent) => void;
9
- onMouseLeave?: (result: Result, event: MouseEvent) => void;
9
+ onMouseLeave?: MouseEventHandler;
10
10
  }
11
11
 
12
- export interface ResultData extends ResultCallbacks {
12
+ export interface ResultData extends Omit<ResultCallbacks, 'onMouseLeave'> {
13
13
  highlightedIndex?: number;
14
14
  results: Result[] | undefined;
15
15
  }
@@ -14,6 +14,7 @@ import {
14
14
  solveSlice,
15
15
  useTranslate,
16
16
  useTypedSelector,
17
+ useTypedStore,
17
18
  } from '@/state';
18
19
 
19
20
  import { Alert } from '../Alert';
@@ -38,13 +39,13 @@ const SolverBase: FunctionComponent<Props> = ({ className, onShowResults }) => {
38
39
  const { maxControlsWidth, showCompactControls, tileSize } = useAppLayout();
39
40
  const error = useTypedSelector(selectSolveError);
40
41
  const isOutdated = useTypedSelector(selectAreResultsOutdated);
41
- const resultCandidate = useTypedSelector(selectResultCandidate);
42
42
  const results = useTypedSelector(selectProcessedResults);
43
+ const store = useTypedStore();
43
44
  const [bestResult] = results || [];
44
45
  const touchCallbacks = useMemo(
45
46
  () => ({
46
47
  onClick: (result: Result) => {
47
- const isSelected = result === resultCandidate;
48
+ const isSelected = result === selectResultCandidate(store.getState());
48
49
 
49
50
  if (isSelected) {
50
51
  dispatch(resultsSlice.actions.applyResult(result));
@@ -53,7 +54,7 @@ const SolverBase: FunctionComponent<Props> = ({ className, onShowResults }) => {
53
54
  }
54
55
  },
55
56
  }),
56
- [dispatch, resultCandidate],
57
+ [dispatch, store],
57
58
  );
58
59
  const mouseCallbacks = useMemo(
59
60
  () => ({
@@ -37,7 +37,7 @@ export const useTooltip = ({ placement = 'top' }: TooltipOptions = {}) => {
37
37
  const { delay } = useDelayGroup(context);
38
38
  const hover = useHover(context, { move: false, delay });
39
39
  const focus = useFocus(context);
40
- const dismiss = useDismiss(context);
40
+ const dismiss = useDismiss(context, { ancestorScroll: true });
41
41
  const role = useRole(context, { role: 'tooltip' });
42
42
  const interactions = useInteractions([hover, focus, dismiss, role]);
43
43
 
@@ -30,8 +30,8 @@ const dictionary = async (request: NextApiRequest, response: NextApiResponse): P
30
30
  },
31
31
  });
32
32
 
33
- const trie = await dictionaries.get(locale);
34
- const results = await Promise.all(words.map((word) => getWordDefinition(locale, word, trie.has(word))));
33
+ const gaddag = await dictionaries.get(locale);
34
+ const results = await Promise.all(words.map((word) => getWordDefinition(locale, word, gaddag.has(word))));
35
35
  response.status(200).send(results.map((result) => result.toJson()));
36
36
  } catch (error) {
37
37
  const message = error instanceof Error ? error.message : 'Unknown error';
@@ -1,7 +1,10 @@
1
+ import { type Gaddag } from '@kamilmielnik/gaddag';
1
2
  import { dictionaries } from '@scrabble-solver/dictionaries';
2
3
  import { logger } from '@scrabble-solver/logger';
3
4
  import { isLocale, type Locale } from '@scrabble-solver/types';
4
5
  import { type NextApiRequest, type NextApiResponse } from 'next';
6
+ import { promisify } from 'util';
7
+ import { gzip } from 'zlib';
5
8
 
6
9
  import { getServerLoggingData } from '@/api';
7
10
 
@@ -22,8 +25,21 @@ const dictionary = async (request: NextApiRequest, response: NextApiResponse): P
22
25
  },
23
26
  });
24
27
 
25
- const trie = await dictionaries.get(locale);
26
- response.status(200).send(trie.serialize());
28
+ const gaddag = await dictionaries.get(locale);
29
+ const isGzip = acceptsGzip(request);
30
+ const body = isGzip ? await getCompressedDictionary(gaddag) : getSerializedDictionary(gaddag);
31
+
32
+ // no-cache makes clients store the payload but revalidate it, so unchanged
33
+ // re-downloads become 304s, answered by Next's built-in ETag handling.
34
+ response.setHeader('Cache-Control', 'no-cache');
35
+ response.setHeader('Content-Type', 'application/octet-stream');
36
+ response.setHeader('Vary', 'Accept-Encoding');
37
+
38
+ if (isGzip) {
39
+ response.setHeader('Content-Encoding', 'gzip');
40
+ }
41
+
42
+ response.status(200).send(body);
27
43
  } catch (error) {
28
44
  const message = error instanceof Error ? error.message : 'Unknown error';
29
45
  logger.error('dictionary - error', { error, meta });
@@ -43,9 +59,70 @@ const parseRequest = (request: NextApiRequest): RequestData => {
43
59
  };
44
60
  };
45
61
 
62
+ const acceptsGzip = (request: NextApiRequest): boolean => {
63
+ const acceptEncoding = request.headers['accept-encoding'];
64
+ const header = Array.isArray(acceptEncoding) ? acceptEncoding.join(',') : acceptEncoding;
65
+
66
+ if (typeof header !== 'string') {
67
+ return false;
68
+ }
69
+
70
+ let wildcardAccepts = false;
71
+
72
+ for (const entry of header.split(',')) {
73
+ const [encoding, ...parameters] = entry.split(';').map((token) => token.trim().toLowerCase());
74
+ const isRefused = parameters.some((parameter) => {
75
+ const [name, quality] = parameter.split('=').map((token) => token.trim());
76
+ return name === 'q' && parseFloat(quality) === 0;
77
+ });
78
+
79
+ if (encoding === 'gzip' || encoding === 'x-gzip') {
80
+ return !isRefused;
81
+ }
82
+
83
+ if (encoding === '*') {
84
+ wildcardAccepts = !isRefused;
85
+ }
86
+ }
87
+
88
+ return wildcardAccepts;
89
+ };
90
+
91
+ // Dictionaries rebuild at most daily, so each response variant is produced
92
+ // once and lives exactly as long as the in-memory dictionary it was made from.
93
+ const serializedCache = new WeakMap<Gaddag, Buffer>();
94
+ const compressedCache = new WeakMap<Gaddag, Promise<Buffer>>();
95
+
96
+ const getSerializedDictionary = (gaddag: Gaddag): Buffer => {
97
+ let serialized = serializedCache.get(gaddag);
98
+
99
+ if (!serialized) {
100
+ // serialize() returns freshly allocated bytes, so the Buffer wraps them without copying.
101
+ const bytes = gaddag.serialize();
102
+ serialized = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
103
+ serializedCache.set(gaddag, serialized);
104
+ }
105
+
106
+ return serialized;
107
+ };
108
+
109
+ const gzipAsync = promisify(gzip);
110
+
111
+ const getCompressedDictionary = (gaddag: Gaddag): Promise<Buffer> => {
112
+ let compressed = compressedCache.get(gaddag);
113
+
114
+ if (!compressed) {
115
+ compressed = gzipAsync(gaddag.serialize());
116
+ compressedCache.set(gaddag, compressed);
117
+ compressed.catch(() => compressedCache.delete(gaddag));
118
+ }
119
+
120
+ return compressed;
121
+ };
122
+
46
123
  export const config = {
47
124
  api: {
48
- responseLimit: '25mb',
125
+ responseLimit: '50mb',
49
126
  },
50
127
  };
51
128
 
@@ -45,9 +45,9 @@ const solve = async (request: NextApiRequest, response: NextApiResponse): Promis
45
45
  },
46
46
  });
47
47
 
48
- const trie = await dictionaries.get(locale);
48
+ const gaddag = await dictionaries.get(locale);
49
49
  const tiles = characters.map((character) => new Tile({ character, isBlank: character === BLANK }));
50
- const results = solveScrabble(trie, config, board, tiles);
50
+ const results = solveScrabble(gaddag, config, board, tiles);
51
51
  response.status(200).send(results);
52
52
  } catch (error) {
53
53
  const message = error instanceof Error ? error.message : 'Unknown error';
@@ -30,10 +30,19 @@ const verify = async (request: NextApiRequest, response: NextApiResponse): Promi
30
30
  },
31
31
  });
32
32
 
33
- const trie = await dictionaries.get(locale);
33
+ const gaddag = await dictionaries.get(locale);
34
34
  const words = board.getWords().sort((a, b) => a.localeCompare(b, locale));
35
- const invalidWords = words.filter((word) => !trie.has(word));
36
- const validWords = words.filter((word) => trie.has(word));
35
+ const invalidWords: string[] = [];
36
+ const validWords: string[] = [];
37
+
38
+ for (const word of words) {
39
+ if (gaddag.has(word)) {
40
+ validWords.push(word);
41
+ } else {
42
+ invalidWords.push(word);
43
+ }
44
+ }
45
+
37
46
  response.status(200).send({ invalidWords, validWords });
38
47
  } catch (error) {
39
48
  const message = error instanceof Error ? error.message : 'Unknown error';
@@ -1,19 +1,25 @@
1
- import { type BoardJson, type Locale, Result, type ResultJson } from '@scrabble-solver/types';
1
+ import { Board, type BoardJson, type Game, type Locale, Result, type ResultJson } from '@scrabble-solver/types';
2
+
3
+ import { solveLocally } from '@/solver-worker';
2
4
 
3
5
  import { fetchJson } from './fetchJson';
4
6
 
5
7
  interface Payload {
6
8
  board: BoardJson;
7
9
  characters: string[];
8
- game: string;
10
+ game: Game;
9
11
  locale: Locale;
10
12
  }
11
13
 
12
14
  export const solve = async ({ board, characters, game, locale }: Payload): Promise<Result[]> => {
13
- const json = await fetchJson<ResultJson[]>('/api/solve', {
14
- method: 'POST',
15
- body: JSON.stringify({ board, characters, game, locale }),
16
- });
15
+ const payload = { board, characters, game, locale };
16
+ const json =
17
+ (await solveLocally(payload)) ??
18
+ (await fetchJson<ResultJson[]>('/api/solve', {
19
+ method: 'POST',
20
+ body: JSON.stringify(payload),
21
+ }));
17
22
 
18
- return json.map(Result.fromJson);
23
+ const decodedBoard = Board.fromJson(board);
24
+ return json.map((result) => Result.fromJson(result, decodedBoard));
19
25
  };
@@ -1,10 +1,12 @@
1
- import { type BoardJson, type Locale } from '@scrabble-solver/types';
1
+ import { type BoardJson, type Game, type Locale } from '@scrabble-solver/types';
2
+
3
+ import { verifyLocally } from '@/solver-worker';
2
4
 
3
5
  import { fetchJson } from './fetchJson';
4
6
 
5
7
  interface Payload {
6
8
  board: BoardJson;
7
- game: string;
9
+ game: Game;
8
10
  locale: Locale;
9
11
  }
10
12
 
@@ -14,8 +16,12 @@ interface Response {
14
16
  }
15
17
 
16
18
  export const verify = async ({ board, game, locale }: Payload): Promise<Response> => {
17
- return fetchJson<Response>('/api/verify', {
18
- method: 'POST',
19
- body: JSON.stringify({ board, game, locale }),
20
- });
19
+ const payload = { board, game, locale };
20
+ return (
21
+ (await verifyLocally(payload)) ??
22
+ fetchJson<Response>('/api/verify', {
23
+ method: 'POST',
24
+ body: JSON.stringify(payload),
25
+ })
26
+ );
21
27
  };
@@ -0,0 +1,86 @@
1
+ import { afterAll, beforeEach, describe, expect, it } from 'bun:test';
2
+
3
+ import { cacheAppShell, respondToNavigation } from './appShell';
4
+
5
+ const entries = new Map<string, Response>();
6
+
7
+ let respond: () => Promise<Response> = () => Promise.resolve(html('shell'));
8
+
9
+ const globals = { caches: globalThis.caches, fetch: globalThis.fetch };
10
+
11
+ Object.assign(globalThis, {
12
+ caches: createCacheStorage(entries),
13
+ fetch: () => respond(),
14
+ });
15
+
16
+ afterAll(() => {
17
+ Object.assign(globalThis, globals);
18
+ });
19
+
20
+ describe('respondToNavigation', () => {
21
+ beforeEach(() => {
22
+ entries.clear();
23
+ respond = () => Promise.resolve(html('shell'));
24
+ });
25
+
26
+ it('serves the network response while online', async () => {
27
+ await cacheAppShell();
28
+ respond = () => Promise.resolve(html('deployed shell'));
29
+ const response = await respondToNavigation({ request: navigation('/') });
30
+ expect(await response.text()).toEqual('deployed shell');
31
+ });
32
+
33
+ it('serves the shell cached on install when the network is gone', async () => {
34
+ await cacheAppShell();
35
+ respond = () => Promise.reject(new Error('offline'));
36
+ const response = await respondToNavigation({ request: navigation('/') });
37
+ expect(await response.text()).toEqual('shell');
38
+ });
39
+
40
+ it('serves the shell for any navigated path', async () => {
41
+ await cacheAppShell();
42
+ respond = () => Promise.reject(new Error('offline'));
43
+ const response = await respondToNavigation({ request: navigation('/not-found') });
44
+ expect(await response.text()).toEqual('shell');
45
+ });
46
+
47
+ /**
48
+ * The cached shell links to the chunks precached next to it, so a navigation
49
+ * must not replace it with HTML from a build this worker did not precache.
50
+ */
51
+ it('keeps the installed shell when a newer one is served from the network', async () => {
52
+ await cacheAppShell();
53
+ respond = () => Promise.resolve(html('deployed shell'));
54
+ await respondToNavigation({ request: navigation('/') });
55
+
56
+ respond = () => Promise.reject(new Error('offline'));
57
+ const response = await respondToNavigation({ request: navigation('/') });
58
+ expect(await response.text()).toEqual('shell');
59
+ });
60
+
61
+ it('rethrows when there is nothing to fall back on', async () => {
62
+ respond = () => Promise.reject(new Error('offline'));
63
+ const error = await respondToNavigation({ request: navigation('/') }).catch((reason: Error) => reason);
64
+ expect(error).toEqual(new Error('offline'));
65
+ });
66
+ });
67
+
68
+ function navigation(url: string): Request {
69
+ return new Request(new URL(url, 'https://scrabble-solver.org'));
70
+ }
71
+
72
+ function html(body: string): Response {
73
+ return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
74
+ }
75
+
76
+ function createCacheStorage(cacheEntries: Map<string, Response>): CacheStorage {
77
+ return {
78
+ open: (name: string) =>
79
+ Promise.resolve({
80
+ add: async (url: string) => {
81
+ cacheEntries.set(`${name} ${url}`, await globalThis.fetch(url));
82
+ },
83
+ match: (url: string) => Promise.resolve(cacheEntries.get(`${name} ${url}`)?.clone()),
84
+ }),
85
+ } as unknown as CacheStorage;
86
+ }
@@ -0,0 +1,39 @@
1
+ import { NavigationRoute, registerRoute } from 'workbox-routing';
2
+
3
+ const APP_SHELL_CACHE = 'app-shell';
4
+ const APP_SHELL_URL = '/';
5
+
6
+ /**
7
+ * Caching the shell on install instead of on navigation pins it to the same build
8
+ * as the chunks precached alongside it, and covers the first visit, whose
9
+ * navigation happens before this worker controls the page.
10
+ */
11
+ export async function cacheAppShell(): Promise<void> {
12
+ const cache = await caches.open(APP_SHELL_CACHE);
13
+ await cache.add(APP_SHELL_URL);
14
+ }
15
+
16
+ export function routeNavigations(): void {
17
+ registerRoute(new NavigationRoute(respondToNavigation, { denylist: [/^\/api\//] }));
18
+ }
19
+
20
+ /**
21
+ * A deploy has to reach online users on their next navigation (see the revalidation
22
+ * headers in next.config.js), so the shell only stands in when the network is gone.
23
+ * Solving keeps working from there, off the dictionary the solver worker holds in
24
+ * the Cache API.
25
+ */
26
+ export async function respondToNavigation({ request }: { request: Request }): Promise<Response> {
27
+ try {
28
+ return await fetch(request);
29
+ } catch (error) {
30
+ const cache = await caches.open(APP_SHELL_CACHE);
31
+ const appShell = await cache.match(APP_SHELL_URL);
32
+
33
+ if (!appShell) {
34
+ throw error;
35
+ }
36
+
37
+ return appShell;
38
+ }
39
+ }
@@ -2,12 +2,12 @@
2
2
 
3
3
  import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
4
4
 
5
- import { routeSolveRequests } from './routeSolveRequests';
6
- import { routeVerifyRequests } from './routeVerifyRequests';
5
+ import { cacheAppShell, routeNavigations } from './appShell';
7
6
 
8
7
  declare const self: ServiceWorkerGlobalScope;
9
8
 
10
- self.addEventListener('install', () => {
9
+ self.addEventListener('install', (event) => {
10
+ event.waitUntil(cacheAppShell());
11
11
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
12
12
  self.skipWaiting();
13
13
  });
@@ -19,5 +19,4 @@ self.addEventListener('activate', () => {
19
19
 
20
20
  cleanupOutdatedCaches();
21
21
  precacheAndRoute(self.__WB_MANIFEST);
22
- routeSolveRequests();
23
- routeVerifyRequests();
22
+ routeNavigations();
@@ -1,26 +1,9 @@
1
1
  import { Workbox } from 'workbox-window';
2
2
 
3
- let serviceWorker: Workbox | null = null;
4
-
5
3
  export const registerServiceWorker = async () => {
6
4
  if (!globalThis.navigator || !('serviceWorker' in globalThis.navigator)) {
7
5
  return;
8
6
  }
9
7
 
10
- serviceWorker = new Workbox('/service-worker.js');
11
- await serviceWorker.register({ immediate: true });
12
- };
13
-
14
- export const getServiceWorker = async (): Promise<Workbox | null> => {
15
- if (process.env.NODE_ENV !== 'production') {
16
- return null;
17
- }
18
-
19
- if (serviceWorker) {
20
- return serviceWorker;
21
- }
22
-
23
- await registerServiceWorker();
24
-
25
- return serviceWorker;
8
+ await new Workbox('/service-worker.js').register({ immediate: true });
26
9
  };
@@ -0,0 +1,151 @@
1
+ import { type ResultJson } from '@scrabble-solver/types';
2
+ import { afterAll, beforeEach, describe, expect, it } from 'bun:test';
3
+
4
+ import type * as clientModule from './client';
5
+ import { type SolverWorkerRequest, type SolverWorkerResponse } from './messages';
6
+
7
+ type Listener = (event: { data: SolverWorkerResponse }) => void;
8
+
9
+ const posted: SolverWorkerRequest[] = [];
10
+ let listeners: Listener[] = [];
11
+ let errorListeners: (() => void)[] = [];
12
+ let terminated = 0;
13
+ let timeouts: (() => void)[] = [];
14
+
15
+ class FakeWorker {
16
+ public postMessage(message: SolverWorkerRequest): void {
17
+ posted.push(message);
18
+ }
19
+
20
+ public addEventListener(type: string, listener: Listener & (() => void)): void {
21
+ if (type === 'message') {
22
+ listeners.push(listener);
23
+ } else {
24
+ errorListeners.push(listener);
25
+ }
26
+ }
27
+
28
+ public terminate(): void {
29
+ terminated += 1;
30
+ }
31
+ }
32
+
33
+ // The client reads these off the global scope on every call, so stubbing them
34
+ // here is what makes the worker round-trip and its timeout observable.
35
+ const globals = { caches: globalThis.caches, clearTimeout, setTimeout, Worker: globalThis.Worker };
36
+
37
+ Object.assign(globalThis, {
38
+ caches: {},
39
+ clearTimeout: () => undefined,
40
+ setTimeout: (callback: () => void) => timeouts.push(callback),
41
+ Worker: FakeWorker,
42
+ });
43
+
44
+ afterAll(() => {
45
+ Object.assign(globalThis, globals);
46
+ });
47
+
48
+ const { prefetchDictionary, solveLocally, verifyLocally }: typeof clientModule = await import('./client');
49
+
50
+ const solvePayload = { board: [], characters: [], game: 'scrabble', locale: 'en-US' } as never;
51
+ const verifyPayload = { board: [], game: 'scrabble', locale: 'en-US' } as never;
52
+
53
+ describe('solver worker client', () => {
54
+ beforeEach(() => {
55
+ posted.length = 0;
56
+ timeouts = [];
57
+ });
58
+
59
+ it('resolves a solve with the worker results', async () => {
60
+ const solve = solveLocally(solvePayload);
61
+ respond({ data: createResults('ab'), id: lastRequestId(), outcome: 'answered' });
62
+
63
+ expect(await solve).toEqual(createResults('ab'));
64
+ });
65
+
66
+ it('resolves to undefined when the worker cannot answer, so the caller falls back to the server', async () => {
67
+ const solve = solveLocally(solvePayload);
68
+ respond({ id: lastRequestId(), outcome: 'unavailable' });
69
+
70
+ expect(await solve).toBeUndefined();
71
+ });
72
+
73
+ it('answers a superseded solve with the newer solve results', async () => {
74
+ const stale = solveLocally(solvePayload);
75
+ const staleId = lastRequestId();
76
+ const latest = solveLocally(solvePayload);
77
+ const latestId = lastRequestId();
78
+
79
+ respond({ id: staleId, outcome: 'superseded' });
80
+ respond({ data: createResults('cd'), id: latestId, outcome: 'answered' });
81
+
82
+ expect(await stale).toEqual(createResults('cd'));
83
+ expect(await latest).toEqual(createResults('cd'));
84
+ });
85
+
86
+ it('resolves to undefined when the worker never answers', async () => {
87
+ const solve = solveLocally(solvePayload);
88
+ expect(timeouts).toHaveLength(1);
89
+ timeouts[0]();
90
+
91
+ expect(await solve).toBeUndefined();
92
+ });
93
+
94
+ it('ignores a late answer to a request that already timed out', async () => {
95
+ const solve = solveLocally(solvePayload);
96
+ const id = lastRequestId();
97
+ timeouts[0]();
98
+ respond({ data: createResults('ef'), id, outcome: 'answered' });
99
+
100
+ expect(await solve).toBeUndefined();
101
+ });
102
+
103
+ it('resolves a verify with the worker words', async () => {
104
+ const verify = verifyLocally(verifyPayload);
105
+ respond({ data: { invalidWords: ['zz'], validWords: ['ab'] }, id: lastRequestId(), outcome: 'answered' });
106
+
107
+ expect(await verify).toEqual({ invalidWords: ['zz'], validWords: ['ab'] });
108
+ });
109
+
110
+ it('does not wait for a prefetch to answer', () => {
111
+ prefetchDictionary('pl-PL' as never);
112
+
113
+ expect(posted[posted.length - 1].type).toBe('prefetch');
114
+ expect(timeouts).toHaveLength(0);
115
+ });
116
+
117
+ it('settles in-flight requests and discards the worker when it fails', async () => {
118
+ const solve = solveLocally(solvePayload);
119
+ const verify = verifyLocally(verifyPayload);
120
+ const failuresBefore = terminated;
121
+
122
+ for (const listener of errorListeners) {
123
+ listener();
124
+ }
125
+
126
+ expect(await solve).toBeUndefined();
127
+ expect(await verify).toBeUndefined();
128
+ expect(terminated).toBe(failuresBefore + 1);
129
+
130
+ listeners = [];
131
+ errorListeners = [];
132
+ const restarted = solveLocally(solvePayload);
133
+ respond({ data: createResults('gh'), id: lastRequestId(), outcome: 'answered' });
134
+
135
+ expect(await restarted).toEqual(createResults('gh'));
136
+ });
137
+ });
138
+
139
+ function respond(response: SolverWorkerResponse): void {
140
+ for (const listener of listeners) {
141
+ listener({ data: response });
142
+ }
143
+ }
144
+
145
+ function lastRequestId(): number {
146
+ return posted[posted.length - 1].id;
147
+ }
148
+
149
+ function createResults(word: string): ResultJson[] {
150
+ return [{ blankIndices: [], id: 0, isHorizontal: true, points: 1, tiles: word.split(''), x: 0, y: 0 }];
151
+ }