scrabble-solver 2.16.6 → 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/.github/workflows/bunx.yml +26 -2
- package/.github/workflows/npx.yml +26 -2
- package/.nx/workspace-data/d/server-process.json +3 -3
- package/.nx/workspace-data/file-map.json +441 -465
- package/.nx/workspace-data/lockfile-dependencies.hash +1 -1
- package/.nx/workspace-data/lockfile-nodes.hash +1 -1
- package/.nx/workspace-data/nx_files.nxt +0 -0
- package/.nx/workspace-data/parsed-lock-file.dependencies.json +2671 -2121
- package/.nx/workspace-data/parsed-lock-file.nodes.json +3846 -5277
- package/.nx/workspace-data/project-graph.json +7089 -7924
- package/.nx/workspace-data/source-maps.json +351 -255
- package/.oxlintrc.json +6 -0
- package/CLAUDE.md +25 -20
- package/README.md +9 -0
- package/bun.lock +1054 -2897
- package/bunfig.toml +6 -0
- package/cypress/e2e/app.spec.cy.ts +4 -4
- package/cypress/e2e/features/solver-paths.ts +66 -0
- package/cypress/e2e/features.spec.cy.ts +1 -0
- package/cypress/support/lib/actions.ts +28 -9
- package/cypress.config.ts +7 -0
- package/lerna.json +1 -1
- package/nx.json +3 -1
- package/package.json +18 -18
- package/packages/configs/package.json +4 -4
- package/packages/constants/package.json +2 -2
- package/packages/dictionaries/README.md +1 -1
- package/packages/dictionaries/build/Dictionaries.d.ts +2 -2
- package/packages/dictionaries/build/Dictionaries.js +6 -6
- package/packages/dictionaries/build/lib/DiskCache.d.ts +11 -4
- package/packages/dictionaries/build/lib/DiskCache.js +27 -10
- package/packages/dictionaries/build/lib/LayeredCache.d.ts +4 -4
- package/packages/dictionaries/build/lib/LayeredCache.js +3 -3
- package/packages/dictionaries/build/lib/MemoryCache.d.ts +4 -4
- package/packages/dictionaries/build/lib/MemoryCache.js +2 -2
- package/packages/dictionaries/build/lib/createCacheTimestampComparator.d.ts +2 -2
- package/packages/dictionaries/build/lib/downloadDictionary.d.ts +2 -2
- package/packages/dictionaries/build/lib/downloadDictionary.js +4 -3
- package/packages/dictionaries/build/lib/getDictionaryFilepath.d.ts +3 -1
- package/packages/dictionaries/build/lib/getDictionaryFilepath.js +8 -3
- package/packages/dictionaries/package.json +8 -7
- package/packages/dictionaries/src/Dictionaries.ts +12 -12
- package/packages/dictionaries/src/lib/DiskCache.test.ts +85 -0
- package/packages/dictionaries/src/lib/DiskCache.ts +34 -14
- package/packages/dictionaries/src/lib/LayeredCache.ts +7 -7
- package/packages/dictionaries/src/lib/MemoryCache.ts +6 -6
- package/packages/dictionaries/src/lib/createCacheTimestampComparator.ts +2 -2
- package/packages/dictionaries/src/lib/downloadDictionary.ts +5 -4
- package/packages/dictionaries/src/lib/getDictionaryFilepath.ts +7 -2
- package/packages/gaddag/build/Gaddag.d.ts +39 -0
- package/packages/gaddag/build/Gaddag.js +128 -0
- package/packages/gaddag/build/buildGaddag.d.ts +10 -0
- package/packages/gaddag/build/buildGaddag.js +360 -0
- package/packages/gaddag/build/constants.d.ts +12 -0
- package/packages/gaddag/build/constants.js +15 -0
- package/packages/gaddag/build/index.d.ts +3 -0
- package/packages/gaddag/build/index.js +13 -0
- package/packages/logger/package.json +2 -2
- package/packages/scrabble-solver/.next/BUILD_ID +1 -1
- package/packages/scrabble-solver/.next/build-manifest.json +17 -17
- package/packages/scrabble-solver/.next/cache/.previewinfo +1 -1
- package/packages/scrabble-solver/.next/cache/.rscinfo +1 -1
- package/packages/scrabble-solver/.next/cache/.tsbuildinfo +1 -1
- package/packages/scrabble-solver/.next/cache/webpack/client-production/0.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/client-production/index.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/server-production/0.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/server-production/index.pack +0 -0
- package/packages/scrabble-solver/.next/cache/webpack/server-production/index.pack.old +0 -0
- package/packages/scrabble-solver/.next/diagnostics/framework.json +1 -1
- package/packages/scrabble-solver/.next/next-minimal-server.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/next-server.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/prerender-manifest.json +7 -4
- package/packages/scrabble-solver/.next/required-server-files.js +29 -10
- package/packages/scrabble-solver/.next/required-server-files.json +29 -10
- package/packages/scrabble-solver/.next/routes-manifest.json +1 -1
- package/packages/scrabble-solver/.next/server/chunks/129.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/30.js +2 -2
- package/packages/scrabble-solver/.next/server/chunks/318.js +3 -3
- package/packages/scrabble-solver/.next/server/chunks/392.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/671.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/712.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/785.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/791.js +3 -3
- package/packages/scrabble-solver/.next/server/middleware-build-manifest.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/404.html +1 -1
- package/packages/scrabble-solver/.next/server/pages/500.html +1 -1
- package/packages/scrabble-solver/.next/server/pages/_app.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/_app.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/_document.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/_error.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/_error.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale].js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/solve.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/solve.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/verify.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/verify.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/visit.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/api/visit.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/index.html +1 -1
- package/packages/scrabble-solver/.next/server/pages/index.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/index.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/index.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/not-found.html +1 -1
- package/packages/scrabble-solver/.next/server/pages/not-found.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages-manifest.json +1 -1
- package/packages/scrabble-solver/.next/server/webpack-runtime.js +1 -1
- package/packages/scrabble-solver/.next/static/{js6-w0PwySs2PyOSeCYfi → OLE8_ZInlGH7ENU0NIOpf}/_buildManifest.js +1 -1
- package/packages/scrabble-solver/.next/static/chunks/583.7a46807be14c377e.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/830.661816bf0de8c2f1.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/framework-3cf15b8041c59dd3.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/main-5b999f3f24ebfa84.js +5 -0
- package/packages/scrabble-solver/.next/static/chunks/pages/_app-7024e7a27ffbb2fa.js +7 -0
- package/packages/scrabble-solver/.next/static/chunks/pages/{index-bdf5e1e44cea16f6.js → index-8b398b0a5d870773.js} +1 -1
- package/packages/scrabble-solver/.next/static/chunks/webpack-3943d624bbb47a64.js +1 -0
- package/packages/scrabble-solver/.next/static/css/aedeaada218d7280.css +2 -0
- package/packages/scrabble-solver/.next/trace +21 -21
- package/packages/scrabble-solver/.next/trace-build +1 -1
- package/packages/scrabble-solver/.next/types/cache-life.d.ts +1 -0
- package/packages/scrabble-solver/.next/types/root-params.d.ts +3 -0
- package/packages/scrabble-solver/next-env.d.ts +1 -0
- package/packages/scrabble-solver/next.config.js +0 -1
- package/packages/scrabble-solver/package.json +19 -19
- package/packages/scrabble-solver/public/service-worker.js +2 -2
- package/packages/scrabble-solver/public/service-worker.js.map +1 -1
- package/packages/scrabble-solver/src/components/Board/components/Cell/Cell.tsx +4 -1
- package/packages/scrabble-solver/src/components/Board/hooks/useGrid.ts +17 -6
- package/packages/scrabble-solver/src/components/Dictionary/Dictionary.module.scss +7 -22
- package/packages/scrabble-solver/src/components/Dictionary/Dictionary.tsx +1 -8
- package/packages/scrabble-solver/src/components/Results/Result.tsx +0 -3
- package/packages/scrabble-solver/src/components/Results/Results.module.scss +0 -1
- package/packages/scrabble-solver/src/components/Results/Results.tsx +10 -2
- package/packages/scrabble-solver/src/components/Results/types.ts +3 -3
- package/packages/scrabble-solver/src/components/Solver/Solver.tsx +4 -3
- package/packages/scrabble-solver/src/components/Tooltip/useTooltip.ts +1 -1
- package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/[word].ts +2 -2
- package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/index.ts +80 -3
- package/packages/scrabble-solver/src/pages/api/solve.ts +2 -2
- package/packages/scrabble-solver/src/pages/api/verify.ts +12 -3
- package/packages/scrabble-solver/src/sdk/solve.ts +13 -7
- package/packages/scrabble-solver/src/sdk/verify.ts +12 -6
- package/packages/scrabble-solver/src/service-worker/appShell.test.ts +86 -0
- package/packages/scrabble-solver/src/service-worker/appShell.ts +39 -0
- package/packages/scrabble-solver/src/service-worker/index.ts +4 -5
- package/packages/scrabble-solver/src/serviceWorkerManager.ts +1 -18
- package/packages/scrabble-solver/src/solver-worker/client.test.ts +151 -0
- package/packages/scrabble-solver/src/solver-worker/client.ts +106 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/constants.ts +9 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/getDictionary.ts +21 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/index.ts +2 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidateDictionary.test.ts +199 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidateDictionary.ts +86 -0
- package/packages/scrabble-solver/src/solver-worker/dictionaries/revalidatedAt.ts +43 -0
- package/packages/scrabble-solver/src/solver-worker/getGaddag.test.ts +109 -0
- package/packages/scrabble-solver/src/solver-worker/getGaddag.ts +62 -0
- package/packages/scrabble-solver/src/solver-worker/index.ts +1 -0
- package/packages/scrabble-solver/src/solver-worker/index.worker.ts +124 -0
- package/packages/scrabble-solver/src/solver-worker/messages.ts +20 -0
- package/packages/scrabble-solver/src/state/index.ts +1 -0
- package/packages/scrabble-solver/src/state/sagas.ts +19 -9
- package/packages/scrabble-solver/src/state/useTypedStore.ts +6 -0
- package/packages/scrabble-solver/src/types/api.ts +1 -2
- package/packages/scrabble-solver/tsconfig.json +4 -0
- package/packages/scrabble-solver/tsconfig.tsbuildinfo +1 -1
- package/packages/solver/README.md +24 -0
- package/packages/solver/benchmarks/index.ts +15 -0
- package/packages/solver/benchmarks/lib.ts +31 -0
- package/packages/solver/benchmarks/renderChart.ts +165 -0
- package/packages/solver/benchmarks/results/chart.svg +65 -0
- package/packages/solver/benchmarks/runBenchmarks.ts +66 -0
- package/packages/solver/benchmarks/scenarios.ts +64 -0
- package/packages/solver/benchmarks/types.ts +16 -0
- package/packages/solver/benchmarks/updateReadme.ts +53 -0
- package/packages/solver/build/MoveGenerator.d.ts +116 -0
- package/packages/solver/build/MoveGenerator.js +686 -0
- package/packages/solver/build/index.d.ts +0 -13
- package/packages/solver/build/index.js +1 -27
- package/packages/solver/build/solve.d.ts +2 -2
- package/packages/solver/build/solve.js +3 -19
- package/packages/solver/package.json +8 -7
- package/packages/solver/src/MoveGenerator.test.ts +1009 -0
- package/packages/solver/src/MoveGenerator.ts +926 -0
- package/packages/solver/src/ReferenceSolver.ts +401 -0
- package/packages/solver/src/index.ts +0 -13
- package/packages/solver/src/solve.test.ts +21 -21
- package/packages/solver/src/solve.ts +4 -22
- package/packages/solver/tsconfig.json +1 -1
- package/packages/types/build/Cell.d.ts +1 -0
- package/packages/types/build/Cell.js +3 -0
- package/packages/types/build/Result.d.ts +3 -2
- package/packages/types/build/Result.js +11 -6
- package/packages/types/build/ResultJson.d.ts +5 -4
- package/packages/types/build/index.d.ts +0 -6
- package/packages/types/build/index.js +1 -9
- package/packages/types/build/readResultCells.d.ts +5 -0
- package/packages/types/build/readResultCells.js +75 -0
- package/packages/types/package.json +5 -4
- package/packages/types/src/Cell.ts +4 -0
- package/packages/types/src/Result.ts +15 -6
- package/packages/types/src/ResultJson.ts +5 -5
- package/packages/types/src/index.ts +0 -6
- package/packages/types/src/readResultCells.test.ts +140 -0
- package/packages/types/src/readResultCells.ts +90 -0
- package/packages/word-definitions/package.json +3 -3
- package/packages/word-lists/package.json +4 -4
- package/packages/scrabble-solver/.next/static/chunks/framework-a23f7f643b59a2ca.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/main-92448bb08bb6bf1f.js +0 -5
- package/packages/scrabble-solver/.next/static/chunks/pages/_app-4ae86f006f6b9fe7.js +0 -7
- package/packages/scrabble-solver/.next/static/chunks/webpack-1a7a166b34ed8d24.js +0 -1
- package/packages/scrabble-solver/.next/static/css/0af35adb4bab56f0.css +0 -2
- package/packages/scrabble-solver/src/sdk/getDictionary.ts +0 -9
- package/packages/scrabble-solver/src/service-worker/average.ts +0 -7
- package/packages/scrabble-solver/src/service-worker/dictionaries/constants.ts +0 -5
- package/packages/scrabble-solver/src/service-worker/dictionaries/getDictionary.ts +0 -20
- package/packages/scrabble-solver/src/service-worker/dictionaries/index.ts +0 -2
- package/packages/scrabble-solver/src/service-worker/dictionaries/revalidateDictionary.ts +0 -33
- package/packages/scrabble-solver/src/service-worker/getTrie.ts +0 -24
- package/packages/scrabble-solver/src/service-worker/routeSolveRequests.ts +0 -91
- package/packages/scrabble-solver/src/service-worker/routeVerifyRequests.ts +0 -37
- package/packages/solver/build/areDigraphsValid.d.ts +0 -2
- package/packages/solver/build/areDigraphsValid.js +0 -17
- package/packages/solver/build/fillPattern.d.ts +0 -6
- package/packages/solver/build/fillPattern.js +0 -52
- package/packages/solver/build/generateEndIndices.d.ts +0 -2
- package/packages/solver/build/generateEndIndices.js +0 -17
- package/packages/solver/build/generateHorizontalPatterns.d.ts +0 -2
- package/packages/solver/build/generateHorizontalPatterns.js +0 -16
- package/packages/solver/build/generatePattern.d.ts +0 -7
- package/packages/solver/build/generatePattern.js +0 -20
- package/packages/solver/build/generatePatterns.d.ts +0 -2
- package/packages/solver/build/generatePatterns.js +0 -11
- package/packages/solver/build/generateStartIndices.d.ts +0 -2
- package/packages/solver/build/generateStartIndices.js +0 -16
- package/packages/solver/build/generateVectors.d.ts +0 -7
- package/packages/solver/build/generateVectors.js +0 -7
- package/packages/solver/build/generateVerticalPatterns.d.ts +0 -2
- package/packages/solver/build/generateVerticalPatterns.js +0 -16
- package/packages/solver/build/getCellsScore.d.ts +0 -2
- package/packages/solver/build/getCellsScore.js +0 -18
- package/packages/solver/build/getPatternHash.d.ts +0 -2
- package/packages/solver/build/getPatternHash.js +0 -14
- package/packages/solver/build/getPatternScore.d.ts +0 -2
- package/packages/solver/build/getPatternScore.js +0 -22
- package/packages/solver/build/getUniquePatterns.d.ts +0 -2
- package/packages/solver/build/getUniquePatterns.js +0 -17
- package/packages/solver/src/areDigraphsValid.ts +0 -18
- package/packages/solver/src/fillPattern.test.ts +0 -107
- package/packages/solver/src/fillPattern.ts +0 -68
- package/packages/solver/src/generateEndIndices.test.ts +0 -23
- package/packages/solver/src/generateEndIndices.ts +0 -19
- package/packages/solver/src/generateHorizontalPatterns.test.ts +0 -31
- package/packages/solver/src/generateHorizontalPatterns.ts +0 -15
- package/packages/solver/src/generatePattern.ts +0 -33
- package/packages/solver/src/generatePatterns.test.ts +0 -13
- package/packages/solver/src/generatePatterns.ts +0 -11
- package/packages/solver/src/generateStartIndices.test.ts +0 -19
- package/packages/solver/src/generateStartIndices.ts +0 -17
- package/packages/solver/src/generateVectors.test.ts +0 -12
- package/packages/solver/src/generateVectors.ts +0 -10
- package/packages/solver/src/generateVerticalPatterns.test.ts +0 -32
- package/packages/solver/src/generateVerticalPatterns.ts +0 -15
- package/packages/solver/src/getCellsScore.ts +0 -21
- package/packages/solver/src/getPatternHash.test.ts +0 -17
- package/packages/solver/src/getPatternHash.ts +0 -12
- package/packages/solver/src/getPatternScore.test.ts +0 -204
- package/packages/solver/src/getPatternScore.ts +0 -23
- package/packages/solver/src/getUniquePatterns.ts +0 -19
- package/packages/types/build/CollisionJson.d.ts +0 -2
- package/packages/types/build/CollisionJson.js +0 -2
- package/packages/types/build/FinalPattern.d.ts +0 -6
- package/packages/types/build/FinalPattern.js +0 -14
- package/packages/types/build/HorizontalPattern.d.ts +0 -5
- package/packages/types/build/HorizontalPattern.js +0 -34
- package/packages/types/build/Pattern.d.ts +0 -20
- package/packages/types/build/Pattern.js +0 -50
- package/packages/types/build/PatternJson.d.ts +0 -6
- package/packages/types/build/PatternJson.js +0 -2
- package/packages/types/build/VerticalPattern.d.ts +0 -5
- package/packages/types/build/VerticalPattern.js +0 -34
- package/packages/types/src/CollisionJson.ts +0 -3
- package/packages/types/src/FinalPattern.ts +0 -14
- package/packages/types/src/HorizontalPattern.ts +0 -37
- package/packages/types/src/Pattern.ts +0 -71
- package/packages/types/src/PatternJson.ts +0 -7
- package/packages/types/src/VerticalPattern.ts +0 -37
- /package/packages/scrabble-solver/.next/static/{js6-w0PwySs2PyOSeCYfi → OLE8_ZInlGH7ENU0NIOpf}/_ssgManifest.js +0 -0
- /package/packages/scrabble-solver/src/{service-worker → solver-worker}/dictionaries/expirationManager.ts +0 -0
- /package/packages/scrabble-solver/src/{service-worker → solver-worker}/dictionaries/getDictionaryUrl.ts +0 -0
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type Locale, type ResultJson } from '@scrabble-solver/types';
|
|
2
|
+
|
|
3
|
+
import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
|
|
4
|
+
|
|
5
|
+
import { type SolverWorkerRequest, type SolverWorkerResponse, type VerifyResult } from './messages';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A worker that never answers - killed under memory pressure, or a message lost
|
|
9
|
+
* with it - must not hold the UI in a loading state with no server fallback.
|
|
10
|
+
*/
|
|
11
|
+
const REQUEST_TIMEOUT = 30 * 1000;
|
|
12
|
+
|
|
13
|
+
interface PendingRequest {
|
|
14
|
+
resolve: (response: SolverWorkerResponse) => void;
|
|
15
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let worker: Worker | undefined;
|
|
19
|
+
let nextId = 0;
|
|
20
|
+
let latestSolveId = 0;
|
|
21
|
+
let latestSolve: Promise<ResultJson[] | undefined> = Promise.resolve(undefined);
|
|
22
|
+
const pending = new Map<number, PendingRequest>();
|
|
23
|
+
|
|
24
|
+
export function solveLocally(payload: SolveRequestPayload): Promise<ResultJson[] | undefined> {
|
|
25
|
+
const id = ++nextId;
|
|
26
|
+
const solve = readSolveResponse(id, payload);
|
|
27
|
+
latestSolveId = id;
|
|
28
|
+
latestSolve = solve;
|
|
29
|
+
return solve;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function verifyLocally(payload: VerifyRequestPayload): Promise<VerifyResult | undefined> {
|
|
33
|
+
const response = await request({ id: ++nextId, type: 'verify', payload });
|
|
34
|
+
return response.outcome === 'answered' ? (response.data as VerifyResult) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function prefetchDictionary(locale: Locale): void {
|
|
38
|
+
getWorker()?.postMessage({ id: ++nextId, locale, type: 'prefetch' } satisfies SolverWorkerRequest);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readSolveResponse(id: number, payload: SolveRequestPayload): Promise<ResultJson[] | undefined> {
|
|
42
|
+
const response = await request({ id, type: 'solve', payload });
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The worker skipped this solve for a newer one; answer with the newer result
|
|
46
|
+
* rather than an empty list a caller could render as "no moves found".
|
|
47
|
+
*/
|
|
48
|
+
if (response.outcome === 'superseded' && id !== latestSolveId) {
|
|
49
|
+
return latestSolve;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return response.outcome === 'answered' ? (response.data as ResultJson[]) : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function request(message: SolverWorkerRequest): Promise<SolverWorkerResponse> {
|
|
56
|
+
const targetWorker = getWorker();
|
|
57
|
+
|
|
58
|
+
if (!targetWorker) {
|
|
59
|
+
return Promise.resolve({ id: message.id, outcome: 'unavailable' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const timeout = setTimeout(() => settle({ id: message.id, outcome: 'unavailable' }), REQUEST_TIMEOUT);
|
|
64
|
+
pending.set(message.id, { resolve, timeout });
|
|
65
|
+
targetWorker.postMessage(message);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function settle(response: SolverWorkerResponse): void {
|
|
70
|
+
const entry = pending.get(response.id);
|
|
71
|
+
|
|
72
|
+
if (!entry) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pending.delete(response.id);
|
|
77
|
+
clearTimeout(entry.timeout);
|
|
78
|
+
entry.resolve(response);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getWorker(): Worker | undefined {
|
|
82
|
+
if (typeof Worker === 'undefined' || !('caches' in globalThis)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!worker) {
|
|
87
|
+
worker = new Worker(new URL('./index.worker.ts', import.meta.url));
|
|
88
|
+
worker.addEventListener('message', ({ data }: MessageEvent<SolverWorkerResponse>) => settle(data));
|
|
89
|
+
worker.addEventListener('error', handleWorkerFailure);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return worker;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Settles every in-flight request as locally unanswerable - callers fall back
|
|
97
|
+
* to the server - and discards the worker so the next call starts a fresh one.
|
|
98
|
+
*/
|
|
99
|
+
function handleWorkerFailure(): void {
|
|
100
|
+
for (const id of pending.keys()) {
|
|
101
|
+
settle({ id, outcome: 'unavailable' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
worker?.terminate();
|
|
105
|
+
worker = undefined;
|
|
106
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const DICTIONARY_CACHE = 'dictionary-api-cache';
|
|
2
|
+
|
|
3
|
+
export const REVALIDATED_AT_CACHE = 'dictionary-revalidated-at';
|
|
4
|
+
|
|
5
|
+
export const DAY = 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
// Staleness is handled by conditional revalidation; expiration only cleans up
|
|
8
|
+
// dictionaries of locales the user no longer plays.
|
|
9
|
+
export const DICTIONARY_CACHE_MAX_AGE = 30 * DAY;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Locale } from '@scrabble-solver/types';
|
|
2
|
+
|
|
3
|
+
import { DICTIONARY_CACHE } from './constants';
|
|
4
|
+
import { getDictionaryUrl } from './getDictionaryUrl';
|
|
5
|
+
import { resetRevalidationThrottle } from './revalidateDictionary';
|
|
6
|
+
|
|
7
|
+
export async function getDictionary(locale: Locale): Promise<Response | undefined> {
|
|
8
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
9
|
+
return cache.match(getDictionaryUrl(locale));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function deleteDictionary(locale: Locale): Promise<void> {
|
|
13
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
14
|
+
await cache.delete(getDictionaryUrl(locale));
|
|
15
|
+
/**
|
|
16
|
+
* Reset last: resetting first leaves a window in which a revalidation starts,
|
|
17
|
+
* reads the doomed entry's ETag, takes the 304 it answers with, and arms the
|
|
18
|
+
* throttle against a dictionary this call is about to delete.
|
|
19
|
+
*/
|
|
20
|
+
await resetRevalidationThrottle(locale);
|
|
21
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Locale } from '@scrabble-solver/types';
|
|
2
|
+
import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { DICTIONARY_CACHE, REVALIDATED_AT_CACHE } from './constants';
|
|
5
|
+
import type * as getDictionaryModule from './getDictionary';
|
|
6
|
+
import { getDictionaryUrl } from './getDictionaryUrl';
|
|
7
|
+
import type * as revalidateDictionaryModule from './revalidateDictionary';
|
|
8
|
+
|
|
9
|
+
const HOUR = 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const fetchCalls: { cache: RequestCache | undefined; ifNoneMatch: string | undefined; url: string }[] = [];
|
|
12
|
+
const updatedTimestamps: string[] = [];
|
|
13
|
+
let respond: () => Promise<Response> = () => Promise.resolve(notModified());
|
|
14
|
+
|
|
15
|
+
await mock.module('./expirationManager', () => ({
|
|
16
|
+
expirationManager: {
|
|
17
|
+
expireEntries: () => Promise.resolve(),
|
|
18
|
+
updateTimestamp: (url: string) => {
|
|
19
|
+
updatedTimestamps.push(url);
|
|
20
|
+
return Promise.resolve();
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const globals = { caches: globalThis.caches, fetch: globalThis.fetch };
|
|
26
|
+
|
|
27
|
+
Object.assign(globalThis, {
|
|
28
|
+
caches: createCacheStorage(),
|
|
29
|
+
fetch: (url: string, { cache, headers }: RequestInit) => {
|
|
30
|
+
fetchCalls.push({ cache, ifNoneMatch: (headers as Record<string, string>)['If-None-Match'], url });
|
|
31
|
+
return respond();
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
Object.assign(globalThis, globals);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const { resetRevalidationThrottle, revalidateDictionary }: typeof revalidateDictionaryModule =
|
|
40
|
+
await import('./revalidateDictionary');
|
|
41
|
+
const { deleteDictionary }: typeof getDictionaryModule = await import('./getDictionary');
|
|
42
|
+
|
|
43
|
+
describe('revalidateDictionary', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
fetchCalls.length = 0;
|
|
46
|
+
updatedTimestamps.length = 0;
|
|
47
|
+
respond = () => Promise.resolve(notModified());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `no-store` would look equivalent, but makes the browser send
|
|
52
|
+
* `Cache-Control: no-cache`, which Next.js answers with the whole body.
|
|
53
|
+
*/
|
|
54
|
+
it('sends a conditional request the HTTP cache cannot answer', async () => {
|
|
55
|
+
const locale = await unthrottled(Locale.EN_US);
|
|
56
|
+
await cacheDictionary(locale, 'dictionary', '"v1"');
|
|
57
|
+
await revalidateDictionary(locale);
|
|
58
|
+
expect(fetchCalls).toEqual([{ cache: 'no-cache', ifNoneMatch: '"v1"', url: '/api/dictionary/en-US' }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('keeps the cached body on 304', async () => {
|
|
62
|
+
const locale = await unthrottled(Locale.EN_GB);
|
|
63
|
+
await cacheDictionary(locale, 'dictionary', '"v1"');
|
|
64
|
+
await revalidateDictionary(locale);
|
|
65
|
+
expect(await readCachedDictionary(locale)).toBe('dictionary');
|
|
66
|
+
expect(updatedTimestamps).toEqual([getDictionaryUrl(locale)]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('replaces the cached body on 200', async () => {
|
|
70
|
+
const locale = await unthrottled(Locale.DE_DE);
|
|
71
|
+
await cacheDictionary(locale, 'old', '"v1"');
|
|
72
|
+
respond = () => Promise.resolve(new Response('new', { headers: { etag: '"v2"' } }));
|
|
73
|
+
await revalidateDictionary(locale);
|
|
74
|
+
expect(await readCachedDictionary(locale)).toBe('new');
|
|
75
|
+
expect(updatedTimestamps).toEqual([getDictionaryUrl(locale)]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('omits If-None-Match when nothing is cached', async () => {
|
|
79
|
+
const locale = await unthrottled(Locale.ES_ES);
|
|
80
|
+
respond = () => Promise.resolve(new Response('dictionary'));
|
|
81
|
+
await revalidateDictionary(locale);
|
|
82
|
+
expect(fetchCalls[0].ifNoneMatch).toBeUndefined();
|
|
83
|
+
expect(await readCachedDictionary(locale)).toBe('dictionary');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects a failed response without arming the throttle', async () => {
|
|
87
|
+
const locale = await unthrottled(Locale.FA_IR);
|
|
88
|
+
respond = () => Promise.resolve(new Response(null, { status: 503, statusText: 'Service Unavailable' }));
|
|
89
|
+
const failure = await revalidateDictionary(locale).catch((error: Error) => error.message);
|
|
90
|
+
expect(failure).toBe('HTTP 503: Service Unavailable');
|
|
91
|
+
respond = () => Promise.resolve(notModified());
|
|
92
|
+
await revalidateDictionary(locale);
|
|
93
|
+
expect(fetchCalls).toHaveLength(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('revalidates at most once per interval', async () => {
|
|
97
|
+
const locale = await unthrottled(Locale.FR_FR);
|
|
98
|
+
await revalidateDictionary(locale);
|
|
99
|
+
await revalidateDictionary(locale);
|
|
100
|
+
expect(fetchCalls).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('shares one request between concurrent callers', async () => {
|
|
104
|
+
const locale = await unthrottled(Locale.EN_US);
|
|
105
|
+
await Promise.all([revalidateDictionary(locale), revalidateDictionary(locale), revalidateDictionary(locale)]);
|
|
106
|
+
expect(fetchCalls).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('persists the throttle, so a later page load can honour it', async () => {
|
|
110
|
+
const locale = await unthrottled(Locale.PL_PL);
|
|
111
|
+
await revalidateDictionary(locale);
|
|
112
|
+
expect(await readPersistedRevalidatedAt(locale)).toBeGreaterThan(Date.now() - HOUR);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* This locale and the next are used nowhere else: the worker is created per
|
|
117
|
+
* page, so only a locale unseen in memory exercises a fresh page's read.
|
|
118
|
+
*/
|
|
119
|
+
it('honours a throttle persisted by an earlier page load', async () => {
|
|
120
|
+
await persistRevalidatedAt(Locale.RO_RO, Date.now() - 1000);
|
|
121
|
+
await revalidateDictionary(Locale.RO_RO);
|
|
122
|
+
expect(fetchCalls).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('revalidates when a throttle persisted by an earlier page load has expired', async () => {
|
|
126
|
+
await persistRevalidatedAt(Locale.TR_TR, Date.now() - 2 * HOUR);
|
|
127
|
+
await revalidateDictionary(Locale.TR_TR);
|
|
128
|
+
expect(fetchCalls).toHaveLength(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('lets the next access revalidate after the dictionary is deleted', async () => {
|
|
132
|
+
const locale = await unthrottled(Locale.DE_DE);
|
|
133
|
+
await revalidateDictionary(locale);
|
|
134
|
+
await revalidateDictionary(locale);
|
|
135
|
+
await deleteDictionary(locale);
|
|
136
|
+
await revalidateDictionary(locale);
|
|
137
|
+
expect(fetchCalls).toHaveLength(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('does not arm the throttle when the dictionary is deleted mid-revalidation', async () => {
|
|
141
|
+
const locale = await unthrottled(Locale.EN_GB);
|
|
142
|
+
let release: (response: Response) => void = () => undefined;
|
|
143
|
+
respond = () => new Promise((resolve) => (release = resolve));
|
|
144
|
+
const revalidation = revalidateDictionary(locale);
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
146
|
+
await resetRevalidationThrottle(locale);
|
|
147
|
+
release(notModified());
|
|
148
|
+
await revalidation;
|
|
149
|
+
respond = () => Promise.resolve(notModified());
|
|
150
|
+
await revalidateDictionary(locale);
|
|
151
|
+
expect(fetchCalls).toHaveLength(2);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** Throttle state outlives a test, so tests sharing a locale start it un-throttled. */
|
|
156
|
+
async function unthrottled(locale: Locale): Promise<Locale> {
|
|
157
|
+
await resetRevalidationThrottle(locale);
|
|
158
|
+
return locale;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function cacheDictionary(locale: Locale, body: string, etag: string): Promise<void> {
|
|
162
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
163
|
+
await cache.put(getDictionaryUrl(locale), new Response(body, { headers: { etag } }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function readCachedDictionary(locale: Locale): Promise<string | undefined> {
|
|
167
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
168
|
+
return (await cache.match(getDictionaryUrl(locale)))?.text();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function persistRevalidatedAt(locale: Locale, timestamp: number): Promise<void> {
|
|
172
|
+
const cache = await caches.open(REVALIDATED_AT_CACHE);
|
|
173
|
+
await cache.put(getDictionaryUrl(locale), new Response(String(timestamp)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function readPersistedRevalidatedAt(locale: Locale): Promise<number> {
|
|
177
|
+
const cache = await caches.open(REVALIDATED_AT_CACHE);
|
|
178
|
+
return Number(await (await cache.match(getDictionaryUrl(locale)))?.text());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function notModified(): Response {
|
|
182
|
+
return new Response(null, { status: 304 });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function createCacheStorage(): CacheStorage {
|
|
186
|
+
const entries = new Map<string, Response>();
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
open: (name: string) =>
|
|
190
|
+
Promise.resolve({
|
|
191
|
+
delete: (url: string) => Promise.resolve(entries.delete(`${name} ${url}`)),
|
|
192
|
+
match: (url: string) => Promise.resolve(entries.get(`${name} ${url}`)?.clone()),
|
|
193
|
+
put: (url: string, response: Response) => {
|
|
194
|
+
entries.set(`${name} ${url}`, response);
|
|
195
|
+
return Promise.resolve();
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
} as unknown as CacheStorage;
|
|
199
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type Locale } from '@scrabble-solver/types';
|
|
2
|
+
|
|
3
|
+
import { DICTIONARY_CACHE } from './constants';
|
|
4
|
+
import { expirationManager } from './expirationManager';
|
|
5
|
+
import { getDictionaryUrl } from './getDictionaryUrl';
|
|
6
|
+
import { clearRevalidatedAt, readRevalidatedAt, writeRevalidatedAt } from './revalidatedAt';
|
|
7
|
+
|
|
8
|
+
const REVALIDATION_INTERVAL = 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
const requests: Partial<Record<Locale, Promise<void> | undefined>> = {};
|
|
11
|
+
const resetWhileRevalidating = new Set<Locale>();
|
|
12
|
+
|
|
13
|
+
export function revalidateDictionary(locale: Locale): Promise<void> {
|
|
14
|
+
const inFlight = requests[locale];
|
|
15
|
+
|
|
16
|
+
if (inFlight) {
|
|
17
|
+
return inFlight;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Registered before the first await, so concurrent callers share this request.
|
|
21
|
+
const request = revalidateWhenStale(locale).finally(() => {
|
|
22
|
+
requests[locale] = undefined;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
requests[locale] = request;
|
|
26
|
+
return request;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lets the next access revalidate immediately - used when the cached dictionary
|
|
31
|
+
* is deleted, so its replacement is not stuck behind the throttle.
|
|
32
|
+
* A revalidation already in flight when this runs saw the deleted entry, so it
|
|
33
|
+
* must not arm the throttle when it finishes.
|
|
34
|
+
* Both in-memory guards are raised before the first await, so a caller that
|
|
35
|
+
* runs this straight after deleting the entry leaves no gap for a revalidation
|
|
36
|
+
* to slip into.
|
|
37
|
+
*/
|
|
38
|
+
export async function resetRevalidationThrottle(locale: Locale): Promise<void> {
|
|
39
|
+
resetWhileRevalidating.add(locale);
|
|
40
|
+
await clearRevalidatedAt(locale);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function revalidateWhenStale(locale: Locale): Promise<void> {
|
|
44
|
+
if (Date.now() - (await readRevalidatedAt(locale)) < REVALIDATION_INTERVAL) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
resetWhileRevalidating.delete(locale);
|
|
49
|
+
await revalidate(locale);
|
|
50
|
+
|
|
51
|
+
if (!resetWhileRevalidating.has(locale)) {
|
|
52
|
+
await writeRevalidatedAt(locale, Date.now());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `no-cache` revalidates against the server without letting the HTTP cache
|
|
58
|
+
* answer, while still allowing a 304 that keeps the multi-megabyte cached body
|
|
59
|
+
* untouched; only a real 200 rewrites it. `no-store` would look equivalent but
|
|
60
|
+
* makes the browser send `Cache-Control: no-cache`, which Next.js reads as an
|
|
61
|
+
* unconditional request and answers with the whole body every time.
|
|
62
|
+
*/
|
|
63
|
+
async function revalidate(locale: Locale): Promise<void> {
|
|
64
|
+
await expirationManager.expireEntries();
|
|
65
|
+
|
|
66
|
+
const url = getDictionaryUrl(locale);
|
|
67
|
+
const cache = await caches.open(DICTIONARY_CACHE);
|
|
68
|
+
const cached = await cache.match(url);
|
|
69
|
+
const etag = cached?.headers.get('etag');
|
|
70
|
+
const response = await fetch(url, {
|
|
71
|
+
cache: 'no-cache',
|
|
72
|
+
headers: etag ? { 'If-None-Match': etag } : {},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (response.status === 304) {
|
|
76
|
+
await expirationManager.updateTimestamp(url);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await cache.put(url, response);
|
|
85
|
+
await expirationManager.updateTimestamp(url);
|
|
86
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Locale } from '@scrabble-solver/types';
|
|
2
|
+
|
|
3
|
+
import { REVALIDATED_AT_CACHE } from './constants';
|
|
4
|
+
import { getDictionaryUrl } from './getDictionaryUrl';
|
|
5
|
+
|
|
6
|
+
const timestamps: Partial<Record<Locale, number>> = {};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A dedicated worker is created per page, so a timestamp kept only in memory
|
|
10
|
+
* would let every navigation revalidate again. The Cache API is the only
|
|
11
|
+
* storage a worker shares with other tabs, so the throttle lives there and is
|
|
12
|
+
* read at most once per locale per worker.
|
|
13
|
+
*/
|
|
14
|
+
export async function readRevalidatedAt(locale: Locale): Promise<number> {
|
|
15
|
+
const known = timestamps[locale];
|
|
16
|
+
|
|
17
|
+
if (typeof known !== 'undefined') {
|
|
18
|
+
return known;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const persisted = await readPersistedRevalidatedAt(locale);
|
|
22
|
+
// A reset that landed while reading wins over the value it invalidated.
|
|
23
|
+
return (timestamps[locale] ??= persisted);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeRevalidatedAt(locale: Locale, timestamp: number): Promise<void> {
|
|
27
|
+
timestamps[locale] = timestamp;
|
|
28
|
+
const cache = await caches.open(REVALIDATED_AT_CACHE);
|
|
29
|
+
await cache.put(getDictionaryUrl(locale), new Response(String(timestamp)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function clearRevalidatedAt(locale: Locale): Promise<void> {
|
|
33
|
+
timestamps[locale] = 0;
|
|
34
|
+
const cache = await caches.open(REVALIDATED_AT_CACHE);
|
|
35
|
+
await cache.delete(getDictionaryUrl(locale));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readPersistedRevalidatedAt(locale: Locale): Promise<number> {
|
|
39
|
+
const cache = await caches.open(REVALIDATED_AT_CACHE);
|
|
40
|
+
const response = await cache.match(getDictionaryUrl(locale));
|
|
41
|
+
const timestamp = Number(await response?.text());
|
|
42
|
+
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
43
|
+
}
|