scrabble-solver 2.18.3 → 2.18.5
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/.nx/workspace-data/d/server-process.json +3 -3
- package/.nx/workspace-data/file-map.json +1615 -1582
- 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 +1091 -1156
- package/.nx/workspace-data/parsed-lock-file.nodes.json +2038 -2029
- package/.nx/workspace-data/project-graph.json +3290 -3338
- package/.nx/workspace-data/source-maps.json +44 -8
- package/.oxlintrc.json +0 -1
- package/CLAUDE.md +10 -8
- package/bun.lock +707 -424
- package/lerna.json +1 -1
- package/package.json +11 -11
- package/packages/configs/package.json +3 -3
- package/packages/constants/package.json +1 -1
- package/packages/dictionaries/bin/update-dictionaries.js +2 -5
- package/packages/dictionaries/build/Dictionaries.d.ts +1 -1
- package/packages/dictionaries/build/Dictionaries.js +9 -4
- package/packages/dictionaries/build/lib/DiskCache.js +1 -1
- package/packages/dictionaries/build/lib/downloadDictionary.d.ts +1 -1
- package/packages/dictionaries/build/lib/downloadDictionary.js +12 -7
- package/packages/dictionaries/package.json +5 -5
- package/packages/dictionaries/src/Dictionaries.ts +13 -6
- package/packages/dictionaries/src/lib/DiskCache.ts +2 -2
- package/packages/dictionaries/src/lib/downloadDictionary.ts +14 -6
- package/packages/logger/README.md +18 -0
- package/packages/logger/build/constants.d.ts +3 -0
- package/packages/logger/build/constants.js +4 -1
- package/packages/logger/build/describeError.d.ts +5 -0
- package/packages/logger/build/describeError.js +23 -0
- package/packages/logger/build/events.d.ts +92 -0
- package/packages/logger/build/events.js +13 -0
- package/packages/logger/build/formatCsvRow.d.ts +2 -0
- package/packages/logger/build/formatCsvRow.js +20 -0
- package/packages/logger/build/index.d.ts +5 -1
- package/packages/logger/build/index.js +13 -3
- package/packages/logger/build/logError.d.ts +4 -0
- package/packages/logger/build/logError.js +17 -0
- package/packages/logger/build/logEvent.d.ts +3 -0
- package/packages/logger/build/logEvent.js +29 -0
- package/packages/logger/package.json +7 -8
- package/packages/logger/scripts/export-csv.ts +113 -0
- package/packages/logger/scripts/readEvents.ts +34 -0
- package/packages/logger/scripts/tsconfig.json +10 -0
- package/packages/logger/src/constants.ts +9 -0
- package/packages/logger/src/describeError.test.ts +44 -0
- package/packages/logger/src/describeError.ts +30 -0
- package/packages/logger/src/events.ts +103 -0
- package/packages/logger/src/formatCsvRow.test.ts +41 -0
- package/packages/logger/src/formatCsvRow.ts +24 -0
- package/packages/logger/src/index.ts +13 -1
- package/packages/logger/src/logError.ts +24 -0
- package/packages/logger/src/logEvent.test.ts +59 -0
- package/packages/logger/src/logEvent.ts +26 -0
- package/packages/scrabble-solver/.next/BUILD_ID +1 -1
- package/packages/scrabble-solver/.next/build-manifest.json +19 -19
- 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 +2 -2
- 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/dynamic-css-manifest.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 +4 -4
- package/packages/scrabble-solver/.next/react-loadable-manifest.json +6 -6
- package/packages/scrabble-solver/.next/routes-manifest.json +1 -1
- package/packages/scrabble-solver/.next/server/chunks/30.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/546.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/612.js +5 -0
- package/packages/scrabble-solver/.next/server/chunks/712.js +1 -1
- package/packages/scrabble-solver/.next/server/chunks/715.js +1 -0
- package/packages/scrabble-solver/.next/server/chunks/853.js +2 -2
- package/packages/scrabble-solver/.next/server/chunks/961.js +1 -1
- package/packages/scrabble-solver/.next/server/dynamic-css-manifest.js +1 -1
- package/packages/scrabble-solver/.next/server/middleware-build-manifest.js +1 -1
- package/packages/scrabble-solver/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/packages/scrabble-solver/.next/server/pages/404.html +2 -2
- package/packages/scrabble-solver/.next/server/pages/500.html +2 -2
- package/packages/scrabble-solver/.next/server/pages/_app.js.nft.json +1 -1
- package/packages/scrabble-solver/.next/server/pages/_document.js +2 -2
- 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 +2 -2
- 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 +2 -2
- 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/index.meta +1 -0
- package/packages/scrabble-solver/.next/server/pages/not-found.html +2 -2
- package/packages/scrabble-solver/.next/server/route-cache/PAGES/8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1/$/index.html +29 -0
- package/packages/scrabble-solver/.next/server/route-cache/PAGES/8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1/$/index.json +1 -0
- package/packages/scrabble-solver/.next/server/route-cache/PAGES/8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1/$/index.meta +1 -0
- package/packages/scrabble-solver/.next/static/{8WnIUTe-yJvPD6stltvYI → YHx536pklAqlc9UGAXCj8}/_buildManifest.js +1 -1
- package/packages/scrabble-solver/.next/static/chunks/101-fee295c7587c0836.js +3 -0
- package/packages/scrabble-solver/.next/static/chunks/{256.6a20b23a766cfebd.js → 256.2a4fa7f278e0a97d.js} +1 -1
- package/packages/scrabble-solver/.next/static/chunks/693.d295da7c7b873986.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/827.09f660ca0a951d12.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/980.dd35e807b18063c1.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/framework-988fd6f19d2744e8.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/main-d397eb8af33c5163.js +5 -0
- package/packages/scrabble-solver/.next/static/chunks/pages/_app-0c68f165d6de8507.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/pages/index-ea21d96a88f25000.js +1 -0
- package/packages/scrabble-solver/.next/static/chunks/{webpack-d7138f1ef6b8c9d6.js → webpack-31879a8fb6dcffd0.js} +1 -1
- package/packages/scrabble-solver/.next/static/css/{92f85ce358a0a468.css → 4dbcacc1122ac6b9.css} +1 -1
- package/packages/scrabble-solver/.next/static/css/8b8087461767360a.css +1 -0
- package/packages/scrabble-solver/.next/static/css/{65bcc9440e067113.css → 98051ed25076b896.css} +1 -1
- package/packages/scrabble-solver/.next/trace +22 -22
- package/packages/scrabble-solver/.next/trace-build +1 -1
- package/packages/scrabble-solver/package.json +18 -18
- package/packages/scrabble-solver/public/service-worker.js +1 -1
- package/packages/scrabble-solver/src/api/BadRequestError.ts +6 -0
- package/packages/scrabble-solver/src/api/index.ts +2 -1
- package/packages/scrabble-solver/src/api/withApiLog.ts +85 -0
- package/packages/scrabble-solver/src/components/Button/Button.module.scss +1 -1
- package/packages/scrabble-solver/src/components/Solver/Solver.module.scss +4 -4
- package/packages/scrabble-solver/src/components/Solver/Solver.tsx +2 -2
- package/packages/scrabble-solver/src/hooks/useColumns.ts +5 -7
- package/packages/scrabble-solver/src/modals/ResultsModal/ResultsModal.tsx +2 -2
- package/packages/scrabble-solver/src/modals/WordsModal/WordsModal.module.scss +1 -1
- package/packages/scrabble-solver/src/modals/WordsModal/WordsModal.tsx +2 -2
- package/packages/scrabble-solver/src/modals/WordsModal/components/WordsTable/WordsTable.tsx +2 -2
- package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/[word].ts +22 -30
- package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/index.ts +36 -42
- package/packages/scrabble-solver/src/pages/api/solve.ts +32 -42
- package/packages/scrabble-solver/src/pages/api/verify.ts +35 -43
- package/packages/scrabble-solver/src/pages/api/visit.ts +38 -13
- package/packages/scrabble-solver/src/pages/index.tsx +2 -2
- package/packages/scrabble-solver/src/parameters/index.ts +0 -2
- package/packages/scrabble-solver/src/sdk/solve.ts +3 -9
- package/packages/scrabble-solver/src/sdk/verify.ts +2 -10
- package/packages/scrabble-solver/src/sdk/visit.ts +8 -3
- package/packages/scrabble-solver/src/state/sagas.ts +7 -1
- package/packages/scrabble-solver/src/styles/_tokens.scss +0 -2
- package/packages/scrabble-solver/src/styles/mixins.scss +0 -5
- package/packages/scrabble-solver/src/styles/variables.module.scss +0 -1
- package/packages/scrabble-solver/src/styles/variables.module.scss.d.ts +0 -1
- package/packages/scrabble-solver/src/styles/variables.scss +1 -1
- package/packages/scrabble-solver/src/types/api.ts +7 -0
- package/packages/scrabble-solver/tsconfig.tsbuildinfo +1 -1
- package/packages/solver/package.json +6 -6
- package/packages/types/package.json +2 -2
- package/packages/word-definitions/package.json +3 -3
- package/packages/word-lists/package.json +3 -3
- package/playwright-report/index.html +6 -6
- package/packages/gaddag/build/Gaddag.d.ts +0 -39
- package/packages/gaddag/build/Gaddag.js +0 -128
- package/packages/gaddag/build/buildGaddag.d.ts +0 -10
- package/packages/gaddag/build/buildGaddag.js +0 -360
- package/packages/gaddag/build/constants.d.ts +0 -12
- package/packages/gaddag/build/constants.js +0 -15
- package/packages/gaddag/build/index.d.ts +0 -3
- package/packages/gaddag/build/index.js +0 -13
- package/packages/logger/build/logger.d.ts +0 -1
- package/packages/logger/build/logger.js +0 -28
- package/packages/logger/scripts/stats.js +0 -73
- package/packages/logger/src/logger.ts +0 -30
- package/packages/scrabble-solver/.next/server/chunks/392.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/101-829ab8a8d310e7a0.js +0 -3
- package/packages/scrabble-solver/.next/static/chunks/693.677c88e0eb1340b5.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/827.c81b92b383a748c9.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/980.7ce17464fc56648a.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/framework-626cbd48ac5aca57.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/main-7e52da35a4beea0b.js +0 -5
- package/packages/scrabble-solver/.next/static/chunks/pages/_app-e3fe880e24c024ab.js +0 -1
- package/packages/scrabble-solver/.next/static/chunks/pages/index-b073a1686818dd26.js +0 -1
- package/packages/scrabble-solver/.next/static/css/a691348b788f8bc0.css +0 -1
- package/packages/scrabble-solver/src/api/getServerLoggingData.ts +0 -17
- package/packages/scrabble-solver/src/hooks/useIsCompactLayout.ts +0 -14
- /package/packages/scrabble-solver/.next/static/{8WnIUTe-yJvPD6stltvYI → YHx536pklAqlc9UGAXCj8}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { CSV_DIRECTORY, EVENT_FIELDS, type EventType, type EventValue, formatCsvRow, type LoggedEvent } from '../src';
|
|
6
|
+
|
|
7
|
+
import { readEvents } from './readEvents';
|
|
8
|
+
|
|
9
|
+
interface CsvFile {
|
|
10
|
+
error: Error | undefined;
|
|
11
|
+
filename: string;
|
|
12
|
+
rows: number;
|
|
13
|
+
stream: fs.WriteStream;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const YEAR_FORMAT = /^\d{4}$/;
|
|
17
|
+
|
|
18
|
+
await exportCsv(parseYear(process.argv[2]));
|
|
19
|
+
|
|
20
|
+
async function exportCsv(year: string | undefined): Promise<void> {
|
|
21
|
+
fs.mkdirSync(CSV_DIRECTORY, { recursive: true });
|
|
22
|
+
const files = new Map<string, CsvFile>();
|
|
23
|
+
let skippedLines = 0;
|
|
24
|
+
|
|
25
|
+
for await (const event of readEvents()) {
|
|
26
|
+
if (!event) {
|
|
27
|
+
skippedLines += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const eventYear = event.timestamp.slice(0, 4);
|
|
32
|
+
|
|
33
|
+
if (year && eventYear !== year) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await writeRow(getFile(files, event.type, eventYear), formatEventRow(event));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await Promise.all([...files.values()].map(closeFile));
|
|
41
|
+
printSummary(files, skippedLines);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatEventRow(event: LoggedEvent): string {
|
|
45
|
+
const values: Record<string, EventValue> = event;
|
|
46
|
+
return formatCsvRow([event.timestamp, ...EVENT_FIELDS[event.type].map((field) => values[field])]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getFile(files: Map<string, CsvFile>, type: EventType, year: string): CsvFile {
|
|
50
|
+
const filename = `${type}-${year}.csv`;
|
|
51
|
+
const openedFile = files.get(filename);
|
|
52
|
+
|
|
53
|
+
if (openedFile) {
|
|
54
|
+
return openedFile;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const file = openFile(filename, type);
|
|
58
|
+
files.set(filename, file);
|
|
59
|
+
return file;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function openFile(filename: string, type: EventType): CsvFile {
|
|
63
|
+
const stream = fs.createWriteStream(path.join(CSV_DIRECTORY, filename));
|
|
64
|
+
const file: CsvFile = { error: undefined, filename, rows: 0, stream };
|
|
65
|
+
stream.on('error', (error) => {
|
|
66
|
+
file.error = error;
|
|
67
|
+
});
|
|
68
|
+
stream.write(formatCsvRow(['timestamp', ...EVENT_FIELDS[type]]));
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function writeRow(file: CsvFile, row: string): Promise<void> {
|
|
73
|
+
assertWritable(file);
|
|
74
|
+
file.rows += 1;
|
|
75
|
+
|
|
76
|
+
if (!file.stream.write(row)) {
|
|
77
|
+
await once(file.stream, 'drain');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function closeFile(file: CsvFile): Promise<void> {
|
|
82
|
+
assertWritable(file);
|
|
83
|
+
file.stream.end();
|
|
84
|
+
await once(file.stream, 'finish');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertWritable(file: CsvFile): void {
|
|
88
|
+
if (file.error) {
|
|
89
|
+
throw file.error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function printSummary(files: Map<string, CsvFile>, skippedLines: number): void {
|
|
94
|
+
if (files.size === 0) {
|
|
95
|
+
console.log('No events to export');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const file of [...files.values()].sort((a, b) => a.filename.localeCompare(b.filename))) {
|
|
99
|
+
console.log(`${path.join(CSV_DIRECTORY, file.filename)}: ${file.rows} rows`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (skippedLines > 0) {
|
|
103
|
+
console.log(`Skipped ${skippedLines} unparseable lines`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseYear(argument: string | undefined): string | undefined {
|
|
108
|
+
if (argument !== undefined && !YEAR_FORMAT.test(argument)) {
|
|
109
|
+
throw new Error(`Expected a YYYY year, got "${argument}"`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return argument;
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
|
|
4
|
+
import { EVENT_FIELDS, EVENTS_FILEPATH, type LoggedEvent } from '../src';
|
|
5
|
+
|
|
6
|
+
export async function* readEvents(): AsyncGenerator<LoggedEvent | undefined> {
|
|
7
|
+
if (!fs.existsSync(EVENTS_FILEPATH)) {
|
|
8
|
+
throw new Error(`No events file at ${EVENTS_FILEPATH}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const lines = readline.createInterface({ input: fs.createReadStream(EVENTS_FILEPATH), crlfDelay: Infinity });
|
|
12
|
+
|
|
13
|
+
for await (const line of lines) {
|
|
14
|
+
yield parseEvent(line);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseEvent(line: string): LoggedEvent | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const value: unknown = JSON.parse(line);
|
|
21
|
+
return isLoggedEvent(value) ? value : undefined;
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isLoggedEvent(value: unknown): value is LoggedEvent {
|
|
28
|
+
if (typeof value !== 'object' || value === null) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { timestamp, type } = value as Partial<Record<'timestamp' | 'type', unknown>>;
|
|
33
|
+
return typeof timestamp === 'string' && typeof type === 'string' && Object.hasOwn(EVENT_FIELDS, type);
|
|
34
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import os from 'os';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
|
+
// bun test exposes the Jest globals to every module, so their presence marks a test run even under an inherited NODE_ENV
|
|
5
|
+
declare const describe: unknown;
|
|
6
|
+
|
|
4
7
|
export const OUTPUT_DIRECTORY = path.resolve(os.homedir(), '.scrabble-solver', 'logs');
|
|
8
|
+
|
|
9
|
+
export const EVENTS_FILEPATH = path.resolve(OUTPUT_DIRECTORY, 'events.txt');
|
|
10
|
+
|
|
11
|
+
export const CSV_DIRECTORY = path.resolve(os.homedir(), '.scrabble-solver', 'csv');
|
|
12
|
+
|
|
13
|
+
export const IS_TEST_RUN = process.env.NODE_ENV === 'test' || typeof describe === 'function';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describeError } from './describeError';
|
|
2
|
+
|
|
3
|
+
describe('describeError', () => {
|
|
4
|
+
it('prefixes the message with the error name and keeps the stack', () => {
|
|
5
|
+
const { message, stack } = describeError(new Error('boom'));
|
|
6
|
+
|
|
7
|
+
expect(message).toBe('Error: boom');
|
|
8
|
+
expect(stack).toStartWith('Error: boom\n');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('includes the error code', () => {
|
|
12
|
+
const error = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:443'), { code: 'ECONNREFUSED' });
|
|
13
|
+
|
|
14
|
+
expect(describeError(error).message).toBe('Error [ECONNREFUSED]: connect ECONNREFUSED 127.0.0.1:443');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('lists the errors behind an AggregateError with an empty message', () => {
|
|
18
|
+
const error = Object.assign(
|
|
19
|
+
new AggregateError(
|
|
20
|
+
[
|
|
21
|
+
Object.assign(new Error('connect ECONNREFUSED ::1:443'), { code: 'ECONNREFUSED' }),
|
|
22
|
+
Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:443'), { code: 'ECONNREFUSED' }),
|
|
23
|
+
],
|
|
24
|
+
'',
|
|
25
|
+
),
|
|
26
|
+
{ code: 'ECONNREFUSED' },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(describeError(error).message).toBe(
|
|
30
|
+
'AggregateError [ECONNREFUSED]: (Error [ECONNREFUSED]: connect ECONNREFUSED ::1:443; Error [ECONNREFUSED]: connect ECONNREFUSED 127.0.0.1:443)',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('includes the cause', () => {
|
|
35
|
+
const error = new Error('Could not download word list', { cause: new TypeError('fetch failed') });
|
|
36
|
+
|
|
37
|
+
expect(describeError(error).message).toBe('Error: Could not download word list (TypeError: fetch failed)');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('stringifies values that are not errors without a stack', () => {
|
|
41
|
+
expect(describeError('boom')).toEqual({ message: 'boom', stack: undefined });
|
|
42
|
+
expect(describeError(undefined)).toEqual({ message: 'undefined', stack: undefined });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface ErrorDescription {
|
|
2
|
+
message: string;
|
|
3
|
+
stack: string | undefined;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function describeError(error: unknown): ErrorDescription {
|
|
7
|
+
return { message: formatError(error), stack: error instanceof Error ? error.stack : undefined };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatError(error: unknown): string {
|
|
11
|
+
if (!(error instanceof Error)) {
|
|
12
|
+
return String(error);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const heading = `${error.name}${formatCode(error)}: ${error.message}`.trimEnd();
|
|
16
|
+
const nested = getNestedErrors(error);
|
|
17
|
+
return nested.length === 0 ? heading : `${heading} (${nested.map(formatError).join('; ')})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatCode(error: Error): string {
|
|
21
|
+
return 'code' in error && typeof error.code === 'string' ? ` [${error.code}]` : '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getNestedErrors(error: Error): unknown[] {
|
|
25
|
+
if (error instanceof AggregateError) {
|
|
26
|
+
return error.errors;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return error.cause === undefined ? [] : [error.cause];
|
|
30
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export type Event = VisitEvent | SolveEvent | VerifyEvent | DownloadEvent | DefinitionEvent | BuildEvent | ErrorEvent;
|
|
2
|
+
|
|
3
|
+
export type EventType = Event['type'];
|
|
4
|
+
|
|
5
|
+
export type EventOf<T extends EventType> = Extract<Event, { type: T }>;
|
|
6
|
+
|
|
7
|
+
export type EventValue = string | number | boolean | undefined;
|
|
8
|
+
|
|
9
|
+
export type LoggedEvent = Event & { timestamp: string };
|
|
10
|
+
|
|
11
|
+
export type Operation = 'visit' | 'solve' | 'verify' | 'download' | 'definition' | 'build' | 'cache';
|
|
12
|
+
|
|
13
|
+
type VisitEvent = {
|
|
14
|
+
type: 'visit';
|
|
15
|
+
ip?: string;
|
|
16
|
+
ua?: string;
|
|
17
|
+
referrer?: string;
|
|
18
|
+
locale?: string;
|
|
19
|
+
game?: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type SolveEvent = {
|
|
24
|
+
type: 'solve';
|
|
25
|
+
ip?: string;
|
|
26
|
+
ms: number;
|
|
27
|
+
locale: string;
|
|
28
|
+
game: string;
|
|
29
|
+
tiles: number;
|
|
30
|
+
blanks: number;
|
|
31
|
+
rack: string;
|
|
32
|
+
results: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type VerifyEvent = {
|
|
36
|
+
type: 'verify';
|
|
37
|
+
ip?: string;
|
|
38
|
+
ms: number;
|
|
39
|
+
locale: string;
|
|
40
|
+
game: string;
|
|
41
|
+
tiles: number;
|
|
42
|
+
blanks: number;
|
|
43
|
+
valid: number;
|
|
44
|
+
invalid: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type DownloadEvent = {
|
|
48
|
+
type: 'download';
|
|
49
|
+
ip?: string;
|
|
50
|
+
ms: number;
|
|
51
|
+
locale: string;
|
|
52
|
+
status: number;
|
|
53
|
+
encoding: 'gzip' | 'identity';
|
|
54
|
+
bytes: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type DefinitionEvent = {
|
|
58
|
+
type: 'definition';
|
|
59
|
+
ip?: string;
|
|
60
|
+
ms: number;
|
|
61
|
+
locale: string;
|
|
62
|
+
words: string;
|
|
63
|
+
found: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type BuildEvent = {
|
|
67
|
+
type: 'build';
|
|
68
|
+
locale: string;
|
|
69
|
+
words: number;
|
|
70
|
+
download_ms: number;
|
|
71
|
+
build_ms: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type ErrorEvent = {
|
|
75
|
+
type: 'error';
|
|
76
|
+
level: 'error' | 'warn';
|
|
77
|
+
operation: Operation;
|
|
78
|
+
locale?: string;
|
|
79
|
+
ip?: string;
|
|
80
|
+
ua?: string;
|
|
81
|
+
message: string;
|
|
82
|
+
stack?: string;
|
|
83
|
+
input?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type FieldsOf<T extends EventType> = Exclude<keyof EventOf<T>, 'type'>;
|
|
87
|
+
|
|
88
|
+
// Field order is the column order of the exported CSV files - append only, never reorder.
|
|
89
|
+
export const EVENT_FIELDS = {
|
|
90
|
+
visit: ['ip', 'ua', 'referrer', 'locale', 'game', 'url'],
|
|
91
|
+
solve: ['ip', 'ms', 'locale', 'game', 'tiles', 'blanks', 'rack', 'results'],
|
|
92
|
+
verify: ['ip', 'ms', 'locale', 'game', 'tiles', 'blanks', 'valid', 'invalid'],
|
|
93
|
+
download: ['ip', 'ms', 'locale', 'status', 'encoding', 'bytes'],
|
|
94
|
+
definition: ['ip', 'ms', 'locale', 'words', 'found'],
|
|
95
|
+
build: ['locale', 'words', 'download_ms', 'build_ms'],
|
|
96
|
+
error: ['level', 'operation', 'locale', 'ip', 'ua', 'message', 'stack', 'input'],
|
|
97
|
+
} as const satisfies { [T in EventType]: readonly FieldsOf<T>[] };
|
|
98
|
+
|
|
99
|
+
type UnlistedFields = { [T in EventType]: Exclude<FieldsOf<T>, (typeof EVENT_FIELDS)[T][number]> }[EventType];
|
|
100
|
+
|
|
101
|
+
type Assert<T extends never> = T;
|
|
102
|
+
|
|
103
|
+
export type EveryFieldIsListed = Assert<UnlistedFields>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { formatCsvRow } from './formatCsvRow';
|
|
2
|
+
|
|
3
|
+
describe('formatCsvRow', () => {
|
|
4
|
+
it('joins values with commas and ends the line', () => {
|
|
5
|
+
expect(formatCsvRow(['a', 1, true])).toBe('a,1,true\n');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('writes undefined as an empty field', () => {
|
|
9
|
+
expect(formatCsvRow(['a', undefined, 'b'])).toBe('a,,b\n');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('quotes fields containing commas', () => {
|
|
13
|
+
expect(formatCsvRow(['x,y'])).toBe('"x,y"\n');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('quotes fields containing quotes and doubles them', () => {
|
|
17
|
+
expect(formatCsvRow(['say "hi"'])).toBe('"say ""hi"""\n');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('quotes fields containing line breaks', () => {
|
|
21
|
+
expect(formatCsvRow(['Error: boom\n at solve'])).toBe('"Error: boom\n at solve"\n');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('quotes fields with leading or trailing whitespace', () => {
|
|
25
|
+
expect(formatCsvRow([' ab', 'cd '])).toBe('" ab","cd "\n');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('leaves inner whitespace unquoted', () => {
|
|
29
|
+
expect(formatCsvRow(['pz cvmt'])).toBe('pz cvmt\n');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('prefixes text a spreadsheet would evaluate as a formula with a quote', () => {
|
|
33
|
+
expect(formatCsvRow(['=HYPERLINK("http://evil","x")', '+1', '-1', '@x', '\tx'])).toBe(
|
|
34
|
+
`"'=HYPERLINK(""http://evil"",""x"")",'+1,'-1,'@x,'\tx\n`,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('leaves negative numbers alone', () => {
|
|
39
|
+
expect(formatCsvRow([-1])).toBe('-1\n');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type EventValue } from './events';
|
|
2
|
+
|
|
3
|
+
const FORMULA_PREFIX = /^[=+\-@\t\r]/;
|
|
4
|
+
|
|
5
|
+
export function formatCsvRow(values: EventValue[]): string {
|
|
6
|
+
return `${values.map(formatCsvField).join(',')}\n`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatCsvField(value: EventValue): string {
|
|
10
|
+
if (typeof value === 'undefined') {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const text = typeof value === 'string' ? escapeFormula(value) : String(value);
|
|
15
|
+
return needsQuoting(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function escapeFormula(text: string): string {
|
|
19
|
+
return FORMULA_PREFIX.test(text) ? `'${text}` : text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function needsQuoting(text: string): boolean {
|
|
23
|
+
return /[",\n\r]/.test(text) || text !== text.trim();
|
|
24
|
+
}
|
|
@@ -1 +1,13 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { CSV_DIRECTORY, EVENTS_FILEPATH, OUTPUT_DIRECTORY } from './constants';
|
|
2
|
+
export {
|
|
3
|
+
EVENT_FIELDS,
|
|
4
|
+
type Event,
|
|
5
|
+
type EventOf,
|
|
6
|
+
type EventType,
|
|
7
|
+
type EventValue,
|
|
8
|
+
type LoggedEvent,
|
|
9
|
+
type Operation,
|
|
10
|
+
} from './events';
|
|
11
|
+
export { formatCsvRow } from './formatCsvRow';
|
|
12
|
+
export { logError } from './logError';
|
|
13
|
+
export { logEvent } from './logEvent';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IS_TEST_RUN } from './constants';
|
|
2
|
+
import { describeError, type ErrorDescription } from './describeError';
|
|
3
|
+
import { type EventOf, type Operation } from './events';
|
|
4
|
+
import { logEvent } from './logEvent';
|
|
5
|
+
|
|
6
|
+
type ErrorContext = Partial<Pick<EventOf<'error'>, 'level' | 'input' | 'ip' | 'locale' | 'ua'>>;
|
|
7
|
+
|
|
8
|
+
export function logError(
|
|
9
|
+
operation: Operation,
|
|
10
|
+
error: unknown,
|
|
11
|
+
{ level = 'error', ...context }: ErrorContext = {},
|
|
12
|
+
): void {
|
|
13
|
+
const description = describeError(error);
|
|
14
|
+
logEvent({ type: 'error', level, operation, ...description, ...context });
|
|
15
|
+
|
|
16
|
+
if (level === 'error' && !IS_TEST_RUN) {
|
|
17
|
+
process.stderr.write(formatStderrEntry(operation, description));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatStderrEntry(operation: Operation, { message, stack }: ErrorDescription): string {
|
|
22
|
+
const frames = stack?.split('\n').filter((line) => line.startsWith(' at ')) ?? [];
|
|
23
|
+
return `${[`${operation}: ${message}`, ...frames].join('\n')}\n`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { formatEventLine } from './logEvent';
|
|
2
|
+
|
|
3
|
+
const NOW = new Date('2026-08-23T02:09:02.123Z');
|
|
4
|
+
|
|
5
|
+
describe('formatEventLine', () => {
|
|
6
|
+
it('orders keys as timestamp, type, then the declared fields', () => {
|
|
7
|
+
const line = formatEventLine(
|
|
8
|
+
{
|
|
9
|
+
type: 'solve',
|
|
10
|
+
results: 312,
|
|
11
|
+
rack: 'p,z, ,c,v,m,t',
|
|
12
|
+
blanks: 4,
|
|
13
|
+
tiles: 61,
|
|
14
|
+
game: 'scrabble',
|
|
15
|
+
locale: 'en-US',
|
|
16
|
+
ms: 14,
|
|
17
|
+
ip: '203.0.113.7',
|
|
18
|
+
},
|
|
19
|
+
NOW,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(Object.keys(JSON.parse(line) as Record<string, unknown>)).toEqual([
|
|
23
|
+
'timestamp',
|
|
24
|
+
'type',
|
|
25
|
+
'ip',
|
|
26
|
+
'ms',
|
|
27
|
+
'locale',
|
|
28
|
+
'game',
|
|
29
|
+
'tiles',
|
|
30
|
+
'blanks',
|
|
31
|
+
'rack',
|
|
32
|
+
'results',
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('formats the timestamp in UTC with seconds precision', () => {
|
|
37
|
+
const line = formatEventLine({ type: 'build', locale: 'pl-PL', words: 3, download_ms: 1, build_ms: 2 }, NOW);
|
|
38
|
+
|
|
39
|
+
expect(line).toBe(
|
|
40
|
+
'{"timestamp":"2026-08-23T02:09:02Z","type":"build","locale":"pl-PL","words":3,"download_ms":1,"build_ms":2}\n',
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('omits undefined fields', () => {
|
|
45
|
+
const line = formatEventLine({ type: 'visit', ip: '::1', referrer: undefined }, NOW);
|
|
46
|
+
|
|
47
|
+
expect(line).toBe('{"timestamp":"2026-08-23T02:09:02Z","type":"visit","ip":"::1"}\n');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps multi-line values on a single line', () => {
|
|
51
|
+
const line = formatEventLine(
|
|
52
|
+
{ type: 'error', level: 'error', operation: 'solve', message: 'Error: boom', stack: 'Error: boom\n at solve' },
|
|
53
|
+
NOW,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(line.endsWith('\n')).toBe(true);
|
|
57
|
+
expect(line.slice(0, -1)).not.toContain('\n');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
|
|
3
|
+
import { EVENTS_FILEPATH, IS_TEST_RUN, OUTPUT_DIRECTORY } from './constants';
|
|
4
|
+
import { EVENT_FIELDS, type Event } from './events';
|
|
5
|
+
|
|
6
|
+
export function logEvent(event: Event): void {
|
|
7
|
+
if (IS_TEST_RUN) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
fs.mkdirSync(OUTPUT_DIRECTORY, { recursive: true });
|
|
13
|
+
fs.appendFileSync(EVENTS_FILEPATH, formatEventLine(event, new Date()));
|
|
14
|
+
} catch (error) {
|
|
15
|
+
process.stderr.write(`Could not append to ${EVENTS_FILEPATH}: ${String(error)}\n`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatEventLine(event: Event, now: Date): string {
|
|
20
|
+
const line = { timestamp: formatTimestamp(now), ...event };
|
|
21
|
+
return `${JSON.stringify(line, ['timestamp', 'type', ...EVENT_FIELDS[event.type]])}\n`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatTimestamp(date: Date): string {
|
|
25
|
+
return `${date.toISOString().slice(0, 19)}Z`;
|
|
26
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
YHx536pklAqlc9UGAXCj8
|
|
@@ -4,37 +4,37 @@
|
|
|
4
4
|
],
|
|
5
5
|
"devFiles": [],
|
|
6
6
|
"lowPriorityFiles": [
|
|
7
|
-
"static/
|
|
8
|
-
"static/
|
|
7
|
+
"static/YHx536pklAqlc9UGAXCj8/_buildManifest.js",
|
|
8
|
+
"static/YHx536pklAqlc9UGAXCj8/_ssgManifest.js"
|
|
9
9
|
],
|
|
10
10
|
"rootMainFiles": [],
|
|
11
11
|
"rootMainFilesTree": {},
|
|
12
12
|
"pages": {
|
|
13
13
|
"/": [
|
|
14
|
-
"static/chunks/webpack-
|
|
15
|
-
"static/chunks/framework-
|
|
16
|
-
"static/chunks/main-
|
|
17
|
-
"static/chunks/101-
|
|
18
|
-
"static/css/
|
|
19
|
-
"static/chunks/pages/index-
|
|
14
|
+
"static/chunks/webpack-31879a8fb6dcffd0.js",
|
|
15
|
+
"static/chunks/framework-988fd6f19d2744e8.js",
|
|
16
|
+
"static/chunks/main-d397eb8af33c5163.js",
|
|
17
|
+
"static/chunks/101-fee295c7587c0836.js",
|
|
18
|
+
"static/css/8b8087461767360a.css",
|
|
19
|
+
"static/chunks/pages/index-ea21d96a88f25000.js"
|
|
20
20
|
],
|
|
21
21
|
"/_app": [
|
|
22
|
-
"static/chunks/webpack-
|
|
23
|
-
"static/chunks/framework-
|
|
24
|
-
"static/chunks/main-
|
|
25
|
-
"static/css/
|
|
26
|
-
"static/chunks/pages/_app-
|
|
22
|
+
"static/chunks/webpack-31879a8fb6dcffd0.js",
|
|
23
|
+
"static/chunks/framework-988fd6f19d2744e8.js",
|
|
24
|
+
"static/chunks/main-d397eb8af33c5163.js",
|
|
25
|
+
"static/css/4dbcacc1122ac6b9.css",
|
|
26
|
+
"static/chunks/pages/_app-0c68f165d6de8507.js"
|
|
27
27
|
],
|
|
28
28
|
"/_error": [
|
|
29
|
-
"static/chunks/webpack-
|
|
30
|
-
"static/chunks/framework-
|
|
31
|
-
"static/chunks/main-
|
|
29
|
+
"static/chunks/webpack-31879a8fb6dcffd0.js",
|
|
30
|
+
"static/chunks/framework-988fd6f19d2744e8.js",
|
|
31
|
+
"static/chunks/main-d397eb8af33c5163.js",
|
|
32
32
|
"static/chunks/pages/_error-f0290a98eb3ed0ec.js"
|
|
33
33
|
],
|
|
34
34
|
"/not-found": [
|
|
35
|
-
"static/chunks/webpack-
|
|
36
|
-
"static/chunks/framework-
|
|
37
|
-
"static/chunks/main-
|
|
35
|
+
"static/chunks/webpack-31879a8fb6dcffd0.js",
|
|
36
|
+
"static/chunks/framework-988fd6f19d2744e8.js",
|
|
37
|
+
"static/chunks/main-d397eb8af33c5163.js",
|
|
38
38
|
"static/css/dee878eb64099d92.css",
|
|
39
39
|
"static/chunks/pages/not-found-a59f9deeeff63d17.js"
|
|
40
40
|
]
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"previewModeId":"
|
|
1
|
+
{"previewModeId":"e5835b7974ab86517c9c034b6f26b3cc","previewModeSigningKey":"fdd7ae3f3c9bd42534b79ae3f3ad08f8c08815d0712533b6bd6b5ba27ddc1d0e","previewModeEncryptionKey":"8a6fa29105f687ac81798e710d2478624efb911bfef8f3509bc47911dc94b0f6","expireAt":1792442881616}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"encryption.key":"
|
|
1
|
+
{"encryption.key":"9C2t0KMmOagP1Ue6VIKvAFFN+2eDiMLL5yiFkiey4eo=","encryption.expire_at":1792442881614}
|