@scrabble-solver/scrabble-solver 2.8.10 → 2.9.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 (79) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/InjectManifest.js.nft.json +1 -0
  3. package/.next/build-manifest.json +9 -9
  4. package/.next/cache/.tsbuildinfo +1 -1
  5. package/.next/cache/eslint/.cache_8dgz12 +1 -1
  6. package/.next/cache/next-server.js.nft.json +1 -1
  7. package/.next/cache/webpack/client-production/0.pack +0 -0
  8. package/.next/cache/webpack/client-production/index.pack +0 -0
  9. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  10. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  11. package/.next/cache/webpack/server-production/0.pack +0 -0
  12. package/.next/cache/webpack/server-production/index.pack +0 -0
  13. package/.next/next-server.js.nft.json +1 -1
  14. package/.next/prerender-manifest.json +1 -1
  15. package/.next/routes-manifest.json +1 -1
  16. package/.next/server/InjectManifest.js.nft.json +1 -0
  17. package/.next/server/chunks/413.js +44 -17
  18. package/.next/server/chunks/452.js +894 -0
  19. package/.next/server/chunks/515.js +82 -45
  20. package/.next/server/chunks/911.js +0 -887
  21. package/.next/server/middleware-build-manifest.js +1 -1
  22. package/.next/server/pages/404.html +3 -3
  23. package/.next/server/pages/404.js.nft.json +1 -1
  24. package/.next/server/pages/500.html +2 -2
  25. package/.next/server/pages/_app.js.nft.json +1 -1
  26. package/.next/server/pages/_document.js.nft.json +1 -1
  27. package/.next/server/pages/_error.js.nft.json +1 -1
  28. package/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
  29. package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  30. package/.next/server/pages/api/dictionary/[locale].js +200 -0
  31. package/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -0
  32. package/.next/server/pages/api/solve.js +32 -2
  33. package/.next/server/pages/api/solve.js.nft.json +1 -1
  34. package/.next/server/pages/api/verify.js +1 -1
  35. package/.next/server/pages/api/verify.js.nft.json +1 -1
  36. package/.next/server/pages/index.html +7 -7
  37. package/.next/server/pages/index.js +74 -29
  38. package/.next/server/pages/index.js.nft.json +1 -1
  39. package/.next/server/pages/index.json +1 -1
  40. package/.next/server/pages-manifest.json +1 -0
  41. package/.next/static/BiJ0RS1mh1CdONVQg_p20/_buildManifest.js +1 -0
  42. package/.next/static/{A8A_Lmg8cM-Bkf-Jo1CLh → BiJ0RS1mh1CdONVQg_p20}/_ssgManifest.js +0 -0
  43. package/.next/static/chunks/317-c5d262202c17d519.js +1 -0
  44. package/.next/static/chunks/{758-eff80059a1365d5d.js → 546-447e243fc9de2c59.js} +1 -1
  45. package/.next/static/chunks/pages/{404-90c624da3c83fd17.js → 404-7082923654d5996f.js} +1 -1
  46. package/.next/static/chunks/pages/_app-1878e12521f2d115.js +1 -0
  47. package/.next/static/chunks/pages/index-14d33636a0746c22.js +1 -0
  48. package/.next/trace +52 -42
  49. package/next.config.js +11 -0
  50. package/package.json +17 -11
  51. package/src/components/Board/components/Cell/CellPure.tsx +33 -31
  52. package/src/components/Board/hooks/useGrid.ts +23 -18
  53. package/src/components/Sidebar/Sidebar.tsx +20 -1
  54. package/src/components/SvgFontCss/SvgFontCss.tsx +2 -1
  55. package/src/pages/api/dictionary/[locale]/index.ts +53 -0
  56. package/src/pages/api/solve.ts +6 -0
  57. package/src/pages/index.tsx +8 -1
  58. package/src/sdk/fetch.ts +30 -0
  59. package/src/sdk/fetchJson.ts +10 -31
  60. package/src/sdk/getDictionary.ts +11 -0
  61. package/src/service-worker/dictionaries/constants.ts +3 -0
  62. package/src/service-worker/dictionaries/expirationManager.ts +9 -0
  63. package/src/service-worker/dictionaries/getDictionary.ts +22 -0
  64. package/src/service-worker/dictionaries/getDictionaryUrl.ts +7 -0
  65. package/src/service-worker/dictionaries/index.ts +2 -0
  66. package/src/service-worker/dictionaries/revalidateDictionary.ts +35 -0
  67. package/src/service-worker/getTrie.ts +26 -0
  68. package/src/service-worker/index.ts +22 -0
  69. package/src/service-worker/routeSolveRequests.ts +37 -0
  70. package/src/service-worker/routeVerifyRequests.ts +35 -0
  71. package/src/serviceWorkerManager.ts +26 -0
  72. package/src/state/sagas.ts +7 -0
  73. package/src/state/slices/rackSlice.ts +0 -5
  74. package/src/state/slices/settingsInitialState.ts +20 -1
  75. package/tsconfig.json +1 -0
  76. package/.next/static/A8A_Lmg8cM-Bkf-Jo1CLh/_buildManifest.js +0 -1
  77. package/.next/static/chunks/317-95ab9051449362fa.js +0 -1
  78. package/.next/static/chunks/pages/_app-0e358b5622cf9e66.js +0 -1
  79. package/.next/static/chunks/pages/index-0cc5e6eda5adac73.js +0 -1
package/next.config.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const WorkboxPlugin = require('workbox-webpack-plugin');
5
6
 
6
7
  const tsConfig = fs.readFileSync(path.resolve(__dirname, 'tsconfig.json'), 'utf-8');
7
8
  const tsConfigJson = JSON.parse(tsConfig);
@@ -39,5 +40,15 @@ module.exports = {
39
40
  },
40
41
  ],
41
42
  },
43
+ plugins: [
44
+ ...config.plugins,
45
+ process.env.NODE_ENV === 'production'
46
+ ? new WorkboxPlugin.InjectManifest({
47
+ swSrc: path.join(__dirname, 'src/service-worker/index.ts'),
48
+ swDest: path.join(__dirname, 'public/service-worker.js'),
49
+ exclude: [/\.map$/, /\.next/, /_next/, /manifest/, /\.htaccess$/, /.*\/static\/.*/, /service-worker\.js$/],
50
+ })
51
+ : undefined,
52
+ ].filter(Boolean),
42
53
  }),
43
54
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrabble-solver/scrabble-solver",
3
- "version": "2.8.10",
3
+ "version": "2.9.0",
4
4
  "description": "Scrabble Solver 2 - App",
5
5
  "engines": {
6
6
  "node": ">=16"
@@ -28,15 +28,16 @@
28
28
  "start": "env-cmd next start -p 3333"
29
29
  },
30
30
  "dependencies": {
31
+ "@kamilmielnik/trie": "^2.0.1",
31
32
  "@popperjs/core": "^2.11.6",
32
33
  "@reduxjs/toolkit": "^1.8.6",
33
- "@scrabble-solver/configs": "^2.8.10",
34
- "@scrabble-solver/constants": "^2.8.10",
35
- "@scrabble-solver/dictionaries": "^2.8.10",
36
- "@scrabble-solver/logger": "^2.8.10",
37
- "@scrabble-solver/solver": "^2.8.10",
38
- "@scrabble-solver/types": "^2.8.10",
39
- "@scrabble-solver/word-definitions": "^2.8.10",
34
+ "@scrabble-solver/configs": "^2.9.0",
35
+ "@scrabble-solver/constants": "^2.9.0",
36
+ "@scrabble-solver/dictionaries": "^2.9.0",
37
+ "@scrabble-solver/logger": "^2.9.0",
38
+ "@scrabble-solver/solver": "^2.9.0",
39
+ "@scrabble-solver/types": "^2.9.0",
40
+ "@scrabble-solver/word-definitions": "^2.9.0",
40
41
  "classnames": "^2.3.2",
41
42
  "next": "^12.3.1",
42
43
  "normalize.css": "^8.0.1",
@@ -51,7 +52,11 @@
51
52
  "redux": "^4.2.0",
52
53
  "redux-saga": "^1.2.1",
53
54
  "store2": "^2.14.2",
54
- "uuid": "^9.0.0"
55
+ "uuid": "^9.0.0",
56
+ "workbox-expiration": "^6.5.4",
57
+ "workbox-precaching": "^6.5.4",
58
+ "workbox-routing": "^6.5.4",
59
+ "workbox-window": "^6.5.4"
55
60
  },
56
61
  "devDependencies": {
57
62
  "@svgr/webpack": "^6.5.0",
@@ -66,7 +71,8 @@
66
71
  "@types/redux-saga": "^0.10.5",
67
72
  "@types/uuid": "^8.3.4",
68
73
  "env-cmd": "^10.1.0",
69
- "sass": "^1.55.0"
74
+ "sass": "^1.55.0",
75
+ "workbox-webpack-plugin": "^6.5.4"
70
76
  },
71
- "gitHead": "8822c74d0d632ee51bca3bf69ccfc7f517bfadb3"
77
+ "gitHead": "f100d9d52460a68f1fb5a1059cd335029d5e46a8"
72
78
  }
@@ -81,39 +81,41 @@ const CellPure: FunctionComponent<Props> = ({
81
81
  onFocus={onFocus}
82
82
  />
83
83
 
84
- <div className={styles.actions}>
85
- <Button tooltip={translate('cell.toggle-direction')} onClick={onDirectionToggleClick}>
86
- <ArrowDown
87
- className={classNames(styles.toggleDirection, {
88
- [styles.right]: direction === 'horizontal',
89
- })}
90
- />
91
- </Button>
92
-
93
- {isEmpty && (
94
- <Button
95
- className={classNames(styles.filterCell, {
96
- [styles.filtered]: isFiltered,
97
- })}
98
- tooltip={translate('cell.filter-cell')}
99
- onClick={onToggleFilterCellClick}
100
- >
101
- <Flag />
84
+ {!cell.isCandidate() && (
85
+ <div className={styles.actions}>
86
+ <Button tooltip={translate('cell.toggle-direction')} onClick={onDirectionToggleClick}>
87
+ <ArrowDown
88
+ className={classNames(styles.toggleDirection, {
89
+ [styles.right]: direction === 'horizontal',
90
+ })}
91
+ />
102
92
  </Button>
103
- )}
104
93
 
105
- {!isEmpty && (
106
- <Button
107
- className={classNames(styles.blank, {
108
- [styles.active]: tile.isBlank,
109
- })}
110
- tooltip={tile.isBlank ? translate('cell.set-not-blank') : translate('cell.set-blank')}
111
- onClick={onToggleBlankClick}
112
- >
113
- B
114
- </Button>
115
- )}
116
- </div>
94
+ {isEmpty && (
95
+ <Button
96
+ className={classNames(styles.filterCell, {
97
+ [styles.filtered]: isFiltered,
98
+ })}
99
+ tooltip={translate('cell.filter-cell')}
100
+ onClick={onToggleFilterCellClick}
101
+ >
102
+ <Flag />
103
+ </Button>
104
+ )}
105
+
106
+ {!isEmpty && (
107
+ <Button
108
+ className={classNames(styles.blank, {
109
+ [styles.active]: tile.isBlank,
110
+ })}
111
+ tooltip={tile.isBlank ? translate('cell.set-not-blank') : translate('cell.set-blank')}
112
+ onClick={onToggleBlankClick}
113
+ >
114
+ B
115
+ </Button>
116
+ )}
117
+ </div>
118
+ )}
117
119
  </div>
118
120
  );
119
121
 
@@ -15,6 +15,7 @@ import {
15
15
  } from 'react';
16
16
  import { useDispatch } from 'react-redux';
17
17
  import { useLatest } from 'react-use';
18
+ import { AnyAction } from 'redux';
18
19
 
19
20
  import { createGridOf, createKeyboardNavigation, extractCharacters, extractInputValue, isCtrl } from 'lib';
20
21
  import { boardSlice, selectConfig, useTypedSelector } from 'state';
@@ -82,6 +83,7 @@ const useGrid = (rows: Cell[][]): [State, Actions] => {
82
83
  const insertValue = useCallback(
83
84
  (position: Point, value: string) => {
84
85
  const characters = value ? extractCharacters(config, value).filter((character) => character !== BLANK) : [BLANK];
86
+ const actions: AnyAction[] = [];
85
87
  let board = new Board({ rows: rows.map((row) => row.map((cell) => cell.clone())) });
86
88
  let { x, y } = position;
87
89
 
@@ -108,10 +110,9 @@ const useGrid = (rows: Cell[][]): [State, Actions] => {
108
110
  const twoCharacterCandidate = cellUp.tile.character + character;
109
111
 
110
112
  if (!cellUp.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
111
- board = boardSlice.reducer(
112
- board,
113
- boardSlice.actions.changeCellValue({ x, y: y - 1, value: twoCharacterCandidate }),
114
- );
113
+ const action = boardSlice.actions.changeCellValue({ x, y: y - 1, value: twoCharacterCandidate });
114
+ board = boardSlice.reducer(board, action);
115
+ actions.push(action);
115
116
  return;
116
117
  }
117
118
  }
@@ -121,8 +122,10 @@ const useGrid = (rows: Cell[][]): [State, Actions] => {
121
122
  const twoCharacterCandidate = character + cellDown.tile.character;
122
123
 
123
124
  if (!cellDown.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
124
- board = boardSlice.reducer(board, boardSlice.actions.changeCellValue({ x, y, value: character }));
125
- board = boardSlice.reducer(board, boardSlice.actions.changeCellValue({ x, y: y + 1, value: EMPTY_CELL }));
125
+ const action1 = boardSlice.actions.changeCellValue({ x, y, value: character });
126
+ const action2 = boardSlice.actions.changeCellValue({ x, y: y + 1, value: EMPTY_CELL });
127
+ board = boardSlice.reducer(boardSlice.reducer(board, action1), action2);
128
+ actions.push(action1, action2);
126
129
  scheduleMoveFocus();
127
130
  return;
128
131
  }
@@ -133,10 +136,9 @@ const useGrid = (rows: Cell[][]): [State, Actions] => {
133
136
  const twoCharacterCandidate = cellLeft.tile.character + character;
134
137
 
135
138
  if (!cellLeft.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
136
- board = boardSlice.reducer(
137
- board,
138
- boardSlice.actions.changeCellValue({ x: x - 1, y, value: twoCharacterCandidate }),
139
- );
139
+ const action = boardSlice.actions.changeCellValue({ x: x - 1, y, value: twoCharacterCandidate });
140
+ board = boardSlice.reducer(board, action);
141
+ actions.push(action);
140
142
  return;
141
143
  }
142
144
  }
@@ -146,8 +148,10 @@ const useGrid = (rows: Cell[][]): [State, Actions] => {
146
148
  const twoCharacterCandidate = character + cellRight.tile.character;
147
149
 
148
150
  if (!cellRight.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
149
- board = boardSlice.reducer(board, boardSlice.actions.changeCellValue({ x, y, value: character }));
150
- board = boardSlice.reducer(board, boardSlice.actions.changeCellValue({ x: x + 1, y, value: EMPTY_CELL }));
151
+ const action1 = boardSlice.actions.changeCellValue({ x, y, value: character });
152
+ const action2 = boardSlice.actions.changeCellValue({ x: x + 1, y, value: EMPTY_CELL });
153
+ board = boardSlice.reducer(boardSlice.reducer(board, action1), action2);
154
+ actions.push(action1, action2);
151
155
  scheduleMoveFocus();
152
156
  return;
153
157
  }
@@ -158,20 +162,21 @@ const useGrid = (rows: Cell[][]): [State, Actions] => {
158
162
  const twoCharacterCandidate = cell.tile.character + character;
159
163
 
160
164
  if (!cell.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
161
- board = boardSlice.reducer(
162
- board,
163
- boardSlice.actions.changeCellValue({ x, y, value: twoCharacterCandidate }),
164
- );
165
+ const action = boardSlice.actions.changeCellValue({ x, y, value: twoCharacterCandidate });
166
+ board = boardSlice.reducer(board, action);
167
+ actions.push(action);
165
168
  return;
166
169
  }
167
170
  }
168
171
 
169
- board = boardSlice.reducer(board, boardSlice.actions.changeCellValue({ x, y, value: character }));
172
+ const action = boardSlice.actions.changeCellValue({ x, y, value: character });
173
+ board = boardSlice.reducer(board, action);
174
+ actions.push(action);
170
175
  scheduleMoveFocus();
171
176
  });
172
177
 
173
178
  moveFocus(Math.abs(position.x - x) + Math.abs(position.y - y));
174
- dispatch(boardSlice.actions.change(board));
179
+ actions.forEach(dispatch);
175
180
  },
176
181
  [config, directionRef, dispatch, moveFocus, rows],
177
182
  );
@@ -1,6 +1,7 @@
1
1
  import classNames from 'classnames';
2
- import { FunctionComponent, ReactNode } from 'react';
2
+ import { FunctionComponent, ReactNode, useEffect, useState } from 'react';
3
3
  import Modal from 'react-modal';
4
+ import { useKey } from 'react-use';
4
5
 
5
6
  import { CrossFill } from 'icons';
6
7
  import { TRANSITION_DURATION_LONG } from 'parameters';
@@ -21,6 +22,23 @@ export interface Props {
21
22
 
22
23
  const Sidebar: FunctionComponent<Props> = ({ children, className, isOpen, title, onClose }) => {
23
24
  const translate = useTranslate();
25
+ const [shouldReturnFocusAfterClose, setShouldReturnFocusAfterClose] = useState(true);
26
+
27
+ useKey(
28
+ 'Escape',
29
+ () => {
30
+ setShouldReturnFocusAfterClose(false);
31
+ onClose();
32
+ },
33
+ undefined,
34
+ [onClose],
35
+ );
36
+
37
+ useEffect(() => {
38
+ if (isOpen) {
39
+ setShouldReturnFocusAfterClose(true);
40
+ }
41
+ }, [isOpen]);
24
42
 
25
43
  return (
26
44
  <Modal
@@ -33,6 +51,7 @@ const Sidebar: FunctionComponent<Props> = ({ children, className, isOpen, title,
33
51
  contentLabel={title}
34
52
  isOpen={isOpen}
35
53
  overlayClassName={styles.overlay}
54
+ shouldReturnFocusAfterClose={shouldReturnFocusAfterClose}
36
55
  onRequestClose={onClose}
37
56
  >
38
57
  <div className={classNames(styles.sidebar, className)}>
@@ -7,6 +7,7 @@ text {
7
7
  font-family: 'Open Sans';
8
8
  }`;
9
9
 
10
- const SvgFontCss: FunctionComponent = () => <style type="text/css">{CSS}</style>;
10
+ // eslint-disable-next-line react/no-danger
11
+ const SvgFontCss: FunctionComponent = () => <style type="text/css" dangerouslySetInnerHTML={{ __html: CSS }} />;
11
12
 
12
13
  export default SvgFontCss;
@@ -0,0 +1,53 @@
1
+ import { dictionaries } from '@scrabble-solver/dictionaries';
2
+ import logger from '@scrabble-solver/logger';
3
+ import { isLocale, Locale } from '@scrabble-solver/types';
4
+ import { NextApiRequest, NextApiResponse } from 'next';
5
+
6
+ import { getServerLoggingData } from 'api';
7
+
8
+ interface RequestData {
9
+ locale: Locale;
10
+ }
11
+
12
+ const dictionary = async (request: NextApiRequest, response: NextApiResponse): Promise<void> => {
13
+ const meta = getServerLoggingData(request);
14
+
15
+ try {
16
+ const { locale } = parseRequest(request);
17
+
18
+ logger.info('dictionary - request', {
19
+ meta,
20
+ payload: {
21
+ locale,
22
+ },
23
+ });
24
+
25
+ const trie = await dictionaries.get(locale);
26
+ response.status(200).send(trie.serialize());
27
+ } catch (error) {
28
+ const message = error instanceof Error ? error.message : 'Unknown error';
29
+ logger.error('dictionary - error', { error, meta });
30
+ response.status(500).send({ error: 'Server error', message });
31
+ throw error;
32
+ }
33
+ };
34
+
35
+ const parseRequest = (request: NextApiRequest): RequestData => {
36
+ const { locale } = request.query;
37
+
38
+ if (!isLocale(locale)) {
39
+ throw new Error('Invalid "locale" parameter');
40
+ }
41
+
42
+ return {
43
+ locale,
44
+ };
45
+ };
46
+
47
+ export const config = {
48
+ api: {
49
+ responseLimit: '25mb',
50
+ },
51
+ };
52
+
53
+ export default dictionary;
@@ -89,4 +89,10 @@ const parseRequest = (request: NextApiRequest): RequestData => {
89
89
  };
90
90
  };
91
91
 
92
+ export const config = {
93
+ api: {
94
+ responseLimit: '25mb',
95
+ },
96
+ };
97
+
92
98
  export default solve;
@@ -1,7 +1,7 @@
1
1
  import classNames from 'classnames';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { AnimationEvent, FormEvent, FunctionComponent, useState } from 'react';
4
+ import { AnimationEvent, FormEvent, FunctionComponent, useEffect, useState } from 'react';
5
5
  import Modal from 'react-modal';
6
6
  import { useDispatch } from 'react-redux';
7
7
  import { useEffectOnce, useMeasure } from 'react-use';
@@ -24,6 +24,7 @@ import {
24
24
  import { useIsTablet, useLocalStorage } from 'hooks';
25
25
  import { getCellSize } from 'lib';
26
26
  import { COMPONENTS_SPACING, COMPONENTS_SPACING_MOBILE, DICTIONARY_HEIGHT } from 'parameters';
27
+ import { registerServiceWorker } from 'serviceWorkerManager';
27
28
  import { initialize, localStorage, reset, selectConfig, solveSlice, useTypedSelector } from 'state';
28
29
 
29
30
  import styles from './index.module.scss';
@@ -72,6 +73,12 @@ const Index: FunctionComponent<Props> = ({ version }) => {
72
73
  dispatch(initialize());
73
74
  });
74
75
 
76
+ useEffect(() => {
77
+ if (process.env.NODE_ENV === 'production') {
78
+ registerServiceWorker();
79
+ }
80
+ }, []);
81
+
75
82
  return (
76
83
  <>
77
84
  <div className={classNames(styles.index, { [styles.initialized]: isInitialized })}>
@@ -0,0 +1,30 @@
1
+ import { isError } from '@scrabble-solver/types';
2
+
3
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
4
+ let response: Response;
5
+
6
+ try {
7
+ response = await window.fetch(input, init);
8
+ } catch (error) {
9
+ const message = isError(error) ? error.message : 'Unknown error';
10
+ throw new Error(`Network error: ${message}`);
11
+ }
12
+
13
+ if (response.ok) {
14
+ return response;
15
+ }
16
+
17
+ try {
18
+ const json = await response.json();
19
+
20
+ if (isError(json)) {
21
+ throw new Error(json.message);
22
+ }
23
+ } finally {
24
+ // do nothing
25
+ }
26
+
27
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
28
+ };
29
+
30
+ export default fetch;
@@ -1,36 +1,15 @@
1
- import { isError } from '@scrabble-solver/types';
1
+ import fetch from './fetch';
2
2
 
3
3
  const fetchJson = async <T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> => {
4
- let response: Response;
5
-
6
- try {
7
- response = await fetch(input, {
8
- ...init,
9
- headers: {
10
- 'Content-Type': 'application/json',
11
- ...init?.headers,
12
- },
13
- });
14
- } catch (error) {
15
- const message = isError(error) ? error.message : 'Unknown error';
16
- throw new Error(`Network error: ${message}`);
17
- }
18
-
19
- if (response.ok) {
20
- return response.json();
21
- }
22
-
23
- try {
24
- const json = await response.json();
25
-
26
- if (isError(json)) {
27
- throw new Error(json.message);
28
- }
29
- } finally {
30
- // do nothing
31
- }
32
-
33
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4
+ const response = await fetch(input, {
5
+ ...init,
6
+ headers: {
7
+ 'Content-Type': 'application/json',
8
+ ...init?.headers,
9
+ },
10
+ });
11
+
12
+ return response.json();
34
13
  };
35
14
 
36
15
  export default fetchJson;
@@ -0,0 +1,11 @@
1
+ import { Trie } from '@kamilmielnik/trie';
2
+ import { Locale } from '@scrabble-solver/types';
3
+
4
+ import fetchJson from './fetchJson';
5
+
6
+ const getDictionary = async (locale: Locale): Promise<Trie> => {
7
+ const serialized = await fetchJson<string>(`/api/dictionary/${locale}`);
8
+ return Trie.deserialize(serialized);
9
+ };
10
+
11
+ export default getDictionary;
@@ -0,0 +1,3 @@
1
+ export const DICTIONARY_CACHE = 'dictionary-api-cache';
2
+
3
+ export const DICTIONARY_CACHE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
@@ -0,0 +1,9 @@
1
+ import { CacheExpiration } from 'workbox-expiration';
2
+
3
+ import { DICTIONARY_CACHE, DICTIONARY_CACHE_MAX_AGE } from './constants';
4
+
5
+ const expirationManager = new CacheExpiration(DICTIONARY_CACHE, {
6
+ maxAgeSeconds: DICTIONARY_CACHE_MAX_AGE / 1000,
7
+ });
8
+
9
+ export default expirationManager;
@@ -0,0 +1,22 @@
1
+ import { Locale } from '@scrabble-solver/types';
2
+
3
+ import { DICTIONARY_CACHE } from './constants';
4
+ import expirationManager from './expirationManager';
5
+ import getDictionaryUrl from './getDictionaryUrl';
6
+
7
+ const getDictionary = async (locale: Locale): Promise<string | undefined> => {
8
+ await expirationManager.expireEntries();
9
+
10
+ const url = getDictionaryUrl(locale);
11
+ const cache = await caches.open(DICTIONARY_CACHE);
12
+ const cached = await cache.match(url);
13
+
14
+ if (typeof cached === 'undefined') {
15
+ return undefined;
16
+ }
17
+
18
+ const serialized = await cached.clone().text();
19
+ return serialized;
20
+ };
21
+
22
+ export default getDictionary;
@@ -0,0 +1,7 @@
1
+ import { Locale } from '@scrabble-solver/types';
2
+
3
+ const getDictionaryUrl = (locale: Locale): string => {
4
+ return `/api/dictionary/${locale}`;
5
+ };
6
+
7
+ export default getDictionaryUrl;
@@ -0,0 +1,2 @@
1
+ export { default as getDictionary } from './getDictionary';
2
+ export { default as revalidateDictionary } from './revalidateDictionary';
@@ -0,0 +1,35 @@
1
+ import { Locale } from '@scrabble-solver/types';
2
+
3
+ import { DICTIONARY_CACHE } from './constants';
4
+ import expirationManager from './expirationManager';
5
+ import getDictionaryUrl from './getDictionaryUrl';
6
+
7
+ const requests: Partial<Record<Locale, Promise<Response> | undefined>> = {};
8
+
9
+ const revalidateDictionary = async (locale: Locale): Promise<void> => {
10
+ if (requests[locale] instanceof Promise) {
11
+ return;
12
+ }
13
+
14
+ let response: Response | undefined;
15
+ const url = getDictionaryUrl(locale);
16
+ const request = fetch(url);
17
+ requests[locale] = request;
18
+
19
+ try {
20
+ response = await request;
21
+
22
+ if (!response.ok) {
23
+ requests[locale] = undefined;
24
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
25
+ }
26
+
27
+ const cache = await caches.open(DICTIONARY_CACHE);
28
+ await cache.put(url, response.clone());
29
+ await expirationManager.updateTimestamp(url);
30
+ } finally {
31
+ requests[locale] = undefined;
32
+ }
33
+ };
34
+
35
+ export default revalidateDictionary;
@@ -0,0 +1,26 @@
1
+ import { Trie } from '@kamilmielnik/trie';
2
+ import { Locale } from '@scrabble-solver/types';
3
+
4
+ import { getDictionary } from './dictionaries';
5
+
6
+ const cache: Partial<Record<Locale, { trie: Trie; dictionary: string } | undefined>> = {};
7
+
8
+ const getTrie = async (locale: Locale): Promise<Trie | undefined> => {
9
+ const dictionary = await getDictionary(locale);
10
+
11
+ if (typeof dictionary === 'undefined') {
12
+ return undefined;
13
+ }
14
+
15
+ const cached = cache[locale];
16
+
17
+ if (typeof cached === 'undefined' || cached.dictionary !== dictionary) {
18
+ const trie = Trie.deserialize(dictionary);
19
+ cache[locale] = { dictionary, trie };
20
+ return trie;
21
+ }
22
+
23
+ return cached.trie;
24
+ };
25
+
26
+ export default getTrie;
@@ -0,0 +1,22 @@
1
+ /// <reference lib="WebWorker" />
2
+
3
+ import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
4
+
5
+ import routeSolveRequests from './routeSolveRequests';
6
+ import routeVerifyRequests from './routeVerifyRequests';
7
+
8
+ declare const self: ServiceWorkerGlobalScope;
9
+
10
+ self.addEventListener('install', () => {
11
+ self.skipWaiting();
12
+ });
13
+
14
+ self.addEventListener('activate', () => {
15
+ self.clients.claim();
16
+ });
17
+
18
+ cleanupOutdatedCaches();
19
+ // eslint-disable-next-line no-underscore-dangle
20
+ precacheAndRoute(self.__WB_MANIFEST);
21
+ routeSolveRequests();
22
+ routeVerifyRequests();
@@ -0,0 +1,37 @@
1
+ import { getConfig } from '@scrabble-solver/configs';
2
+ import { BLANK } from '@scrabble-solver/constants';
3
+ import { solve } from '@scrabble-solver/solver';
4
+ import { Board, Locale, Tile } from '@scrabble-solver/types';
5
+ import { registerRoute } from 'workbox-routing';
6
+
7
+ import { revalidateDictionary } from './dictionaries';
8
+ import getTrie from './getTrie';
9
+
10
+ const headers = {
11
+ 'Content-Type': 'application/json; charset=utf-8',
12
+ };
13
+
14
+ const routeSolveRequests = () => {
15
+ registerRoute(
16
+ ({ url }) => url.origin === location.origin && url.pathname === '/api/solve',
17
+ async ({ request }) => {
18
+ const { board, characters, configId, locale } = await request.clone().json();
19
+ const trie = await getTrie(locale);
20
+
21
+ if (!trie) {
22
+ const response = await fetch(request);
23
+ revalidateDictionary(locale);
24
+ return response;
25
+ }
26
+
27
+ const config = getConfig(configId)[locale as Locale];
28
+ const tiles = characters.map((character: string) => new Tile({ character, isBlank: character === BLANK }));
29
+ const resultsJson = solve(trie, config, Board.fromJson(board), tiles);
30
+ const json = JSON.stringify(resultsJson);
31
+ return new Response(json, { headers });
32
+ },
33
+ 'POST',
34
+ );
35
+ };
36
+
37
+ export default routeSolveRequests;