scrabble-solver 2.18.2 → 2.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/.nx/workspace-data/d/server-process.json +3 -3
  2. package/.nx/workspace-data/file-map.json +1472 -1435
  3. package/.nx/workspace-data/lockfile-dependencies.hash +1 -1
  4. package/.nx/workspace-data/lockfile-nodes.hash +1 -1
  5. package/.nx/workspace-data/nx_files.nxt +0 -0
  6. package/.nx/workspace-data/parsed-lock-file.dependencies.json +528 -713
  7. package/.nx/workspace-data/parsed-lock-file.nodes.json +965 -1370
  8. package/.nx/workspace-data/project-graph.json +1561 -2156
  9. package/.nx/workspace-data/source-maps.json +48 -8
  10. package/.oxlintrc.json +0 -1
  11. package/CLAUDE.md +10 -8
  12. package/README.md +1 -1
  13. package/bun.lock +230 -279
  14. package/lerna.json +1 -1
  15. package/package.json +7 -6
  16. package/packages/configs/build/games/index.d.ts +1 -0
  17. package/packages/configs/build/games/index.js +1 -0
  18. package/packages/configs/build/games/wyrazy.d.ts +19 -0
  19. package/packages/configs/build/games/wyrazy.js +73 -0
  20. package/packages/configs/build/languages/polish.d.ts +1 -0
  21. package/packages/configs/build/languages/polish.js +39 -1
  22. package/packages/configs/package.json +5 -4
  23. package/packages/configs/src/games/index.ts +1 -0
  24. package/packages/configs/src/games/wyrazy.ts +71 -0
  25. package/packages/configs/src/languages/polish.ts +40 -1
  26. package/packages/constants/package.json +1 -1
  27. package/packages/dictionaries/bin/update-dictionaries.js +2 -5
  28. package/packages/dictionaries/build/Dictionaries.d.ts +1 -1
  29. package/packages/dictionaries/build/Dictionaries.js +9 -4
  30. package/packages/dictionaries/build/lib/DiskCache.js +1 -1
  31. package/packages/dictionaries/build/lib/downloadDictionary.d.ts +1 -1
  32. package/packages/dictionaries/build/lib/downloadDictionary.js +12 -7
  33. package/packages/dictionaries/package.json +4 -4
  34. package/packages/dictionaries/src/Dictionaries.ts +13 -6
  35. package/packages/dictionaries/src/lib/DiskCache.ts +2 -2
  36. package/packages/dictionaries/src/lib/downloadDictionary.ts +14 -6
  37. package/packages/logger/README.md +18 -0
  38. package/packages/logger/build/constants.d.ts +3 -0
  39. package/packages/logger/build/constants.js +4 -1
  40. package/packages/logger/build/describeError.d.ts +5 -0
  41. package/packages/logger/build/describeError.js +23 -0
  42. package/packages/logger/build/events.d.ts +92 -0
  43. package/packages/logger/build/events.js +13 -0
  44. package/packages/logger/build/formatCsvRow.d.ts +2 -0
  45. package/packages/logger/build/formatCsvRow.js +20 -0
  46. package/packages/logger/build/index.d.ts +5 -1
  47. package/packages/logger/build/index.js +13 -3
  48. package/packages/logger/build/logError.d.ts +4 -0
  49. package/packages/logger/build/logError.js +17 -0
  50. package/packages/logger/build/logEvent.d.ts +3 -0
  51. package/packages/logger/build/logEvent.js +29 -0
  52. package/packages/logger/package.json +7 -8
  53. package/packages/logger/scripts/export-csv.ts +113 -0
  54. package/packages/logger/scripts/readEvents.ts +34 -0
  55. package/packages/logger/scripts/tsconfig.json +10 -0
  56. package/packages/logger/src/constants.ts +9 -0
  57. package/packages/logger/src/describeError.test.ts +44 -0
  58. package/packages/logger/src/describeError.ts +30 -0
  59. package/packages/logger/src/events.ts +103 -0
  60. package/packages/logger/src/formatCsvRow.test.ts +41 -0
  61. package/packages/logger/src/formatCsvRow.ts +24 -0
  62. package/packages/logger/src/index.ts +13 -1
  63. package/packages/logger/src/logError.ts +24 -0
  64. package/packages/logger/src/logEvent.test.ts +59 -0
  65. package/packages/logger/src/logEvent.ts +26 -0
  66. package/packages/scrabble-solver/.next/BUILD_ID +1 -1
  67. package/packages/scrabble-solver/.next/build-manifest.json +15 -15
  68. package/packages/scrabble-solver/.next/cache/.previewinfo +1 -1
  69. package/packages/scrabble-solver/.next/cache/.rscinfo +1 -1
  70. package/packages/scrabble-solver/.next/cache/.tsbuildinfo +1 -1
  71. package/packages/scrabble-solver/.next/cache/webpack/client-production/0.pack +0 -0
  72. package/packages/scrabble-solver/.next/cache/webpack/client-production/index.pack +0 -0
  73. package/packages/scrabble-solver/.next/cache/webpack/client-production/index.pack.old +0 -0
  74. package/packages/scrabble-solver/.next/cache/webpack/edge-server-production/index.pack +0 -0
  75. package/packages/scrabble-solver/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
  76. package/packages/scrabble-solver/.next/cache/webpack/server-production/0.pack +0 -0
  77. package/packages/scrabble-solver/.next/cache/webpack/server-production/index.pack +0 -0
  78. package/packages/scrabble-solver/.next/cache/webpack/server-production/index.pack.old +0 -0
  79. package/packages/scrabble-solver/.next/diagnostics/framework.json +1 -1
  80. package/packages/scrabble-solver/.next/dynamic-css-manifest.json +1 -1
  81. package/packages/scrabble-solver/.next/prerender-manifest.json +4 -4
  82. package/packages/scrabble-solver/.next/react-loadable-manifest.json +6 -6
  83. package/packages/scrabble-solver/.next/routes-manifest.json +1 -1
  84. package/packages/scrabble-solver/.next/server/chunks/546.js +1 -1
  85. package/packages/scrabble-solver/.next/server/chunks/612.js +5 -0
  86. package/packages/scrabble-solver/.next/server/chunks/67.js +1 -1
  87. package/packages/scrabble-solver/.next/server/chunks/712.js +1 -1
  88. package/packages/scrabble-solver/.next/server/chunks/715.js +1 -0
  89. package/packages/scrabble-solver/.next/server/chunks/812.js +1 -1
  90. package/packages/scrabble-solver/.next/server/chunks/961.js +1 -1
  91. package/packages/scrabble-solver/.next/server/dynamic-css-manifest.js +1 -1
  92. package/packages/scrabble-solver/.next/server/middleware-build-manifest.js +1 -1
  93. package/packages/scrabble-solver/.next/server/middleware-react-loadable-manifest.js +1 -1
  94. package/packages/scrabble-solver/.next/server/pages/404.html +3 -3
  95. package/packages/scrabble-solver/.next/server/pages/500.html +3 -3
  96. package/packages/scrabble-solver/.next/server/pages/_app.js.nft.json +1 -1
  97. package/packages/scrabble-solver/.next/server/pages/_document.js +2 -2
  98. package/packages/scrabble-solver/.next/server/pages/_document.js.nft.json +1 -1
  99. package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale]/[word].js +2 -2
  100. package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  101. package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale].js +1 -1
  102. package/packages/scrabble-solver/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
  103. package/packages/scrabble-solver/.next/server/pages/api/solve.js +1 -1
  104. package/packages/scrabble-solver/.next/server/pages/api/solve.js.nft.json +1 -1
  105. package/packages/scrabble-solver/.next/server/pages/api/verify.js +1 -1
  106. package/packages/scrabble-solver/.next/server/pages/api/verify.js.nft.json +1 -1
  107. package/packages/scrabble-solver/.next/server/pages/api/visit.js +1 -1
  108. package/packages/scrabble-solver/.next/server/pages/api/visit.js.nft.json +1 -1
  109. package/packages/scrabble-solver/.next/server/pages/index.html +3 -3
  110. package/packages/scrabble-solver/.next/server/pages/index.js +1 -1
  111. package/packages/scrabble-solver/.next/server/pages/index.js.nft.json +1 -1
  112. package/packages/scrabble-solver/.next/server/pages/index.json +1 -1
  113. package/packages/scrabble-solver/.next/server/pages/not-found.html +3 -3
  114. package/packages/scrabble-solver/.next/server/pages/not-found.js.nft.json +1 -1
  115. package/packages/scrabble-solver/.next/static/{nvGZh1gPuX98L07YmxlYD → TAqjh9l5VJQOfJ4SzgPk2}/_buildManifest.js +1 -1
  116. package/packages/scrabble-solver/.next/static/chunks/101-f341f70c10d71212.js +3 -0
  117. package/packages/scrabble-solver/.next/static/chunks/256.6a20b23a766cfebd.js +1 -0
  118. package/packages/scrabble-solver/.next/static/chunks/693.d295da7c7b873986.js +1 -0
  119. package/packages/scrabble-solver/.next/static/chunks/980.dd35e807b18063c1.js +1 -0
  120. package/packages/scrabble-solver/.next/static/chunks/{main-7e52da35a4beea0b.js → main-6e9ac37e28d2b899.js} +1 -1
  121. package/packages/scrabble-solver/.next/static/chunks/pages/_app-9340d51f5a6787fc.js +1 -0
  122. package/packages/scrabble-solver/.next/static/chunks/pages/index-ea21d96a88f25000.js +1 -0
  123. package/packages/scrabble-solver/.next/static/chunks/{webpack-9f5c9d275348829b.js → webpack-d73aa4aae49cd800.js} +1 -1
  124. package/packages/scrabble-solver/.next/static/css/{92f85ce358a0a468.css → 4dbcacc1122ac6b9.css} +1 -1
  125. package/packages/scrabble-solver/.next/static/css/8b8087461767360a.css +1 -0
  126. package/packages/scrabble-solver/.next/static/css/{65bcc9440e067113.css → 98051ed25076b896.css} +1 -1
  127. package/packages/scrabble-solver/.next/trace +22 -21
  128. package/packages/scrabble-solver/.next/trace-build +1 -1
  129. package/packages/scrabble-solver/package.json +10 -10
  130. package/packages/scrabble-solver/public/llms.txt +1 -1
  131. package/packages/scrabble-solver/public/service-worker.js +1 -1
  132. package/packages/scrabble-solver/src/api/BadRequestError.ts +6 -0
  133. package/packages/scrabble-solver/src/api/index.ts +2 -1
  134. package/packages/scrabble-solver/src/api/withApiLog.ts +85 -0
  135. package/packages/scrabble-solver/src/components/Button/Button.module.scss +1 -1
  136. package/packages/scrabble-solver/src/components/Solver/Solver.module.scss +4 -4
  137. package/packages/scrabble-solver/src/components/Solver/Solver.tsx +2 -2
  138. package/packages/scrabble-solver/src/hooks/useColumns.ts +5 -7
  139. package/packages/scrabble-solver/src/modals/ResultsModal/ResultsModal.tsx +2 -2
  140. package/packages/scrabble-solver/src/modals/WordsModal/WordsModal.module.scss +1 -1
  141. package/packages/scrabble-solver/src/modals/WordsModal/WordsModal.tsx +2 -2
  142. package/packages/scrabble-solver/src/modals/WordsModal/components/WordsTable/WordsTable.tsx +2 -2
  143. package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/[word].ts +22 -30
  144. package/packages/scrabble-solver/src/pages/api/dictionary/[locale]/index.ts +36 -42
  145. package/packages/scrabble-solver/src/pages/api/solve.ts +32 -42
  146. package/packages/scrabble-solver/src/pages/api/verify.ts +35 -43
  147. package/packages/scrabble-solver/src/pages/api/visit.ts +38 -13
  148. package/packages/scrabble-solver/src/pages/index.tsx +3 -3
  149. package/packages/scrabble-solver/src/parameters/index.ts +0 -2
  150. package/packages/scrabble-solver/src/sdk/solve.ts +3 -9
  151. package/packages/scrabble-solver/src/sdk/verify.ts +2 -10
  152. package/packages/scrabble-solver/src/sdk/visit.ts +8 -3
  153. package/packages/scrabble-solver/src/state/sagas.ts +7 -1
  154. package/packages/scrabble-solver/src/styles/_tokens.scss +0 -2
  155. package/packages/scrabble-solver/src/styles/mixins.scss +0 -5
  156. package/packages/scrabble-solver/src/styles/variables.module.scss +0 -1
  157. package/packages/scrabble-solver/src/styles/variables.module.scss.d.ts +0 -1
  158. package/packages/scrabble-solver/src/styles/variables.scss +1 -1
  159. package/packages/scrabble-solver/src/types/api.ts +7 -0
  160. package/packages/scrabble-solver/tsconfig.tsbuildinfo +1 -1
  161. package/packages/solver/package.json +5 -5
  162. package/packages/types/build/Game.d.ts +2 -1
  163. package/packages/types/build/Game.js +1 -0
  164. package/packages/types/package.json +2 -2
  165. package/packages/types/src/Game.ts +1 -0
  166. package/packages/word-definitions/package.json +2 -2
  167. package/packages/word-lists/package.json +2 -2
  168. package/playwright-report/index.html +1 -1
  169. package/packages/logger/build/logger.d.ts +0 -1
  170. package/packages/logger/build/logger.js +0 -28
  171. package/packages/logger/scripts/stats.js +0 -52
  172. package/packages/logger/src/logger.ts +0 -30
  173. package/packages/scrabble-solver/.next/server/chunks/392.js +0 -1
  174. package/packages/scrabble-solver/.next/static/chunks/101-829ab8a8d310e7a0.js +0 -3
  175. package/packages/scrabble-solver/.next/static/chunks/256.66ff9fb6d7db9ec7.js +0 -1
  176. package/packages/scrabble-solver/.next/static/chunks/693.677c88e0eb1340b5.js +0 -1
  177. package/packages/scrabble-solver/.next/static/chunks/980.7ce17464fc56648a.js +0 -1
  178. package/packages/scrabble-solver/.next/static/chunks/pages/_app-f9ec81278ffa7b1a.js +0 -1
  179. package/packages/scrabble-solver/.next/static/chunks/pages/index-b5bf018100015277.js +0 -1
  180. package/packages/scrabble-solver/.next/static/css/a691348b788f8bc0.css +0 -1
  181. package/packages/scrabble-solver/src/api/getServerLoggingData.ts +0 -17
  182. package/packages/scrabble-solver/src/hooks/useIsCompactLayout.ts +0 -14
  183. /package/packages/scrabble-solver/.next/static/{nvGZh1gPuX98L07YmxlYD → TAqjh9l5VJQOfJ4SzgPk2}/_ssgManifest.js +0 -0
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeError = describeError;
4
+ function describeError(error) {
5
+ return { message: formatError(error), stack: error instanceof Error ? error.stack : undefined };
6
+ }
7
+ function formatError(error) {
8
+ if (!(error instanceof Error)) {
9
+ return String(error);
10
+ }
11
+ const heading = `${error.name}${formatCode(error)}: ${error.message}`.trimEnd();
12
+ const nested = getNestedErrors(error);
13
+ return nested.length === 0 ? heading : `${heading} (${nested.map(formatError).join('; ')})`;
14
+ }
15
+ function formatCode(error) {
16
+ return 'code' in error && typeof error.code === 'string' ? ` [${error.code}]` : '';
17
+ }
18
+ function getNestedErrors(error) {
19
+ if (error instanceof AggregateError) {
20
+ return error.errors;
21
+ }
22
+ return error.cause === undefined ? [] : [error.cause];
23
+ }
@@ -0,0 +1,92 @@
1
+ export type Event = VisitEvent | SolveEvent | VerifyEvent | DownloadEvent | DefinitionEvent | BuildEvent | ErrorEvent;
2
+ export type EventType = Event['type'];
3
+ export type EventOf<T extends EventType> = Extract<Event, {
4
+ type: T;
5
+ }>;
6
+ export type EventValue = string | number | boolean | undefined;
7
+ export type LoggedEvent = Event & {
8
+ timestamp: string;
9
+ };
10
+ export type Operation = 'visit' | 'solve' | 'verify' | 'download' | 'definition' | 'build' | 'cache';
11
+ type VisitEvent = {
12
+ type: 'visit';
13
+ ip?: string;
14
+ ua?: string;
15
+ referrer?: string;
16
+ locale?: string;
17
+ game?: string;
18
+ url?: string;
19
+ };
20
+ type SolveEvent = {
21
+ type: 'solve';
22
+ ip?: string;
23
+ ms: number;
24
+ locale: string;
25
+ game: string;
26
+ tiles: number;
27
+ blanks: number;
28
+ rack: string;
29
+ results: number;
30
+ };
31
+ type VerifyEvent = {
32
+ type: 'verify';
33
+ ip?: string;
34
+ ms: number;
35
+ locale: string;
36
+ game: string;
37
+ tiles: number;
38
+ blanks: number;
39
+ valid: number;
40
+ invalid: number;
41
+ };
42
+ type DownloadEvent = {
43
+ type: 'download';
44
+ ip?: string;
45
+ ms: number;
46
+ locale: string;
47
+ status: number;
48
+ encoding: 'gzip' | 'identity';
49
+ bytes: number;
50
+ };
51
+ type DefinitionEvent = {
52
+ type: 'definition';
53
+ ip?: string;
54
+ ms: number;
55
+ locale: string;
56
+ words: string;
57
+ found: number;
58
+ };
59
+ type BuildEvent = {
60
+ type: 'build';
61
+ locale: string;
62
+ words: number;
63
+ download_ms: number;
64
+ build_ms: number;
65
+ };
66
+ type ErrorEvent = {
67
+ type: 'error';
68
+ level: 'error' | 'warn';
69
+ operation: Operation;
70
+ locale?: string;
71
+ ip?: string;
72
+ ua?: string;
73
+ message: string;
74
+ stack?: string;
75
+ input?: string;
76
+ };
77
+ type FieldsOf<T extends EventType> = Exclude<keyof EventOf<T>, 'type'>;
78
+ export declare const EVENT_FIELDS: {
79
+ readonly visit: readonly ["ip", "ua", "referrer", "locale", "game", "url"];
80
+ readonly solve: readonly ["ip", "ms", "locale", "game", "tiles", "blanks", "rack", "results"];
81
+ readonly verify: readonly ["ip", "ms", "locale", "game", "tiles", "blanks", "valid", "invalid"];
82
+ readonly download: readonly ["ip", "ms", "locale", "status", "encoding", "bytes"];
83
+ readonly definition: readonly ["ip", "ms", "locale", "words", "found"];
84
+ readonly build: readonly ["locale", "words", "download_ms", "build_ms"];
85
+ readonly error: readonly ["level", "operation", "locale", "ip", "ua", "message", "stack", "input"];
86
+ };
87
+ type UnlistedFields = {
88
+ [T in EventType]: Exclude<FieldsOf<T>, (typeof EVENT_FIELDS)[T][number]>;
89
+ }[EventType];
90
+ type Assert<T extends never> = T;
91
+ export type EveryFieldIsListed = Assert<UnlistedFields>;
92
+ export {};
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EVENT_FIELDS = void 0;
4
+ // Field order is the column order of the exported CSV files - append only, never reorder.
5
+ exports.EVENT_FIELDS = {
6
+ visit: ['ip', 'ua', 'referrer', 'locale', 'game', 'url'],
7
+ solve: ['ip', 'ms', 'locale', 'game', 'tiles', 'blanks', 'rack', 'results'],
8
+ verify: ['ip', 'ms', 'locale', 'game', 'tiles', 'blanks', 'valid', 'invalid'],
9
+ download: ['ip', 'ms', 'locale', 'status', 'encoding', 'bytes'],
10
+ definition: ['ip', 'ms', 'locale', 'words', 'found'],
11
+ build: ['locale', 'words', 'download_ms', 'build_ms'],
12
+ error: ['level', 'operation', 'locale', 'ip', 'ua', 'message', 'stack', 'input'],
13
+ };
@@ -0,0 +1,2 @@
1
+ import { type EventValue } from './events';
2
+ export declare function formatCsvRow(values: EventValue[]): string;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCsvRow = formatCsvRow;
4
+ const FORMULA_PREFIX = /^[=+\-@\t\r]/;
5
+ function formatCsvRow(values) {
6
+ return `${values.map(formatCsvField).join(',')}\n`;
7
+ }
8
+ function formatCsvField(value) {
9
+ if (typeof value === 'undefined') {
10
+ return '';
11
+ }
12
+ const text = typeof value === 'string' ? escapeFormula(value) : String(value);
13
+ return needsQuoting(text) ? `"${text.replaceAll('"', '""')}"` : text;
14
+ }
15
+ function escapeFormula(text) {
16
+ return FORMULA_PREFIX.test(text) ? `'${text}` : text;
17
+ }
18
+ function needsQuoting(text) {
19
+ return /[",\n\r]/.test(text) || text !== text.trim();
20
+ }
@@ -1 +1,5 @@
1
- export { logger } from './logger';
1
+ export { CSV_DIRECTORY, EVENTS_FILEPATH, OUTPUT_DIRECTORY } from './constants';
2
+ export { EVENT_FIELDS, type Event, type EventOf, type EventType, type EventValue, type LoggedEvent, type Operation, } from './events';
3
+ export { formatCsvRow } from './formatCsvRow';
4
+ export { logError } from './logError';
5
+ export { logEvent } from './logEvent';
@@ -1,5 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.logger = void 0;
4
- var logger_1 = require("./logger");
5
- Object.defineProperty(exports, "logger", { enumerable: true, get: function () { return logger_1.logger; } });
3
+ exports.logEvent = exports.logError = exports.formatCsvRow = exports.EVENT_FIELDS = exports.OUTPUT_DIRECTORY = exports.EVENTS_FILEPATH = exports.CSV_DIRECTORY = void 0;
4
+ var constants_1 = require("./constants");
5
+ Object.defineProperty(exports, "CSV_DIRECTORY", { enumerable: true, get: function () { return constants_1.CSV_DIRECTORY; } });
6
+ Object.defineProperty(exports, "EVENTS_FILEPATH", { enumerable: true, get: function () { return constants_1.EVENTS_FILEPATH; } });
7
+ Object.defineProperty(exports, "OUTPUT_DIRECTORY", { enumerable: true, get: function () { return constants_1.OUTPUT_DIRECTORY; } });
8
+ var events_1 = require("./events");
9
+ Object.defineProperty(exports, "EVENT_FIELDS", { enumerable: true, get: function () { return events_1.EVENT_FIELDS; } });
10
+ var formatCsvRow_1 = require("./formatCsvRow");
11
+ Object.defineProperty(exports, "formatCsvRow", { enumerable: true, get: function () { return formatCsvRow_1.formatCsvRow; } });
12
+ var logError_1 = require("./logError");
13
+ Object.defineProperty(exports, "logError", { enumerable: true, get: function () { return logError_1.logError; } });
14
+ var logEvent_1 = require("./logEvent");
15
+ Object.defineProperty(exports, "logEvent", { enumerable: true, get: function () { return logEvent_1.logEvent; } });
@@ -0,0 +1,4 @@
1
+ import { type EventOf, type Operation } from './events';
2
+ type ErrorContext = Partial<Pick<EventOf<'error'>, 'level' | 'input' | 'ip' | 'locale' | 'ua'>>;
3
+ export declare function logError(operation: Operation, error: unknown, { level, ...context }?: ErrorContext): void;
4
+ export {};
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.logError = logError;
4
+ const constants_1 = require("./constants");
5
+ const describeError_1 = require("./describeError");
6
+ const logEvent_1 = require("./logEvent");
7
+ function logError(operation, error, { level = 'error', ...context } = {}) {
8
+ const description = (0, describeError_1.describeError)(error);
9
+ (0, logEvent_1.logEvent)({ type: 'error', level, operation, ...description, ...context });
10
+ if (level === 'error' && !constants_1.IS_TEST_RUN) {
11
+ process.stderr.write(formatStderrEntry(operation, description));
12
+ }
13
+ }
14
+ function formatStderrEntry(operation, { message, stack }) {
15
+ const frames = stack?.split('\n').filter((line) => line.startsWith(' at ')) ?? [];
16
+ return `${[`${operation}: ${message}`, ...frames].join('\n')}\n`;
17
+ }
@@ -0,0 +1,3 @@
1
+ import { type Event } from './events';
2
+ export declare function logEvent(event: Event): void;
3
+ export declare function formatEventLine(event: Event, now: Date): string;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.logEvent = logEvent;
7
+ exports.formatEventLine = formatEventLine;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const constants_1 = require("./constants");
10
+ const events_1 = require("./events");
11
+ function logEvent(event) {
12
+ if (constants_1.IS_TEST_RUN) {
13
+ return;
14
+ }
15
+ try {
16
+ fs_1.default.mkdirSync(constants_1.OUTPUT_DIRECTORY, { recursive: true });
17
+ fs_1.default.appendFileSync(constants_1.EVENTS_FILEPATH, formatEventLine(event, new Date()));
18
+ }
19
+ catch (error) {
20
+ process.stderr.write(`Could not append to ${constants_1.EVENTS_FILEPATH}: ${String(error)}\n`);
21
+ }
22
+ }
23
+ function formatEventLine(event, now) {
24
+ const line = { timestamp: formatTimestamp(now), ...event };
25
+ return `${JSON.stringify(line, ['timestamp', 'type', ...events_1.EVENT_FIELDS[event.type]])}\n`;
26
+ }
27
+ function formatTimestamp(date) {
28
+ return `${date.toISOString().slice(0, 19)}Z`;
29
+ }
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@scrabble-solver/logger",
3
- "version": "2.18.2",
4
- "description": "Winston-based JSON file logger for Scrabble Solver.",
3
+ "version": "2.18.4",
4
+ "description": "JSON-lines event log for Scrabble Solver, with CSV export.",
5
5
  "keywords": [
6
6
  "logger",
7
- "winston",
7
+ "events",
8
+ "csv",
8
9
  "scrabble"
9
10
  ],
10
11
  "main": "build/index.js",
@@ -24,12 +25,10 @@
24
25
  },
25
26
  "homepage": "https://scrabble-solver.org",
26
27
  "scripts": {
27
- "build": "tsc --project .",
28
+ "build": "tsc --project . && tsc --project scripts",
28
29
  "clean": "rimraf build/",
29
- "stats": "node scripts/stats.js"
30
- },
31
- "dependencies": {
32
- "winston": "^3.19.0"
30
+ "export": "bun scripts/export-csv.ts",
31
+ "test": "bun test src"
33
32
  },
34
33
  "gitHead": "1e2d1ad49a288d4a44196d8e3336740da132c637"
35
34
  }
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+
4
+ "compilerOptions": {
5
+ "module": "preserve",
6
+ "moduleResolution": "bundler",
7
+ "noEmit": true
8
+ },
9
+ "include": ["./**/*.ts"]
10
+ }
@@ -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
+ });