@hickeroar/tsbayes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +277 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +15 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/classifier.d.ts +36 -0
  8. package/dist/core/classifier.js +227 -0
  9. package/dist/core/classifier.js.map +1 -0
  10. package/dist/core/constants.d.ts +3 -0
  11. package/dist/core/constants.js +4 -0
  12. package/dist/core/constants.js.map +1 -0
  13. package/dist/core/errors.d.ts +6 -0
  14. package/dist/core/errors.js +13 -0
  15. package/dist/core/errors.js.map +1 -0
  16. package/dist/core/persistence.d.ts +7 -0
  17. package/dist/core/persistence.js +92 -0
  18. package/dist/core/persistence.js.map +1 -0
  19. package/dist/core/tokenizer.d.ts +5 -0
  20. package/dist/core/tokenizer.js +17 -0
  21. package/dist/core/tokenizer.js.map +1 -0
  22. package/dist/core/types.d.ts +16 -0
  23. package/dist/core/types.js +2 -0
  24. package/dist/core/types.js.map +1 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/index.js +5 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/server/app.d.ts +15 -0
  29. package/dist/server/app.js +107 -0
  30. package/dist/server/app.js.map +1 -0
  31. package/dist/server/auth.d.ts +5 -0
  32. package/dist/server/auth.js +49 -0
  33. package/dist/server/auth.js.map +1 -0
  34. package/dist/server/config.d.ts +6 -0
  35. package/dist/server/config.js +19 -0
  36. package/dist/server/config.js.map +1 -0
  37. package/dist/server/readiness.d.ts +5 -0
  38. package/dist/server/readiness.js +10 -0
  39. package/dist/server/readiness.js.map +1 -0
  40. package/dist/server/start.d.ts +3 -0
  41. package/dist/server/start.js +10 -0
  42. package/dist/server/start.js.map +1 -0
  43. package/package.json +77 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## [1.0.0] - 2026-02-21
6
+
7
+ - Initial TypeScript implementation of the classifier core.
8
+ - Added HTTP service with training, scoring, classification, and lifecycle endpoints.
9
+ - Invalid `/train/:category` and `/untrain/:category` params now return `400` with `{"error":"invalid request"}`.
10
+ - Added JSON model persistence with validation and atomic file writes.
11
+ - Added strict testing, linting, and CI foundations.
12
+ - Added scoped security audit policy in CI (blocking production dependency audit + non-blocking full-tree visibility report).
13
+ - Pinned vulnerable transitive `minimatch` via npm `overrides` to clear high-severity audit findings.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Vennell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,277 @@
1
+ # tsbayes
2
+
3
+ A memory-based, optional-persistence naive Bayesian text classification package and web API for TypeScript/Node.js.
4
+
5
+ [![CI](https://github.com/hickeroar/tsbayes/actions/workflows/ci.yml/badge.svg)](https://github.com/hickeroar/tsbayes/actions/workflows/ci.yml)
6
+ [![npm version](https://img.shields.io/npm/v/%40hickeroar%2Ftsbayes.svg)](https://www.npmjs.com/package/@hickeroar/tsbayes)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
8
+
9
+ ---
10
+
11
+ ## Why?
12
+
13
+ ```text
14
+ Bayesian text classification is useful for things like spam detection,
15
+ sentiment determination, and general category routing.
16
+
17
+ You gather representative samples for each category, train the model,
18
+ then classify new text based on learned token patterns.
19
+
20
+ Once the model is trained, you can:
21
+ - classify input into a best-fit category
22
+ - inspect relative per-category scores
23
+ - persist and reload model state
24
+ ```
25
+
26
+ ## Installation
27
+
28
+ Requires Node.js 20 or newer.
29
+
30
+ Package usage:
31
+
32
+ ```bash
33
+ npm install @hickeroar/tsbayes
34
+ ```
35
+
36
+ Contributor/development setup:
37
+
38
+ ```bash
39
+ git clone <your-repo-url>
40
+ cd tsbayes
41
+ npm ci
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Run as an API Server
47
+
48
+ ```bash
49
+ npm run dev
50
+ ```
51
+
52
+ Production-style run:
53
+
54
+ ```bash
55
+ npm run build
56
+ npm run start
57
+ ```
58
+
59
+ Environment variables:
60
+
61
+ ```text
62
+ TSBAYES_HOST
63
+ TSBAYES_PORT
64
+ TSBAYES_AUTH_TOKEN
65
+ ```
66
+
67
+ When `TSBAYES_AUTH_TOKEN` is configured, all API endpoints except `/healthz` and `/readyz` require:
68
+
69
+ ```text
70
+ Authorization: Bearer <token>
71
+ ```
72
+
73
+ ## Use as a Library in Your App
74
+
75
+ Import and create a classifier:
76
+
77
+ ```ts
78
+ import { TextClassifier, loadFromFile, saveToFile } from "@hickeroar/tsbayes";
79
+
80
+ const classifier = new TextClassifier();
81
+
82
+ classifier.train("spam", "buy now limited offer click here");
83
+ classifier.train("ham", "team meeting schedule for tomorrow");
84
+
85
+ const classification = classifier.classificationResult("limited offer today");
86
+ console.log(`category=${classification.category} score=${classification.score}`);
87
+
88
+ const scores = classifier.score("team schedule update");
89
+ console.log(scores);
90
+
91
+ classifier.untrain("spam", "buy now limited offer click here");
92
+
93
+ await saveToFile(classifier, "/tmp/tsbayes-model.json");
94
+ const loaded = new TextClassifier();
95
+ await loadFromFile(loaded, "/tmp/tsbayes-model.json");
96
+ console.log(loaded.classificationResult("limited offer today"));
97
+ ```
98
+
99
+ Notes for library usage:
100
+
101
+ - Classifier operations are safe for concurrent request handling in a single Node process.
102
+ - Scores are relative values; compare scores within the same model.
103
+ - Default tokenization applies Unicode NFKC normalization, lowercasing, non-word splitting, and English stemming.
104
+ - Category names accepted by `train` and `untrain` match `^[-_A-Za-z0-9]{1,64}$`.
105
+
106
+ File API notes:
107
+
108
+ - `saveToFile` and `loadFromFile` default to `/tmp/tsbayes-model.json` when no path is provided.
109
+ - Provided file paths must be absolute.
110
+
111
+ ## Development Checks
112
+
113
+ ```bash
114
+ npm run format:check
115
+ npm run lint
116
+ npm run typecheck
117
+ npm run test:coverage
118
+ npm run build
119
+ npm run standalone:audit
120
+ ```
121
+
122
+ Security checks used by CI:
123
+
124
+ ```bash
125
+ npm audit --omit=dev --audit-level=high
126
+ npm audit --audit-level=high
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Using the HTTP API
132
+
133
+ ### API Notes
134
+
135
+ - Category names in `/train/:category` and `/untrain/:category` must match `^[-_A-Za-z0-9]{1,64}$`.
136
+ - Invalid category path params return `400` with `{"error":"invalid request"}`.
137
+ - Request body size is capped at 1 MiB.
138
+ - Error responses are JSON: `{"error":"<message>"}`.
139
+ - If `charset` is declared in `Content-Type`, it must be UTF-8.
140
+ - The HTTP service stores classifier state in memory; process restarts clear training data.
141
+ - Empty request bodies are accepted for `/train/:category` and `/untrain/:category`; this is a no-op for model token tallies.
142
+
143
+ ### Common Error Responses
144
+
145
+ | Status | When |
146
+ | ------ | ----------------------------------------------------------------------------------------------------------- |
147
+ | `400` | Invalid payload or route params (for example non-UTF-8 charset, non-text body, or invalid category pattern) |
148
+ | `401` | Missing/invalid bearer token when auth is enabled |
149
+ | `404` | Invalid route |
150
+ | `413` | Request body exceeds 1 MiB |
151
+ | `500` | Unexpected server error |
152
+
153
+ ### Training the Classifier
154
+
155
+ ##### Endpoint:
156
+
157
+ ```text
158
+ /train/:category
159
+ Example: /train/spam
160
+ Accepts: POST
161
+ Body: raw text/plain
162
+ ```
163
+
164
+ Example:
165
+
166
+ ```bash
167
+ curl -s -X POST "http://localhost:8000/train/spam" \
168
+ -H "Content-Type: text/plain" \
169
+ --data "buy now limited offer click here"
170
+ ```
171
+
172
+ ### Untraining the Classifier
173
+
174
+ ##### Endpoint:
175
+
176
+ ```text
177
+ /untrain/:category
178
+ Example: /untrain/spam
179
+ Accepts: POST
180
+ Body: raw text/plain
181
+ ```
182
+
183
+ ### Getting Classifier Status
184
+
185
+ ##### Endpoint:
186
+
187
+ ```text
188
+ /info
189
+ Accepts: GET
190
+ ```
191
+
192
+ Example response:
193
+
194
+ ```json
195
+ {
196
+ "categories": [
197
+ {
198
+ "category": "spam",
199
+ "tokenTally": 6
200
+ }
201
+ ]
202
+ }
203
+ ```
204
+
205
+ ### Classifying Text
206
+
207
+ ##### Endpoint:
208
+
209
+ ```text
210
+ /classify
211
+ Accepts: POST
212
+ Body: raw text/plain
213
+ ```
214
+
215
+ Example response:
216
+
217
+ ```json
218
+ {
219
+ "category": "spam",
220
+ "score": 3.2142857142857144
221
+ }
222
+ ```
223
+
224
+ If no category can be selected (for example, untrained model), `category` is returned as `null`.
225
+
226
+ ### Scoring Text
227
+
228
+ ##### Endpoint:
229
+
230
+ ```text
231
+ /score
232
+ Accepts: POST
233
+ Body: raw text/plain
234
+ ```
235
+
236
+ Example response:
237
+
238
+ ```json
239
+ {
240
+ "spam": 3.2142857142857144,
241
+ "ham": 0.7857142857142857
242
+ }
243
+ ```
244
+
245
+ ### Flushing Training Data
246
+
247
+ ##### Endpoint:
248
+
249
+ ```text
250
+ /flush
251
+ Accepts: POST
252
+ Body: raw text/plain (optional)
253
+ ```
254
+
255
+ ### Health and Readiness
256
+
257
+ ##### Liveness endpoint
258
+
259
+ ```text
260
+ /healthz
261
+ Accepts: GET
262
+ ```
263
+
264
+ ##### Readiness endpoint
265
+
266
+ ```text
267
+ /readyz
268
+ Accepts: GET
269
+ ```
270
+
271
+ `/healthz` and `/readyz` are intentionally unauthenticated even when API auth is enabled.
272
+
273
+ ## Operational Notes
274
+
275
+ - The HTTP server is in-memory by default; deploys/restarts wipe trained state.
276
+ - Use `saveToFile` and `loadFromFile` in library workflows to persist/reload model state.
277
+ - `/readyz` returns `200` while accepting traffic and `503` when draining during shutdown.
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,15 @@
1
+ import { loadConfig } from "./server/config.js";
2
+ import { startServer } from "./server/start.js";
3
+ async function run() {
4
+ const config = loadConfig(process.env);
5
+ const context = await startServer(config);
6
+ const shutdown = async () => {
7
+ context.readiness.setReady(false);
8
+ await context.app.close();
9
+ process.exit(0);
10
+ };
11
+ process.on("SIGTERM", () => void shutdown());
12
+ process.on("SIGINT", () => void shutdown());
13
+ }
14
+ void run();
15
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,KAAK,UAAU,GAAG;IAChB,MAAM,MAAM,GAAG,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACvC,MAAM,OAAO,GAAG,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC;IAE1C,MAAM,QAAQ,GAAG,KAAK,IAAmB,EAAE;QACzC,OAAO,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QAClC,MAAM,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;QAC1B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC;IAEF,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,KAAK,QAAQ,EAAE,CAAC,CAAC;IAC7C,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK,QAAQ,EAAE,CAAC,CAAC;AAC9C,CAAC;AAED,KAAK,GAAG,EAAE,CAAC"}
@@ -0,0 +1,36 @@
1
+ import type { CategorySummary, ClassificationResult, PersistedModelState } from "./types.js";
2
+ /**
3
+ * In-memory naive Bayes classifier for train/untrain/score/classify workflows.
4
+ * Scores are relative ranking values, not calibrated probabilities.
5
+ */
6
+ export declare class TextClassifier {
7
+ private readonly categories;
8
+ /** Adds token counts from text into the target category. */
9
+ train(category: string, text: string): void;
10
+ /**
11
+ * Removes token counts contributed by text from a category.
12
+ * Missing categories/tokens are treated as no-ops.
13
+ */
14
+ untrain(category: string, text: string): void;
15
+ /** Returns per-category relative scores for the input text. */
16
+ score(text: string): Record<string, number>;
17
+ /** Convenience API returning only the predicted category. */
18
+ classify(text: string): string | null;
19
+ /** Returns top category plus score; ties resolve lexicographically. */
20
+ classificationResult(text: string): ClassificationResult;
21
+ /** Clears all trained model state. */
22
+ flush(): void;
23
+ /** Summarizes trained categories in stable lexical order. */
24
+ categorySummaries(): CategorySummary[];
25
+ /** Serializes classifier state into the versioned persistence model. */
26
+ save(): PersistedModelState;
27
+ /** Loads and validates a persisted model state into memory. */
28
+ load(model: PersistedModelState): void;
29
+ private ensureCategory;
30
+ private validateCategory;
31
+ private totalTally;
32
+ private tokenTotal;
33
+ private calculateBayesianProbability;
34
+ }
35
+ /** Validates persisted model invariants before load. */
36
+ export declare function validateModelState(model: PersistedModelState): void;
@@ -0,0 +1,227 @@
1
+ import { CATEGORY_PATTERN, MODEL_VERSION } from "./constants.js";
2
+ import { PersistenceError, ValidationError } from "./errors.js";
3
+ import { tokenize } from "./tokenizer.js";
4
+ /**
5
+ * In-memory naive Bayes classifier for train/untrain/score/classify workflows.
6
+ * Scores are relative ranking values, not calibrated probabilities.
7
+ */
8
+ export class TextClassifier {
9
+ categories = new Map();
10
+ /** Adds token counts from text into the target category. */
11
+ train(category, text) {
12
+ this.validateCategory(category);
13
+ const tokens = tokenize(text);
14
+ const tokenOccurrences = countOccurrences(tokens);
15
+ const state = this.ensureCategory(category);
16
+ for (const [token, count] of tokenOccurrences) {
17
+ const currentCount = state.tokens.get(token) ?? 0;
18
+ state.tokens.set(token, currentCount + count);
19
+ state.tally += count;
20
+ }
21
+ }
22
+ /**
23
+ * Removes token counts contributed by text from a category.
24
+ * Missing categories/tokens are treated as no-ops.
25
+ */
26
+ untrain(category, text) {
27
+ this.validateCategory(category);
28
+ const state = this.categories.get(category);
29
+ if (!state) {
30
+ return;
31
+ }
32
+ const tokenOccurrences = countOccurrences(tokenize(text));
33
+ for (const [token, count] of tokenOccurrences) {
34
+ const existing = state.tokens.get(token) ?? 0;
35
+ if (existing <= 0) {
36
+ continue;
37
+ }
38
+ const decrement = Math.min(existing, count);
39
+ const nextValue = existing - decrement;
40
+ state.tally -= decrement;
41
+ if (nextValue <= 0) {
42
+ state.tokens.delete(token);
43
+ }
44
+ else {
45
+ state.tokens.set(token, nextValue);
46
+ }
47
+ }
48
+ if (state.tally <= 0) {
49
+ this.categories.delete(category);
50
+ }
51
+ }
52
+ /** Returns per-category relative scores for the input text. */
53
+ score(text) {
54
+ const tokens = tokenize(text);
55
+ const occurrences = countOccurrences(tokens);
56
+ const totalTally = this.totalTally();
57
+ if (totalTally <= 0) {
58
+ return {};
59
+ }
60
+ const scores = new Map();
61
+ for (const [token, occurrenceCount] of occurrences) {
62
+ const tokenTotal = this.tokenTotal(token);
63
+ // Unknown tokens add no evidence to any category.
64
+ if (tokenTotal <= 0) {
65
+ continue;
66
+ }
67
+ for (const [name, state] of this.categories) {
68
+ const tokenInCategory = state.tokens.get(token) ?? 0;
69
+ const probability = this.calculateBayesianProbability({
70
+ tokenInCategory,
71
+ tokenTotal,
72
+ categoryTally: state.tally,
73
+ totalTally
74
+ });
75
+ const current = scores.get(name) ?? 0;
76
+ scores.set(name, current + occurrenceCount * probability);
77
+ }
78
+ }
79
+ const response = {};
80
+ for (const [category, value] of scores) {
81
+ // Keep output aligned with reference behavior: only positive scores are emitted.
82
+ if (value > 0) {
83
+ response[category] = value;
84
+ }
85
+ }
86
+ return response;
87
+ }
88
+ /** Convenience API returning only the predicted category. */
89
+ classify(text) {
90
+ return this.classificationResult(text).category;
91
+ }
92
+ /** Returns top category plus score; ties resolve lexicographically. */
93
+ classificationResult(text) {
94
+ const scores = this.score(text);
95
+ const orderedScores = Object.entries(scores).sort(([a], [b]) => a.localeCompare(b));
96
+ let bestCategory = null;
97
+ let bestScore = 0;
98
+ // We sort first so strict ">" makes tie results deterministic.
99
+ for (const [category, score] of orderedScores) {
100
+ if (score > bestScore) {
101
+ bestScore = score;
102
+ bestCategory = category;
103
+ }
104
+ }
105
+ return { category: bestCategory, score: bestScore };
106
+ }
107
+ /** Clears all trained model state. */
108
+ flush() {
109
+ this.categories.clear();
110
+ }
111
+ /** Summarizes trained categories in stable lexical order. */
112
+ categorySummaries() {
113
+ return [...this.categories.entries()]
114
+ .sort(([a], [b]) => a.localeCompare(b))
115
+ .map(([category, state]) => ({
116
+ category,
117
+ tokenTally: state.tally
118
+ }));
119
+ }
120
+ /** Serializes classifier state into the versioned persistence model. */
121
+ save() {
122
+ const categories = {};
123
+ for (const [name, state] of this.categories) {
124
+ categories[name] = {
125
+ tally: state.tally,
126
+ tokens: Object.fromEntries(state.tokens)
127
+ };
128
+ }
129
+ return {
130
+ version: MODEL_VERSION,
131
+ categories
132
+ };
133
+ }
134
+ /** Loads and validates a persisted model state into memory. */
135
+ load(model) {
136
+ validateModelState(model);
137
+ this.categories.clear();
138
+ for (const [name, persisted] of Object.entries(model.categories)) {
139
+ const tokens = new Map();
140
+ let tally = 0;
141
+ for (const [token, count] of Object.entries(persisted.tokens)) {
142
+ tokens.set(token, count);
143
+ tally += count;
144
+ }
145
+ this.categories.set(name, { tally, tokens });
146
+ }
147
+ }
148
+ ensureCategory(category) {
149
+ const existing = this.categories.get(category);
150
+ if (existing) {
151
+ return existing;
152
+ }
153
+ const created = { tally: 0, tokens: new Map() };
154
+ this.categories.set(category, created);
155
+ return created;
156
+ }
157
+ validateCategory(category) {
158
+ if (!CATEGORY_PATTERN.test(category)) {
159
+ throw new ValidationError("invalid category");
160
+ }
161
+ }
162
+ totalTally() {
163
+ let total = 0;
164
+ for (const state of this.categories.values()) {
165
+ total += state.tally;
166
+ }
167
+ return total;
168
+ }
169
+ tokenTotal(token) {
170
+ let total = 0;
171
+ for (const state of this.categories.values()) {
172
+ total += state.tokens.get(token) ?? 0;
173
+ }
174
+ return total;
175
+ }
176
+ calculateBayesianProbability(input) {
177
+ const { tokenInCategory, tokenTotal, categoryTally, totalTally } = input;
178
+ if (categoryTally <= 0 || totalTally <= 0) {
179
+ return 0;
180
+ }
181
+ const categoryPrior = categoryTally / totalTally;
182
+ const nonCategoryPrior = 1 - categoryPrior;
183
+ const inCategoryProb = tokenInCategory / categoryTally;
184
+ const remainingTally = totalTally - categoryTally;
185
+ const nonCategoryTokenCount = tokenTotal - tokenInCategory;
186
+ // If no non-category evidence exists, treat non-category probability as zero.
187
+ const notInCategoryProb = remainingTally > 0 && nonCategoryTokenCount > 0 ? nonCategoryTokenCount / remainingTally : 0;
188
+ const numerator = inCategoryProb * categoryPrior;
189
+ const denominator = numerator + notInCategoryProb * nonCategoryPrior;
190
+ return numerator / denominator;
191
+ }
192
+ }
193
+ /** Validates persisted model invariants before load. */
194
+ export function validateModelState(model) {
195
+ if (model.version !== MODEL_VERSION) {
196
+ throw new PersistenceError("unsupported model version");
197
+ }
198
+ for (const [name, category] of Object.entries(model.categories)) {
199
+ if (!CATEGORY_PATTERN.test(name)) {
200
+ throw new PersistenceError("invalid category in model");
201
+ }
202
+ if (!Number.isInteger(category.tally) || category.tally < 0) {
203
+ throw new PersistenceError("invalid tally in model");
204
+ }
205
+ let sum = 0;
206
+ for (const [token, count] of Object.entries(category.tokens)) {
207
+ if (token.length === 0) {
208
+ throw new PersistenceError("invalid token in model");
209
+ }
210
+ if (!Number.isInteger(count) || count <= 0) {
211
+ throw new PersistenceError("invalid token count in model");
212
+ }
213
+ sum += count;
214
+ }
215
+ if (sum !== category.tally) {
216
+ throw new PersistenceError("inconsistent tally in model");
217
+ }
218
+ }
219
+ }
220
+ function countOccurrences(tokens) {
221
+ const occurrences = new Map();
222
+ for (const token of tokens) {
223
+ occurrences.set(token, (occurrences.get(token) ?? 0) + 1);
224
+ }
225
+ return occurrences;
226
+ }
227
+ //# sourceMappingURL=classifier.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classifier.js","sourceRoot":"","sources":["../../src/core/classifier.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export declare const CATEGORY_PATTERN: RegExp;
2
+ export declare const DEFAULT_MODEL_FILE_PATH = "/tmp/tsbayes-model.json";
3
+ export declare const MODEL_VERSION = 1;
@@ -0,0 +1,4 @@
1
+ export const CATEGORY_PATTERN = /^[-_A-Za-z0-9]{1,64}$/;
2
+ export const DEFAULT_MODEL_FILE_PATH = "/tmp/tsbayes-model.json";
3
+ export const MODEL_VERSION = 1;
4
+ //# sourceMappingURL=constants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/core/constants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,gBAAgB,GAAG,uBAAuB,CAAC;AACxD,MAAM,CAAC,MAAM,uBAAuB,GAAG,yBAAyB,CAAC;AACjE,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ export declare class ValidationError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare class PersistenceError extends Error {
5
+ constructor(message: string);
6
+ }
@@ -0,0 +1,13 @@
1
+ export class ValidationError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "ValidationError";
5
+ }
6
+ }
7
+ export class PersistenceError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "PersistenceError";
11
+ }
12
+ }
13
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/core/errors.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,eAAgB,SAAQ,KAAK;IACxC,YAAmB,OAAe;QAChC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;IAChC,CAAC;CACF;AAED,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzC,YAAmB,OAAe;QAChC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;IACjC,CAAC;CACF"}
@@ -0,0 +1,7 @@
1
+ import type { TextClassifier } from "./classifier.js";
2
+ /** Saves model state to disk via temp-write + rename for atomic replacement. */
3
+ export declare function saveToFile(classifier: TextClassifier, filePath?: string): Promise<void>;
4
+ /** Loads model state from disk and validates structure before classifier-level validation. */
5
+ export declare function loadFromFile(classifier: TextClassifier, filePath?: string): Promise<void>;
6
+ /** Helper for tests and tooling that need an ephemeral persisted model file. */
7
+ export declare function saveToTempFile(classifier: TextClassifier): Promise<string>;
@@ -0,0 +1,92 @@
1
+ import { mkdtemp, open, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { DEFAULT_MODEL_FILE_PATH } from "./constants.js";
5
+ import { PersistenceError } from "./errors.js";
6
+ /** Saves model state to disk via temp-write + rename for atomic replacement. */
7
+ export async function saveToFile(classifier, filePath = DEFAULT_MODEL_FILE_PATH) {
8
+ if (!isAbsolute(filePath)) {
9
+ throw new PersistenceError("model path must be absolute");
10
+ }
11
+ const model = classifier.save();
12
+ const dir = dirname(filePath);
13
+ const tmpDir = await mkdtemp(join(dir, ".tsbayes-"));
14
+ const tmpFile = join(tmpDir, "model.json.tmp");
15
+ const payload = `${JSON.stringify(model)}\n`;
16
+ try {
17
+ await writeFile(tmpFile, payload, "utf8");
18
+ // fsync before rename to reduce the chance of partially persisted writes.
19
+ const handle = await open(tmpFile, "r");
20
+ try {
21
+ await handle.sync();
22
+ }
23
+ finally {
24
+ await handle.close();
25
+ }
26
+ await rename(tmpFile, filePath);
27
+ }
28
+ finally {
29
+ await rm(tmpDir, { recursive: true, force: true });
30
+ }
31
+ }
32
+ /** Loads model state from disk and validates structure before classifier-level validation. */
33
+ export async function loadFromFile(classifier, filePath = DEFAULT_MODEL_FILE_PATH) {
34
+ if (!isAbsolute(filePath)) {
35
+ throw new PersistenceError("model path must be absolute");
36
+ }
37
+ const content = await readFile(filePath, "utf8");
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(content);
41
+ }
42
+ catch {
43
+ throw new PersistenceError("model file is not valid JSON");
44
+ }
45
+ if (!isPersistedModelStateShape(parsed)) {
46
+ throw new PersistenceError("model file has invalid structure");
47
+ }
48
+ classifier.load(parsed);
49
+ }
50
+ /** Helper for tests and tooling that need an ephemeral persisted model file. */
51
+ export async function saveToTempFile(classifier) {
52
+ const path = join(tmpdir(), `tsbayes-${Date.now()}.json`);
53
+ await saveToFile(classifier, path);
54
+ return path;
55
+ }
56
+ /** Fast runtime shape check so invalid JSON shapes fail with clear errors. */
57
+ function isPersistedModelStateShape(value) {
58
+ if (!isRecord(value)) {
59
+ return false;
60
+ }
61
+ if (!Number.isInteger(value.version)) {
62
+ return false;
63
+ }
64
+ if (!isRecord(value.categories)) {
65
+ return false;
66
+ }
67
+ for (const [categoryName, categoryValue] of Object.entries(value.categories)) {
68
+ if (categoryName.length === 0 || !isRecord(categoryValue)) {
69
+ return false;
70
+ }
71
+ const tally = categoryValue.tally;
72
+ if (typeof tally !== "number" || !Number.isInteger(tally) || tally < 0) {
73
+ return false;
74
+ }
75
+ if (!isRecord(categoryValue.tokens)) {
76
+ return false;
77
+ }
78
+ for (const [token, count] of Object.entries(categoryValue.tokens)) {
79
+ if (token.length === 0 ||
80
+ typeof count !== "number" ||
81
+ !Number.isInteger(count) ||
82
+ count <= 0) {
83
+ return false;
84
+ }
85
+ }
86
+ }
87
+ return true;
88
+ }
89
+ function isRecord(value) {
90
+ return typeof value === "object" && value !== null && !Array.isArray(value);
91
+ }
92
+ //# sourceMappingURL=persistence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.js","sourceRoot":"","sources":["../../src/core/persistence.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Default tokenizer used by classifier training/scoring.
3
+ * Pipeline: NFKC normalize -> lowercase -> split -> trim/filter -> stem.
4
+ */
5
+ export declare function tokenize(text: string): string[];
@@ -0,0 +1,17 @@
1
+ import { newStemmer } from "snowball-stemmers";
2
+ const NON_WORD_SPLIT = /[^\p{L}\p{N}]+/u;
3
+ const stemmer = newStemmer("english");
4
+ /**
5
+ * Default tokenizer used by classifier training/scoring.
6
+ * Pipeline: NFKC normalize -> lowercase -> split -> trim/filter -> stem.
7
+ */
8
+ export function tokenize(text) {
9
+ return text
10
+ .normalize("NFKC")
11
+ .toLowerCase()
12
+ .split(NON_WORD_SPLIT)
13
+ .map((token) => token.trim())
14
+ .filter((token) => token.length > 0)
15
+ .map((token) => stemmer.stem(token));
16
+ }
17
+ //# sourceMappingURL=tokenizer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokenizer.js","sourceRoot":"","sources":["../../src/core/tokenizer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAE/C,MAAM,cAAc,GAAG,iBAAiB,CAAC;AACzC,MAAM,OAAO,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;AAEtC;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAY;IACnC,OAAO,IAAI;SACR,SAAS,CAAC,MAAM,CAAC;SACjB,WAAW,EAAE;SACb,KAAK,CAAC,cAAc,CAAC;SACrB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;SAC5B,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;SACnC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;AACzC,CAAC"}
@@ -0,0 +1,16 @@
1
+ export interface CategorySummary {
2
+ category: string;
3
+ tokenTally: number;
4
+ }
5
+ export interface ClassificationResult {
6
+ category: string | null;
7
+ score: number;
8
+ }
9
+ export interface PersistedCategoryState {
10
+ tally: number;
11
+ tokens: Record<string, number>;
12
+ }
13
+ export interface PersistedModelState {
14
+ version: number;
15
+ categories: Record<string, PersistedCategoryState>;
16
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/core/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,6 @@
1
+ export { TextClassifier, validateModelState } from "./core/classifier.js";
2
+ export { tokenize } from "./core/tokenizer.js";
3
+ export { saveToFile, loadFromFile } from "./core/persistence.js";
4
+ export { createApp } from "./server/app.js";
5
+ export type { AppContext, AppOptions } from "./server/app.js";
6
+ export type { ClassificationResult, CategorySummary, PersistedModelState } from "./core/types.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { TextClassifier, validateModelState } from "./core/classifier.js";
2
+ export { tokenize } from "./core/tokenizer.js";
3
+ export { saveToFile, loadFromFile } from "./core/persistence.js";
4
+ export { createApp } from "./server/app.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1E,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,15 @@
1
+ import { type FastifyInstance } from "fastify";
2
+ import { TextClassifier } from "../core/classifier.js";
3
+ import { Readiness } from "./readiness.js";
4
+ export interface AppOptions {
5
+ authToken: string | null;
6
+ classifier?: TextClassifier;
7
+ readiness?: Readiness;
8
+ }
9
+ export interface AppContext {
10
+ app: FastifyInstance;
11
+ classifier: TextClassifier;
12
+ readiness: Readiness;
13
+ }
14
+ /** Builds the Fastify app and wires classifier, auth, validation, and health endpoints. */
15
+ export declare function createApp(options: AppOptions): AppContext;
@@ -0,0 +1,107 @@
1
+ import Fastify from "fastify";
2
+ import { TextClassifier } from "../core/classifier.js";
3
+ import { CATEGORY_PATTERN } from "../core/constants.js";
4
+ import { ValidationError } from "../core/errors.js";
5
+ import { Readiness } from "./readiness.js";
6
+ import { checkAuthorization } from "./auth.js";
7
+ const MAX_BODY_SIZE_BYTES = 1024 * 1024;
8
+ const CATEGORY_PARAMS_SCHEMA = {
9
+ type: "object",
10
+ additionalProperties: false,
11
+ required: ["category"],
12
+ properties: {
13
+ category: {
14
+ type: "string",
15
+ minLength: 1,
16
+ maxLength: 64,
17
+ pattern: CATEGORY_PATTERN.source
18
+ }
19
+ }
20
+ };
21
+ /** Builds the Fastify app and wires classifier, auth, validation, and health endpoints. */
22
+ export function createApp(options) {
23
+ const classifier = options.classifier ?? new TextClassifier();
24
+ const readiness = options.readiness ?? new Readiness();
25
+ const app = Fastify({
26
+ logger: false,
27
+ bodyLimit: MAX_BODY_SIZE_BYTES
28
+ });
29
+ // Parse all payloads as UTF-8 text; endpoint handlers decide how to use content.
30
+ app.addContentTypeParser("*", { parseAs: "buffer" }, (request, body, done) => {
31
+ done(null, body.toString("utf8"));
32
+ });
33
+ app.addHook("onRequest", async (request, reply) => {
34
+ // Charset checks happen early so invalid content types fail consistently.
35
+ const charset = parseCharset(request.headers["content-type"]);
36
+ if (charset && charset.toLowerCase() !== "utf-8") {
37
+ throw new ValidationError("content must be utf-8");
38
+ }
39
+ if (!checkAuthorization(options.authToken, request, reply)) {
40
+ return reply;
41
+ }
42
+ return undefined;
43
+ });
44
+ app.get("/healthz", () => ({ status: "ok" }));
45
+ app.get("/readyz", (request, reply) => {
46
+ if (!readiness.isReady()) {
47
+ return reply.code(503).send({ error: "not ready" });
48
+ }
49
+ return { status: "ready" };
50
+ });
51
+ app.get("/info", () => ({ categories: classifier.categorySummaries() }));
52
+ app.post("/train/:category", { schema: { params: CATEGORY_PARAMS_SCHEMA } }, async (request, reply) => {
53
+ const { category } = request.params;
54
+ classifier.train(category, bodyAsText(request.body));
55
+ return reply.code(204).send();
56
+ });
57
+ app.post("/untrain/:category", { schema: { params: CATEGORY_PARAMS_SCHEMA } }, async (request, reply) => {
58
+ const { category } = request.params;
59
+ classifier.untrain(category, bodyAsText(request.body));
60
+ return reply.code(204).send();
61
+ });
62
+ app.post("/classify", (request) => classifier.classificationResult(bodyAsText(request.body)));
63
+ app.post("/score", (request) => classifier.score(bodyAsText(request.body)));
64
+ app.post("/flush", (request, reply) => {
65
+ classifier.flush();
66
+ return reply.code(204).send();
67
+ });
68
+ app.setErrorHandler((error, request, reply) => {
69
+ if (error instanceof ValidationError) {
70
+ return reply.code(400).send({ error: error.message });
71
+ }
72
+ if (error.code === "FST_ERR_VALIDATION") {
73
+ // Route-schema validation failures map to one stable API error.
74
+ return reply.code(400).send({ error: "invalid request" });
75
+ }
76
+ if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
77
+ return reply.code(413).send({ error: "payload too large" });
78
+ }
79
+ request.log.error(error);
80
+ return reply.code(500).send({ error: "internal server error" });
81
+ });
82
+ app.setNotFoundHandler((request, reply) => reply.code(404).send({ error: "not found" }));
83
+ return { app, classifier, readiness };
84
+ }
85
+ function bodyAsText(body) {
86
+ if (body == null) {
87
+ return "";
88
+ }
89
+ if (typeof body === "string") {
90
+ return body;
91
+ }
92
+ throw new ValidationError("body must be text");
93
+ }
94
+ /** Extracts optional charset from Content-Type (for example "text/plain; charset=utf-8"). */
95
+ function parseCharset(contentType) {
96
+ if (!contentType) {
97
+ return null;
98
+ }
99
+ const parts = contentType.split(";").map((part) => part.trim());
100
+ for (const part of parts) {
101
+ if (part.toLowerCase().startsWith("charset=")) {
102
+ return part.slice("charset=".length).trim();
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../../src/server/app.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import type { FastifyReply, FastifyRequest } from "fastify";
2
+ /** Probe routes intentionally bypass auth checks. */
3
+ export declare function isProbePath(path: string): boolean;
4
+ /** Validates bearer auth for protected routes and writes 401 responses when needed. */
5
+ export declare function checkAuthorization(token: string | null, request: FastifyRequest, reply: FastifyReply): boolean;
@@ -0,0 +1,49 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ /** Probe routes intentionally bypass auth checks. */
3
+ export function isProbePath(path) {
4
+ return path === "/healthz" || path === "/readyz";
5
+ }
6
+ /** Validates bearer auth for protected routes and writes 401 responses when needed. */
7
+ export function checkAuthorization(token, request, reply) {
8
+ if (!token || isProbePath(request.url)) {
9
+ return true;
10
+ }
11
+ const header = request.headers.authorization;
12
+ if (!header) {
13
+ reply.header("WWW-Authenticate", "Bearer");
14
+ reply.code(401).send({ error: "unauthorized" });
15
+ return false;
16
+ }
17
+ const match = /^Bearer ([^\s]+)$/i.exec(header.trim());
18
+ if (!match) {
19
+ reply.header("WWW-Authenticate", "Bearer");
20
+ reply.code(401).send({ error: "unauthorized" });
21
+ return false;
22
+ }
23
+ const value = match[1];
24
+ if (!safeEqual(value, token)) {
25
+ reply.header("WWW-Authenticate", "Bearer");
26
+ reply.code(401).send({ error: "unauthorized" });
27
+ return false;
28
+ }
29
+ return true;
30
+ }
31
+ /**
32
+ * Constant-time comparison for token equality.
33
+ * For mismatched lengths we still run timingSafeEqual on padded buffers.
34
+ */
35
+ function safeEqual(left, right) {
36
+ const l = Buffer.from(left, "utf8");
37
+ const r = Buffer.from(right, "utf8");
38
+ if (l.length !== r.length) {
39
+ const max = Math.max(l.length, r.length);
40
+ const lp = Buffer.alloc(max);
41
+ const rp = Buffer.alloc(max);
42
+ l.copy(lp);
43
+ r.copy(rp);
44
+ timingSafeEqual(lp, rp);
45
+ return false;
46
+ }
47
+ return timingSafeEqual(l, r);
48
+ }
49
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG9C,qDAAqD;AACrD,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,IAAI,KAAK,UAAU,IAAI,IAAI,KAAK,SAAS,CAAC;AACnD,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,kBAAkB,CAChC,KAAoB,EACpB,OAAuB,EACvB,KAAmB;IAEnB,IAAI,CAAC,KAAK,IAAI,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QACvC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC;IAC7C,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,KAAK,CAAC,MAAM,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;QAC3C,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;QAChD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,KAAK,GAAG,oBAAoB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACvD,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,KAAK,CAAC,MAAM,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;QAC3C,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;QAChD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;IAExB,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC;QAC7B,KAAK,CAAC,MAAM,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;QAC3C,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;QAChD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,SAAS,SAAS,CAAC,IAAY,EAAE,KAAa;IAC5C,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACpC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACrC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;QACzC,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7B,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7B,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACX,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACX,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QACxB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,OAAO,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/B,CAAC"}
@@ -0,0 +1,6 @@
1
+ export interface ServerConfig {
2
+ host: string;
3
+ port: number;
4
+ authToken: string | null;
5
+ }
6
+ export declare function loadConfig(env: NodeJS.ProcessEnv): ServerConfig;
@@ -0,0 +1,19 @@
1
+ import { ValidationError } from "../core/errors.js";
2
+ export function loadConfig(env) {
3
+ const host = env.TSBAYES_HOST ?? "0.0.0.0";
4
+ const port = parsePort(env.TSBAYES_PORT ?? "8000");
5
+ const authToken = (env.TSBAYES_AUTH_TOKEN ?? "").trim();
6
+ return {
7
+ host,
8
+ port,
9
+ authToken: authToken.length > 0 ? authToken : null
10
+ };
11
+ }
12
+ function parsePort(raw) {
13
+ const value = Number(raw);
14
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
15
+ throw new ValidationError("invalid port");
16
+ }
17
+ return value;
18
+ }
19
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/server/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAQpD,MAAM,UAAU,UAAU,CAAC,GAAsB;IAC/C,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,IAAI,SAAS,CAAC;IAC3C,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,YAAY,IAAI,MAAM,CAAC,CAAC;IACnD,MAAM,SAAS,GAAG,CAAC,GAAG,CAAC,kBAAkB,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAExD,OAAO;QACL,IAAI;QACJ,IAAI;QACJ,SAAS,EAAE,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI;KACnD,CAAC;AACJ,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC5B,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,KAAK,EAAE,CAAC;QAC3D,MAAM,IAAI,eAAe,CAAC,cAAc,CAAC,CAAC;IAC5C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -0,0 +1,5 @@
1
+ export declare class Readiness {
2
+ private ready;
3
+ isReady(): boolean;
4
+ setReady(value: boolean): void;
5
+ }
@@ -0,0 +1,10 @@
1
+ export class Readiness {
2
+ ready = true;
3
+ isReady() {
4
+ return this.ready;
5
+ }
6
+ setReady(value) {
7
+ this.ready = value;
8
+ }
9
+ }
10
+ //# sourceMappingURL=readiness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readiness.js","sourceRoot":"","sources":["../../src/server/readiness.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,SAAS;IACZ,KAAK,GAAG,IAAI,CAAC;IAEd,OAAO;QACZ,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAEM,QAAQ,CAAC,KAAc;QAC5B,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;CACF"}
@@ -0,0 +1,3 @@
1
+ import { type AppContext } from "./app.js";
2
+ import type { ServerConfig } from "./config.js";
3
+ export declare function startServer(config: ServerConfig): Promise<AppContext>;
@@ -0,0 +1,10 @@
1
+ import { createApp } from "./app.js";
2
+ export async function startServer(config) {
3
+ const context = createApp({ authToken: config.authToken });
4
+ await context.app.listen({
5
+ host: config.host,
6
+ port: config.port
7
+ });
8
+ return context;
9
+ }
10
+ //# sourceMappingURL=start.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"start.js","sourceRoot":"","sources":["../../src/server/start.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAmB,MAAM,UAAU,CAAC;AAGtD,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAoB;IACpD,MAAM,OAAO,GAAG,SAAS,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC;IAC3D,MAAM,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC;QACvB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,IAAI,EAAE,MAAM,CAAC,IAAI;KAClB,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@hickeroar/tsbayes",
3
+ "version": "0.1.0",
4
+ "description": "A trainable Bayesian text classifier library and HTTP service.",
5
+ "type": "module",
6
+ "author": "Ryan Vennell <ryan.vennell@gmail.com>",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/hickeroar/tsbayes.git"
11
+ },
12
+ "homepage": "https://github.com/hickeroar/tsbayes#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/hickeroar/tsbayes/issues"
15
+ },
16
+ "keywords": [
17
+ "bayes",
18
+ "naive-bayes",
19
+ "text-classification",
20
+ "fastify",
21
+ "typescript"
22
+ ],
23
+ "bin": {
24
+ "tsbayes": "dist/cli.js"
25
+ },
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "CHANGELOG.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20.0.0"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "clean": "rm -rf dist coverage",
45
+ "start": "node dist/cli.js",
46
+ "dev": "tsx watch src/cli.ts",
47
+ "typecheck": "tsc --noEmit",
48
+ "lint": "eslint .",
49
+ "format": "prettier --write .",
50
+ "format:check": "prettier --check .",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest",
53
+ "test:coverage": "vitest run --coverage",
54
+ "precommit": "npm run format:check && npm run lint && npm run typecheck && npm run test",
55
+ "prepublishOnly": "npm run clean && npm run lint && npm run typecheck && npm run test:coverage && npm run build",
56
+ "standalone:audit": "node scripts/standalone-audit.mjs"
57
+ },
58
+ "dependencies": {
59
+ "fastify": "5.7.4",
60
+ "snowball-stemmers": "0.6.0"
61
+ },
62
+ "devDependencies": {
63
+ "@eslint/js": "10.0.1",
64
+ "@types/node": "25.3.0",
65
+ "@vitest/coverage-v8": "4.0.18",
66
+ "eslint": "10.0.1",
67
+ "eslint-config-prettier": "10.1.8",
68
+ "prettier": "3.8.1",
69
+ "tsx": "4.21.0",
70
+ "typescript": "5.9.3",
71
+ "typescript-eslint": "8.56.0",
72
+ "vitest": "4.0.18"
73
+ },
74
+ "overrides": {
75
+ "minimatch": "^10.2.1"
76
+ }
77
+ }