@scrabble-solver/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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +13 -13
- package/.next/cache/.previewinfo +1 -1
- package/.next/cache/.rscinfo +1 -1
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/webpack/client-production/0.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
- package/.next/cache/webpack/server-production/0.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack.old +0 -0
- package/.next/diagnostics/framework.json +1 -1
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +4 -4
- package/.next/required-server-files.js +3 -2
- package/.next/required-server-files.json +3 -2
- package/.next/routes-manifest.json +1 -1
- package/.next/server/chunks/129.js +1 -0
- package/.next/server/chunks/30.js +1 -1
- package/.next/server/chunks/392.js +1 -1
- package/.next/server/chunks/671.js +1 -1
- package/.next/server/chunks/712.js +1 -1
- package/.next/server/chunks/785.js +1 -0
- package/.next/server/chunks/791.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/pages/_app.js +1 -1
- package/.next/server/pages/_app.js.nft.json +1 -1
- package/.next/server/pages/_error.js +1 -1
- package/.next/server/pages/_error.js.nft.json +1 -1
- package/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
- package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
- package/.next/server/pages/api/dictionary/[locale].js +1 -1
- package/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
- package/.next/server/pages/api/solve.js +1 -1
- package/.next/server/pages/api/solve.js.nft.json +1 -1
- package/.next/server/pages/api/verify.js +1 -1
- package/.next/server/pages/api/verify.js.nft.json +1 -1
- package/.next/server/pages/index.html +1 -1
- package/.next/server/pages/index.js +1 -1
- package/.next/server/pages/index.js.nft.json +1 -1
- package/.next/server/pages/index.json +1 -1
- package/.next/server/pages/not-found.html +1 -1
- package/.next/server/pages/not-found.js.nft.json +1 -1
- package/.next/server/pages-manifest.json +1 -1
- package/.next/server/webpack-runtime.js +1 -1
- package/.next/static/{vYpMmuC_yEurZGcyGVnLf → OLE8_ZInlGH7ENU0NIOpf}/_buildManifest.js +1 -1
- package/.next/static/chunks/583.7a46807be14c377e.js +1 -0
- package/.next/static/chunks/830.661816bf0de8c2f1.js +1 -0
- package/.next/static/chunks/{main-852475ca502b4e49.js → main-5b999f3f24ebfa84.js} +2 -2
- package/.next/static/chunks/pages/_app-7024e7a27ffbb2fa.js +7 -0
- package/.next/static/chunks/pages/{index-ec0f6aeeaf7be227.js → index-8b398b0a5d870773.js} +1 -1
- package/.next/static/chunks/webpack-3943d624bbb47a64.js +1 -0
- package/.next/static/css/aedeaada218d7280.css +2 -0
- package/.next/trace +21 -21
- package/.next/trace-build +1 -1
- package/next.config.js +0 -6
- package/package.json +11 -11
- package/src/components/Board/components/Cell/Cell.tsx +4 -1
- package/src/components/Board/hooks/useGrid.ts +17 -6
- package/src/components/Dictionary/Dictionary.module.scss +7 -22
- package/src/components/Dictionary/Dictionary.tsx +1 -8
- package/src/components/Results/Result.tsx +0 -3
- package/src/components/Results/Results.module.scss +0 -1
- package/src/components/Results/Results.tsx +10 -2
- package/src/components/Results/types.ts +3 -3
- package/src/components/Solver/Solver.tsx +4 -3
- package/src/components/Tooltip/useTooltip.ts +1 -1
- package/src/pages/api/dictionary/[locale]/[word].ts +2 -2
- package/src/pages/api/dictionary/[locale]/index.ts +80 -3
- package/src/pages/api/solve.ts +2 -2
- package/src/pages/api/verify.ts +12 -3
- package/src/sdk/solve.ts +13 -7
- package/src/sdk/verify.ts +12 -6
- package/src/service-worker/appShell.test.ts +86 -0
- package/src/service-worker/appShell.ts +39 -0
- package/src/service-worker/index.ts +4 -5
- package/src/serviceWorkerManager.ts +1 -18
- package/src/solver-worker/client.test.ts +151 -0
- package/src/solver-worker/client.ts +106 -0
- package/src/solver-worker/dictionaries/constants.ts +9 -0
- package/src/solver-worker/dictionaries/getDictionary.ts +21 -0
- package/src/solver-worker/dictionaries/index.ts +2 -0
- package/src/solver-worker/dictionaries/revalidateDictionary.test.ts +199 -0
- package/src/solver-worker/dictionaries/revalidateDictionary.ts +86 -0
- package/src/solver-worker/dictionaries/revalidatedAt.ts +43 -0
- package/src/solver-worker/getGaddag.test.ts +109 -0
- package/src/solver-worker/getGaddag.ts +62 -0
- package/src/solver-worker/index.ts +1 -0
- package/src/solver-worker/index.worker.ts +124 -0
- package/src/solver-worker/messages.ts +20 -0
- package/src/state/index.ts +1 -0
- package/src/state/sagas.ts +19 -9
- package/src/state/useTypedStore.ts +6 -0
- package/src/types/api.ts +1 -2
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/.next/static/chunks/pages/_app-a5bbe02710f13d05.js +0 -7
- package/.next/static/chunks/webpack-1a7a166b34ed8d24.js +0 -1
- package/.next/static/css/0af35adb4bab56f0.css +0 -2
- package/src/sdk/getDictionary.ts +0 -9
- package/src/service-worker/average.ts +0 -7
- package/src/service-worker/dictionaries/constants.ts +0 -5
- package/src/service-worker/dictionaries/getDictionary.ts +0 -20
- package/src/service-worker/dictionaries/index.ts +0 -2
- package/src/service-worker/dictionaries/revalidateDictionary.ts +0 -33
- package/src/service-worker/getTrie.ts +0 -24
- package/src/service-worker/routeSolveRequests.ts +0 -91
- package/src/service-worker/routeVerifyRequests.ts +0 -37
- /package/.next/static/{vYpMmuC_yEurZGcyGVnLf → OLE8_ZInlGH7ENU0NIOpf}/_ssgManifest.js +0 -0
- /package/src/{service-worker → solver-worker}/dictionaries/expirationManager.ts +0 -0
- /package/src/{service-worker → solver-worker}/dictionaries/getDictionaryUrl.ts +0 -0
|
@@ -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';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/// <reference lib="WebWorker" />
|
|
2
|
+
|
|
3
|
+
import { getConfig } from '@scrabble-solver/configs';
|
|
4
|
+
import { BLANK } from '@scrabble-solver/constants';
|
|
5
|
+
import { solve } from '@scrabble-solver/solver';
|
|
6
|
+
import { Board, type Locale, Tile } from '@scrabble-solver/types';
|
|
7
|
+
|
|
8
|
+
import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
|
|
9
|
+
|
|
10
|
+
import { revalidateDictionary } from './dictionaries';
|
|
11
|
+
import { getGaddag } from './getGaddag';
|
|
12
|
+
import { type SolverWorkerRequest, type SolverWorkerResponse } from './messages';
|
|
13
|
+
|
|
14
|
+
declare const self: DedicatedWorkerGlobalScope;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Solves run synchronously on the single worker thread, so a burst of requests
|
|
18
|
+
* would queue stale solves ahead of the latest one.
|
|
19
|
+
*/
|
|
20
|
+
let latestSolveId = 0;
|
|
21
|
+
|
|
22
|
+
self.addEventListener('message', ({ data }: MessageEvent<SolverWorkerRequest>) => {
|
|
23
|
+
if (data.type === 'solve') {
|
|
24
|
+
latestSolveId = data.id;
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
26
|
+
respond(data.id, () => handleSolve(data.id, data.payload));
|
|
27
|
+
} else if (data.type === 'verify') {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
29
|
+
respond(data.id, () => handleVerify(data.id, data.payload));
|
|
30
|
+
} else if (data.type === 'prefetch') {
|
|
31
|
+
prefetchDictionary(data.locale).catch(() => undefined);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function handleSolve(id: number, payload: SolveRequestPayload): Promise<SolverWorkerResponse> {
|
|
36
|
+
const { board, characters, game, locale } = payload;
|
|
37
|
+
const gaddag = await getGaddag(locale);
|
|
38
|
+
revalidateInBackground(locale);
|
|
39
|
+
|
|
40
|
+
if (!gaddag) {
|
|
41
|
+
return { id, outcome: 'unavailable' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await yieldToQueuedMessages();
|
|
45
|
+
|
|
46
|
+
if (latestSolveId !== id) {
|
|
47
|
+
return { id, outcome: 'superseded' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const config = getConfig(game, locale);
|
|
51
|
+
const tiles = characters.map((character) => new Tile({ character, isBlank: character === BLANK }));
|
|
52
|
+
return { data: solve(gaddag, config, Board.fromJson(board), tiles), id, outcome: 'answered' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function handleVerify(
|
|
56
|
+
id: number,
|
|
57
|
+
{ board: boardJson, locale }: VerifyRequestPayload,
|
|
58
|
+
): Promise<SolverWorkerResponse> {
|
|
59
|
+
const gaddag = await getGaddag(locale);
|
|
60
|
+
revalidateInBackground(locale);
|
|
61
|
+
|
|
62
|
+
if (!gaddag) {
|
|
63
|
+
return { id, outcome: 'unavailable' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const board = Board.fromJson(boardJson);
|
|
67
|
+
const words = board.getWords().sort((a, b) => a.localeCompare(b, locale));
|
|
68
|
+
const invalidWords: string[] = [];
|
|
69
|
+
const validWords: string[] = [];
|
|
70
|
+
|
|
71
|
+
for (const word of words) {
|
|
72
|
+
if (gaddag.has(word)) {
|
|
73
|
+
validWords.push(word);
|
|
74
|
+
} else {
|
|
75
|
+
invalidWords.push(word);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { data: { invalidWords, validWords }, id, outcome: 'answered' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Deserializes the dictionary ahead of the first solve, so it does not pay the
|
|
84
|
+
* cold-start cost. Warms even when revalidation fails (e.g. offline) - a
|
|
85
|
+
* previously cached dictionary can still be deserialized.
|
|
86
|
+
*/
|
|
87
|
+
async function prefetchDictionary(locale: Locale): Promise<void> {
|
|
88
|
+
try {
|
|
89
|
+
await revalidateDictionary(locale);
|
|
90
|
+
} catch {
|
|
91
|
+
// Solving falls back to the server until a later revalidation succeeds.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await getGaddag(locale);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Revalidation failures are expected (offline, server down) and must not
|
|
99
|
+
* surface as unhandled rejections - solving keeps using the cached dictionary
|
|
100
|
+
* until a later revalidation succeeds.
|
|
101
|
+
*/
|
|
102
|
+
function revalidateInBackground(locale: Locale): void {
|
|
103
|
+
revalidateDictionary(locale).catch(() => undefined);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function respond(id: number, handle: () => Promise<SolverWorkerResponse>): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
self.postMessage(await handle());
|
|
109
|
+
} catch {
|
|
110
|
+
self.postMessage({ id, outcome: 'unavailable' } satisfies SolverWorkerResponse);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Messages queued behind this handler dispatch only between macrotasks - a
|
|
116
|
+
* MessageChannel round-trip lets them register before an expensive solve.
|
|
117
|
+
*/
|
|
118
|
+
function yieldToQueuedMessages(): Promise<void> {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const { port1, port2 } = new MessageChannel();
|
|
121
|
+
port1.onmessage = () => resolve();
|
|
122
|
+
port2.postMessage(null);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Locale, type ResultJson } from '@scrabble-solver/types';
|
|
2
|
+
|
|
3
|
+
import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
|
|
4
|
+
|
|
5
|
+
export interface VerifyResult {
|
|
6
|
+
invalidWords: string[];
|
|
7
|
+
validWords: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SolverWorkerRequest =
|
|
11
|
+
| { id: number; type: 'prefetch'; locale: Locale }
|
|
12
|
+
| { id: number; type: 'solve'; payload: SolveRequestPayload }
|
|
13
|
+
| { id: number; type: 'verify'; payload: VerifyRequestPayload };
|
|
14
|
+
|
|
15
|
+
export type SolverWorkerResponse =
|
|
16
|
+
| { id: number; outcome: 'answered'; data: ResultJson[] | VerifyResult }
|
|
17
|
+
/** No cached dictionary, or an internal error - the caller falls back to the server. */
|
|
18
|
+
| { id: number; outcome: 'unavailable' }
|
|
19
|
+
/** A newer solve replaced this one - the newer answer is the one worth waiting for. */
|
|
20
|
+
| { id: number; outcome: 'superseded' };
|
package/src/state/index.ts
CHANGED
package/src/state/sagas.ts
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
import { type PayloadAction } from '@reduxjs/toolkit';
|
|
7
7
|
import { hasConfig, languages } from '@scrabble-solver/configs';
|
|
8
8
|
import { Board, type Locale, type Result } from '@scrabble-solver/types';
|
|
9
|
-
import { call, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects';
|
|
9
|
+
import { call, delay, put, select, spawn, takeEvery, takeLatest } from 'redux-saga/effects';
|
|
10
10
|
|
|
11
11
|
import { LOCALE_FEATURES } from '@/i18n';
|
|
12
12
|
import { memoize } from '@/lib';
|
|
13
13
|
import { findWordDefinitions, solve, verify, visit } from '@/sdk';
|
|
14
|
+
import { prefetchDictionary } from '@/solver-worker';
|
|
14
15
|
|
|
15
16
|
import { initialize, reset } from './actions';
|
|
16
17
|
import { boardSlice, selectBoard } from './board';
|
|
@@ -41,7 +42,7 @@ export function* rootSaga(): AnyGenerator {
|
|
|
41
42
|
yield takeEvery(boardSlice.actions.changeCellValue.type, onCellValueChange);
|
|
42
43
|
yield takeEvery([rackSlice.actions.changeCharacter.type, rackSlice.actions.changeCharacters.type], onRackValueChange);
|
|
43
44
|
yield takeEvery(resultsSlice.actions.applyResult.type, onApplyResult);
|
|
44
|
-
yield
|
|
45
|
+
yield takeLatest(resultsSlice.actions.changeResultCandidate.type, onResultCandidateChange);
|
|
45
46
|
yield takeEvery(settingsSlice.actions.changeGame.type, onGameChange);
|
|
46
47
|
yield takeEvery(settingsSlice.actions.changeLocale.type, onLocaleChange);
|
|
47
48
|
yield takeLatest(dictionarySlice.actions.submit.type, onDictionarySubmit);
|
|
@@ -120,8 +121,11 @@ function* onDictionarySubmit(): AnyGenerator {
|
|
|
120
121
|
|
|
121
122
|
function* onInitialize(): AnyGenerator {
|
|
122
123
|
const board = yield select(selectBoard);
|
|
124
|
+
const locale = yield select(selectLocale);
|
|
123
125
|
|
|
124
|
-
yield
|
|
126
|
+
yield spawn(prefetchDictionary, locale);
|
|
127
|
+
// Detached: offline, a failed visit would otherwise tear down the whole root saga.
|
|
128
|
+
yield spawn(visit);
|
|
125
129
|
|
|
126
130
|
if (!board.isEmpty()) {
|
|
127
131
|
yield* resetRack();
|
|
@@ -142,6 +146,8 @@ function* onReset(): AnyGenerator {
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
function* onLocaleChange({ payload: locale }: PayloadAction<Locale>): AnyGenerator {
|
|
149
|
+
yield spawn(prefetchDictionary, locale);
|
|
150
|
+
|
|
145
151
|
const game = yield select(selectGame);
|
|
146
152
|
|
|
147
153
|
if (!hasConfig(game, locale)) {
|
|
@@ -166,13 +172,17 @@ function* onLocaleChange({ payload: locale }: PayloadAction<Locale>): AnyGenerat
|
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
function* onResultCandidateChange({ payload: result }: PayloadAction<Result | null>): AnyGenerator {
|
|
169
|
-
if (result) {
|
|
170
|
-
|
|
171
|
-
const uniqueWords = Array.from(new Set(result.words));
|
|
172
|
-
const input = uniqueWords.join(LOCALE_FEATURES[locale].separator);
|
|
173
|
-
yield put(dictionarySlice.actions.changeInput(input));
|
|
174
|
-
yield put(dictionarySlice.actions.submit());
|
|
175
|
+
if (!result) {
|
|
176
|
+
return;
|
|
175
177
|
}
|
|
178
|
+
|
|
179
|
+
yield delay(SUBMIT_DELAY);
|
|
180
|
+
|
|
181
|
+
const locale: Locale = yield select(selectLocale);
|
|
182
|
+
const uniqueWords = Array.from(new Set(result.words));
|
|
183
|
+
const input = uniqueWords.join(LOCALE_FEATURES[locale].separator);
|
|
184
|
+
yield put(dictionarySlice.actions.changeInput(input));
|
|
185
|
+
yield put(dictionarySlice.actions.submit());
|
|
176
186
|
}
|
|
177
187
|
|
|
178
188
|
function* onSolve(): AnyGenerator {
|
package/src/types/api.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { type BoardJson, type
|
|
1
|
+
import { type BoardJson, type Game, type Locale } from '@scrabble-solver/types';
|
|
2
2
|
|
|
3
3
|
export interface SolveRequestPayload {
|
|
4
4
|
board: BoardJson;
|
|
5
5
|
characters: string[];
|
|
6
|
-
config: ConfigJson;
|
|
7
6
|
game: Game;
|
|
8
7
|
locale: Locale;
|
|
9
8
|
}
|
package/tsconfig.json
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
"isolatedModules": true,
|
|
8
8
|
"jsx": "preserve",
|
|
9
9
|
"lib": ["dom", "dom.iterable", "esnext"],
|
|
10
|
+
// Webpack bundles this package (import.meta worker URLs need an ES module
|
|
11
|
+
// target), unlike the nodenext library packages.
|
|
12
|
+
"module": "esnext",
|
|
13
|
+
"moduleResolution": "bundler",
|
|
10
14
|
"noEmit": true,
|
|
11
15
|
"paths": {
|
|
12
16
|
"@/*": ["./src/*"]
|