@criterionx/server 0.3.0 → 0.3.2

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 (3) hide show
  1. package/dist/index.d.ts +252 -26
  2. package/dist/index.js +606 -26
  3. package/package.json +4 -4
package/dist/index.d.ts CHANGED
@@ -1,7 +1,100 @@
1
- import { Decision } from '@criterionx/core';
2
- import { ZodSchema } from 'zod';
1
+ import { Decision, Result, JsonSchema } from '@criterionx/core';
2
+ export { DecisionSchema, JsonSchema, extractDecisionSchema, toJsonSchema } from '@criterionx/core';
3
3
  import { Hono } from 'hono';
4
4
 
5
+ /**
6
+ * Context passed to hooks
7
+ */
8
+ interface HookContext {
9
+ /** ID of the decision being evaluated */
10
+ decisionId: string;
11
+ /** Input data for the decision */
12
+ input: unknown;
13
+ /** Profile being used */
14
+ profile: unknown;
15
+ /** Request ID for tracing (auto-generated) */
16
+ requestId: string;
17
+ /** Timestamp when evaluation started */
18
+ timestamp: Date;
19
+ }
20
+ /**
21
+ * Hook called before decision evaluation
22
+ *
23
+ * Can modify context (input, profile) by returning a modified context.
24
+ * Return undefined to keep original context.
25
+ * Throw to abort evaluation with error.
26
+ */
27
+ type BeforeEvaluateHook = (ctx: HookContext) => Promise<Partial<HookContext> | void> | Partial<HookContext> | void;
28
+ /**
29
+ * Hook called after decision evaluation
30
+ *
31
+ * Receives the evaluation result. Cannot modify the result.
32
+ * Use for logging, metrics, side effects.
33
+ */
34
+ type AfterEvaluateHook = (ctx: HookContext, result: Result<unknown>) => Promise<void> | void;
35
+ /**
36
+ * Hook called when an error occurs during evaluation
37
+ */
38
+ type OnErrorHook = (ctx: HookContext, error: Error) => Promise<void> | void;
39
+ /**
40
+ * Middleware hooks configuration
41
+ */
42
+ interface Hooks {
43
+ /** Called before decision evaluation */
44
+ beforeEvaluate?: BeforeEvaluateHook;
45
+ /** Called after successful evaluation */
46
+ afterEvaluate?: AfterEvaluateHook;
47
+ /** Called when an error occurs */
48
+ onError?: OnErrorHook;
49
+ }
50
+ /**
51
+ * Metrics configuration options
52
+ */
53
+ interface MetricsOptions$1 {
54
+ /** Enable metrics collection (default: false) */
55
+ enabled?: boolean;
56
+ /** Endpoint path for metrics (default: /metrics) */
57
+ endpoint?: string;
58
+ /** Histogram buckets for latency in seconds */
59
+ buckets?: number[];
60
+ }
61
+ /**
62
+ * OpenAPI info object
63
+ */
64
+ interface OpenAPIInfo {
65
+ /** API title */
66
+ title: string;
67
+ /** API version */
68
+ version: string;
69
+ /** API description */
70
+ description?: string;
71
+ /** Contact information */
72
+ contact?: {
73
+ name?: string;
74
+ url?: string;
75
+ email?: string;
76
+ };
77
+ /** License information */
78
+ license?: {
79
+ name: string;
80
+ url?: string;
81
+ };
82
+ }
83
+ /**
84
+ * OpenAPI configuration options
85
+ */
86
+ interface OpenAPIOptions {
87
+ /** Enable OpenAPI spec generation (default: false) */
88
+ enabled?: boolean;
89
+ /** Endpoint path for OpenAPI spec (default: /openapi.json) */
90
+ endpoint?: string;
91
+ /** API info for OpenAPI spec */
92
+ info?: Partial<OpenAPIInfo>;
93
+ /** Enable Swagger UI (default: true when openapi is enabled) */
94
+ swaggerUI?: boolean;
95
+ /** Swagger UI endpoint (default: /swagger) */
96
+ swaggerEndpoint?: string;
97
+ }
5
98
  /**
6
99
  * Server configuration options
7
100
  */
@@ -12,6 +105,12 @@ interface ServerOptions {
12
105
  profiles?: Record<string, unknown>;
13
106
  /** Enable CORS (default: true) */
14
107
  cors?: boolean;
108
+ /** Middleware hooks for evaluation lifecycle */
109
+ hooks?: Hooks;
110
+ /** Prometheus metrics configuration */
111
+ metrics?: MetricsOptions$1;
112
+ /** OpenAPI spec generation configuration */
113
+ openapi?: OpenAPIOptions;
15
114
  }
16
115
  /**
17
116
  * Request body for decision evaluation
@@ -32,37 +131,39 @@ interface DecisionInfo {
32
131
  meta?: Record<string, unknown>;
33
132
  }
34
133
  /**
35
- * JSON Schema representation
134
+ * Error codes for structured error responses
36
135
  */
37
- interface JsonSchema {
38
- $schema?: string;
39
- type?: string;
40
- properties?: Record<string, JsonSchema>;
41
- required?: string[];
42
- additionalProperties?: boolean;
43
- items?: JsonSchema;
44
- enum?: unknown[];
45
- [key: string]: unknown;
46
- }
136
+ type ErrorCode = "DECISION_NOT_FOUND" | "INVALID_JSON" | "MISSING_INPUT" | "MISSING_PROFILE" | "VALIDATION_ERROR" | "EVALUATION_ERROR" | "INTERNAL_ERROR";
47
137
  /**
48
- * Decision schema export
138
+ * Structured error response
49
139
  */
50
- interface DecisionSchema {
51
- id: string;
52
- version: string;
53
- inputSchema: JsonSchema;
54
- outputSchema: JsonSchema;
55
- profileSchema: JsonSchema;
140
+ interface ErrorResponse {
141
+ error: {
142
+ code: ErrorCode;
143
+ message: string;
144
+ details?: Record<string, unknown>;
145
+ };
146
+ requestId?: string;
147
+ timestamp: string;
56
148
  }
57
-
58
149
  /**
59
- * Convert a Zod schema to JSON Schema
150
+ * Health check status
60
151
  */
61
- declare function toJsonSchema(schema: ZodSchema): JsonSchema;
152
+ type HealthStatus = "healthy" | "degraded" | "unhealthy";
62
153
  /**
63
- * Extract JSON Schemas from a decision
154
+ * Health check response
64
155
  */
65
- declare function extractDecisionSchema(decision: Decision<unknown, unknown, unknown>): DecisionSchema;
156
+ interface HealthResponse {
157
+ status: HealthStatus;
158
+ version: string;
159
+ uptime: number;
160
+ timestamp: string;
161
+ checks?: Record<string, {
162
+ status: HealthStatus;
163
+ message?: string;
164
+ }>;
165
+ }
166
+
66
167
  /**
67
168
  * Generate OpenAPI-compatible schema for a decision endpoint
68
169
  */
@@ -71,6 +172,121 @@ declare function generateEndpointSchema(decision: Decision<unknown, unknown, unk
71
172
  response: JsonSchema;
72
173
  };
73
174
 
175
+ /**
176
+ * Lightweight metrics collector for Criterion Server
177
+ *
178
+ * Exposes metrics in Prometheus format without external dependencies.
179
+ */
180
+ interface MetricsOptions {
181
+ /** Enable metrics collection (default: false) */
182
+ enabled?: boolean;
183
+ /** Endpoint path for metrics (default: /metrics) */
184
+ endpoint?: string;
185
+ /** Histogram buckets for latency in seconds */
186
+ buckets?: number[];
187
+ }
188
+ interface MetricLabels {
189
+ [key: string]: string;
190
+ }
191
+ /**
192
+ * Simple metrics collector that outputs Prometheus format
193
+ */
194
+ declare class MetricsCollector {
195
+ private counters;
196
+ private histograms;
197
+ private buckets;
198
+ constructor(options?: MetricsOptions);
199
+ /**
200
+ * Increment a counter metric
201
+ */
202
+ increment(name: string, labels?: MetricLabels, value?: number): void;
203
+ /**
204
+ * Record a value in a histogram metric
205
+ */
206
+ observe(name: string, labels: MetricLabels, value: number): void;
207
+ /**
208
+ * Get a counter value
209
+ */
210
+ getCounter(name: string, labels?: MetricLabels): number;
211
+ /**
212
+ * Get histogram stats
213
+ */
214
+ getHistogram(name: string, labels: MetricLabels): {
215
+ sum: number;
216
+ count: number;
217
+ } | undefined;
218
+ /**
219
+ * Export all metrics in Prometheus format
220
+ */
221
+ toPrometheus(): string;
222
+ /**
223
+ * Reset all metrics
224
+ */
225
+ reset(): void;
226
+ private findCounter;
227
+ private findHistogram;
228
+ private labelsMatch;
229
+ private formatLabels;
230
+ }
231
+ declare const METRIC_EVALUATIONS_TOTAL = "criterion_evaluations_total";
232
+ declare const METRIC_EVALUATION_DURATION_SECONDS = "criterion_evaluation_duration_seconds";
233
+ declare const METRIC_RULE_MATCHES_TOTAL = "criterion_rule_matches_total";
234
+
235
+ /**
236
+ * OpenAPI 3.0 spec generator for Criterion decisions
237
+ */
238
+
239
+ /**
240
+ * OpenAPI 3.0 specification
241
+ */
242
+ interface OpenAPISpec {
243
+ openapi: "3.0.0";
244
+ info: OpenAPIInfo;
245
+ paths: Record<string, PathItem>;
246
+ components: {
247
+ schemas: Record<string, JsonSchema>;
248
+ };
249
+ }
250
+ interface PathItem {
251
+ post?: Operation;
252
+ get?: Operation;
253
+ }
254
+ interface Operation {
255
+ operationId: string;
256
+ summary: string;
257
+ description?: string;
258
+ tags?: string[];
259
+ requestBody?: {
260
+ required: boolean;
261
+ content: {
262
+ "application/json": {
263
+ schema: {
264
+ $ref: string;
265
+ } | JsonSchema;
266
+ };
267
+ };
268
+ };
269
+ responses: Record<string, Response>;
270
+ }
271
+ interface Response {
272
+ description: string;
273
+ content?: {
274
+ "application/json": {
275
+ schema: {
276
+ $ref: string;
277
+ } | JsonSchema;
278
+ };
279
+ };
280
+ }
281
+ /**
282
+ * Generate OpenAPI spec from decisions
283
+ */
284
+ declare function generateOpenAPISpec(decisions: Decision<any, any, any>[], info?: Partial<OpenAPIInfo>): OpenAPISpec;
285
+ /**
286
+ * Generate Swagger UI HTML
287
+ */
288
+ declare function generateSwaggerUIHtml(specUrl: string): string;
289
+
74
290
  /**
75
291
  * Criterion Server
76
292
  *
@@ -81,6 +297,12 @@ declare class CriterionServer {
81
297
  private engine;
82
298
  private decisions;
83
299
  private profiles;
300
+ private hooks;
301
+ private metricsCollector;
302
+ private metricsOptions;
303
+ private openApiOptions;
304
+ private openApiSpec;
305
+ private startTime;
84
306
  constructor(options: ServerOptions);
85
307
  private setupRoutes;
86
308
  private generateDocsHtml;
@@ -88,6 +310,10 @@ declare class CriterionServer {
88
310
  * Get the Hono app instance (for custom middleware)
89
311
  */
90
312
  get handler(): Hono;
313
+ /**
314
+ * Get the metrics collector (if enabled)
315
+ */
316
+ get metrics(): MetricsCollector | null;
91
317
  /**
92
318
  * Start the server
93
319
  */
@@ -98,4 +324,4 @@ declare class CriterionServer {
98
324
  */
99
325
  declare function createServer(options: ServerOptions): CriterionServer;
100
326
 
101
- export { CriterionServer, type DecisionInfo, type DecisionSchema, type EvaluateRequest, type JsonSchema, type ServerOptions, createServer, extractDecisionSchema, generateEndpointSchema, toJsonSchema };
327
+ export { type AfterEvaluateHook, type BeforeEvaluateHook, CriterionServer, type DecisionInfo, type ErrorCode, type ErrorResponse, type EvaluateRequest, type HealthResponse, type HealthStatus, type HookContext, type Hooks, METRIC_EVALUATIONS_TOTAL, METRIC_EVALUATION_DURATION_SECONDS, METRIC_RULE_MATCHES_TOTAL, MetricsCollector, type MetricsOptions$1 as MetricsOptions, type OnErrorHook, type OpenAPIInfo, type OpenAPIOptions, type OpenAPISpec, type ServerOptions, createServer, generateEndpointSchema, generateOpenAPISpec, generateSwaggerUIHtml };
package/dist/index.js CHANGED
@@ -1,17 +1,8 @@
1
1
  // src/schema.ts
2
- import { zodToJsonSchema } from "zod-to-json-schema";
3
- function toJsonSchema(schema) {
4
- return zodToJsonSchema(schema, { $refStrategy: "none" });
5
- }
6
- function extractDecisionSchema(decision) {
7
- return {
8
- id: decision.id,
9
- version: decision.version,
10
- inputSchema: toJsonSchema(decision.inputSchema),
11
- outputSchema: toJsonSchema(decision.outputSchema),
12
- profileSchema: toJsonSchema(decision.profileSchema)
13
- };
14
- }
2
+ import {
3
+ toJsonSchema,
4
+ extractDecisionSchema
5
+ } from "@criterionx/core";
15
6
  function generateEndpointSchema(decision) {
16
7
  const inputSchema = toJsonSchema(decision.inputSchema);
17
8
  const profileSchema = toJsonSchema(decision.profileSchema);
@@ -49,22 +40,462 @@ function generateEndpointSchema(decision) {
49
40
  };
50
41
  }
51
42
 
43
+ // src/metrics.ts
44
+ var MetricsCollector = class {
45
+ counters = /* @__PURE__ */ new Map();
46
+ histograms = /* @__PURE__ */ new Map();
47
+ buckets;
48
+ constructor(options = {}) {
49
+ this.buckets = options.buckets ?? [
50
+ 1e-3,
51
+ 5e-3,
52
+ 0.01,
53
+ 0.025,
54
+ 0.05,
55
+ 0.1,
56
+ 0.25,
57
+ 0.5,
58
+ 1,
59
+ 2.5,
60
+ 5,
61
+ 10
62
+ ];
63
+ }
64
+ /**
65
+ * Increment a counter metric
66
+ */
67
+ increment(name, labels = {}, value = 1) {
68
+ const existing = this.findCounter(name, labels);
69
+ if (existing) {
70
+ existing.value += value;
71
+ } else {
72
+ if (!this.counters.has(name)) {
73
+ this.counters.set(name, []);
74
+ }
75
+ this.counters.get(name).push({ labels, value });
76
+ }
77
+ }
78
+ /**
79
+ * Record a value in a histogram metric
80
+ */
81
+ observe(name, labels, value) {
82
+ const existing = this.findHistogram(name, labels);
83
+ if (existing) {
84
+ existing.sum += value;
85
+ existing.count += 1;
86
+ for (const bucket of this.buckets) {
87
+ if (value <= bucket) {
88
+ existing.buckets.set(bucket, (existing.buckets.get(bucket) ?? 0) + 1);
89
+ }
90
+ }
91
+ } else {
92
+ if (!this.histograms.has(name)) {
93
+ this.histograms.set(name, []);
94
+ }
95
+ const bucketMap = /* @__PURE__ */ new Map();
96
+ for (const bucket of this.buckets) {
97
+ bucketMap.set(bucket, value <= bucket ? 1 : 0);
98
+ }
99
+ this.histograms.get(name).push({
100
+ labels,
101
+ sum: value,
102
+ count: 1,
103
+ buckets: bucketMap
104
+ });
105
+ }
106
+ }
107
+ /**
108
+ * Get a counter value
109
+ */
110
+ getCounter(name, labels = {}) {
111
+ return this.findCounter(name, labels)?.value ?? 0;
112
+ }
113
+ /**
114
+ * Get histogram stats
115
+ */
116
+ getHistogram(name, labels) {
117
+ const h = this.findHistogram(name, labels);
118
+ if (!h) return void 0;
119
+ return { sum: h.sum, count: h.count };
120
+ }
121
+ /**
122
+ * Export all metrics in Prometheus format
123
+ */
124
+ toPrometheus() {
125
+ const lines = [];
126
+ for (const [name, values] of this.counters) {
127
+ lines.push(`# HELP ${name} Counter metric`);
128
+ lines.push(`# TYPE ${name} counter`);
129
+ for (const v of values) {
130
+ const labelStr = this.formatLabels(v.labels);
131
+ lines.push(`${name}${labelStr} ${v.value}`);
132
+ }
133
+ }
134
+ for (const [name, values] of this.histograms) {
135
+ lines.push(`# HELP ${name} Histogram metric`);
136
+ lines.push(`# TYPE ${name} histogram`);
137
+ for (const v of values) {
138
+ const baseLabels = this.formatLabels(v.labels);
139
+ let cumulative = 0;
140
+ for (const bucket of this.buckets) {
141
+ cumulative += v.buckets.get(bucket) ?? 0;
142
+ const bucketLabels = this.formatLabels({ ...v.labels, le: String(bucket) });
143
+ lines.push(`${name}_bucket${bucketLabels} ${cumulative}`);
144
+ }
145
+ const infLabels = this.formatLabels({ ...v.labels, le: "+Inf" });
146
+ lines.push(`${name}_bucket${infLabels} ${v.count}`);
147
+ lines.push(`${name}_sum${baseLabels} ${v.sum}`);
148
+ lines.push(`${name}_count${baseLabels} ${v.count}`);
149
+ }
150
+ }
151
+ return lines.join("\n");
152
+ }
153
+ /**
154
+ * Reset all metrics
155
+ */
156
+ reset() {
157
+ this.counters.clear();
158
+ this.histograms.clear();
159
+ }
160
+ findCounter(name, labels) {
161
+ const values = this.counters.get(name);
162
+ if (!values) return void 0;
163
+ return values.find((v) => this.labelsMatch(v.labels, labels));
164
+ }
165
+ findHistogram(name, labels) {
166
+ const values = this.histograms.get(name);
167
+ if (!values) return void 0;
168
+ return values.find((v) => this.labelsMatch(v.labels, labels));
169
+ }
170
+ labelsMatch(a, b) {
171
+ const keysA = Object.keys(a);
172
+ const keysB = Object.keys(b);
173
+ if (keysA.length !== keysB.length) return false;
174
+ return keysA.every((key) => a[key] === b[key]);
175
+ }
176
+ formatLabels(labels) {
177
+ const entries = Object.entries(labels);
178
+ if (entries.length === 0) return "";
179
+ const parts = entries.map(([k, v]) => `${k}="${v}"`);
180
+ return `{${parts.join(",")}}`;
181
+ }
182
+ };
183
+ var METRIC_EVALUATIONS_TOTAL = "criterion_evaluations_total";
184
+ var METRIC_EVALUATION_DURATION_SECONDS = "criterion_evaluation_duration_seconds";
185
+ var METRIC_RULE_MATCHES_TOTAL = "criterion_rule_matches_total";
186
+
187
+ // src/openapi.ts
188
+ function toSchemaName(id, suffix) {
189
+ const pascal = id.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
190
+ return `${pascal}${suffix}`;
191
+ }
192
+ function generateOpenAPISpec(decisions, info = {}) {
193
+ const paths = {};
194
+ const schemas = {};
195
+ schemas["EvaluateRequest"] = {
196
+ type: "object",
197
+ properties: {
198
+ input: { description: "Input data for the decision" },
199
+ profile: { description: "Profile to use (overrides default)" }
200
+ },
201
+ required: ["input"]
202
+ };
203
+ schemas["EvaluationResult"] = {
204
+ type: "object",
205
+ properties: {
206
+ status: {
207
+ type: "string",
208
+ enum: ["OK", "INVALID_INPUT", "INVALID_OUTPUT", "NO_MATCH"],
209
+ description: "Evaluation status"
210
+ },
211
+ data: {
212
+ description: "Output data (null if status is not OK)"
213
+ },
214
+ meta: {
215
+ $ref: "#/components/schemas/ResultMeta"
216
+ }
217
+ },
218
+ required: ["status", "data", "meta"]
219
+ };
220
+ schemas["ResultMeta"] = {
221
+ type: "object",
222
+ properties: {
223
+ decisionId: { type: "string" },
224
+ decisionVersion: { type: "string" },
225
+ profileId: { type: "string" },
226
+ matchedRule: { type: "string" },
227
+ explanation: { type: "string" },
228
+ evaluatedAt: { type: "string", format: "date-time" },
229
+ evaluatedRules: {
230
+ type: "array",
231
+ items: {
232
+ type: "object",
233
+ properties: {
234
+ ruleId: { type: "string" },
235
+ matched: { type: "boolean" },
236
+ explanation: { type: "string" }
237
+ }
238
+ }
239
+ }
240
+ }
241
+ };
242
+ schemas["ErrorResponse"] = {
243
+ type: "object",
244
+ properties: {
245
+ error: { type: "string", description: "Error message" }
246
+ },
247
+ required: ["error"]
248
+ };
249
+ for (const decision of decisions) {
250
+ const decisionSchema = extractDecisionSchema(decision);
251
+ const inputSchemaName = toSchemaName(decision.id, "Input");
252
+ const outputSchemaName = toSchemaName(decision.id, "Output");
253
+ const profileSchemaName = toSchemaName(decision.id, "Profile");
254
+ const requestSchemaName = toSchemaName(decision.id, "Request");
255
+ schemas[inputSchemaName] = decisionSchema.inputSchema;
256
+ schemas[outputSchemaName] = decisionSchema.outputSchema;
257
+ schemas[profileSchemaName] = decisionSchema.profileSchema;
258
+ schemas[requestSchemaName] = {
259
+ type: "object",
260
+ properties: {
261
+ input: { $ref: `#/components/schemas/${inputSchemaName}` },
262
+ profile: { $ref: `#/components/schemas/${profileSchemaName}` }
263
+ },
264
+ required: ["input"]
265
+ };
266
+ const path = `/decisions/${decision.id}`;
267
+ const description = decision.meta?.description;
268
+ paths[path] = {
269
+ post: {
270
+ operationId: `evaluate_${decision.id.replace(/-/g, "_")}`,
271
+ summary: `Evaluate ${decision.id}`,
272
+ description: description ?? `Evaluate the ${decision.id} decision`,
273
+ tags: ["decisions"],
274
+ requestBody: {
275
+ required: true,
276
+ content: {
277
+ "application/json": {
278
+ schema: { $ref: `#/components/schemas/${requestSchemaName}` }
279
+ }
280
+ }
281
+ },
282
+ responses: {
283
+ "200": {
284
+ description: "Decision evaluated successfully",
285
+ content: {
286
+ "application/json": {
287
+ schema: {
288
+ allOf: [
289
+ { $ref: "#/components/schemas/EvaluationResult" },
290
+ {
291
+ type: "object",
292
+ properties: {
293
+ data: { $ref: `#/components/schemas/${outputSchemaName}` }
294
+ }
295
+ }
296
+ ]
297
+ }
298
+ }
299
+ }
300
+ },
301
+ "400": {
302
+ description: "Invalid input or no matching rule",
303
+ content: {
304
+ "application/json": {
305
+ schema: { $ref: "#/components/schemas/EvaluationResult" }
306
+ }
307
+ }
308
+ },
309
+ "404": {
310
+ description: "Decision not found",
311
+ content: {
312
+ "application/json": {
313
+ schema: { $ref: "#/components/schemas/ErrorResponse" }
314
+ }
315
+ }
316
+ }
317
+ }
318
+ }
319
+ };
320
+ paths[`${path}/schema`] = {
321
+ get: {
322
+ operationId: `get_${decision.id.replace(/-/g, "_")}_schema`,
323
+ summary: `Get ${decision.id} schema`,
324
+ description: `Get JSON Schema for the ${decision.id} decision`,
325
+ tags: ["schemas"],
326
+ responses: {
327
+ "200": {
328
+ description: "Decision schema",
329
+ content: {
330
+ "application/json": {
331
+ schema: {
332
+ type: "object",
333
+ properties: {
334
+ id: { type: "string" },
335
+ version: { type: "string" },
336
+ inputSchema: { type: "object" },
337
+ outputSchema: { type: "object" },
338
+ profileSchema: { type: "object" }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ };
347
+ }
348
+ paths["/"] = {
349
+ get: {
350
+ operationId: "health_check",
351
+ summary: "Health check",
352
+ tags: ["system"],
353
+ responses: {
354
+ "200": {
355
+ description: "Server status",
356
+ content: {
357
+ "application/json": {
358
+ schema: {
359
+ type: "object",
360
+ properties: {
361
+ name: { type: "string" },
362
+ version: { type: "string" },
363
+ decisions: { type: "integer" },
364
+ metrics: { type: "boolean" }
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }
370
+ }
371
+ }
372
+ };
373
+ paths["/decisions"] = {
374
+ get: {
375
+ operationId: "list_decisions",
376
+ summary: "List all decisions",
377
+ tags: ["decisions"],
378
+ responses: {
379
+ "200": {
380
+ description: "List of registered decisions",
381
+ content: {
382
+ "application/json": {
383
+ schema: {
384
+ type: "object",
385
+ properties: {
386
+ decisions: {
387
+ type: "array",
388
+ items: {
389
+ type: "object",
390
+ properties: {
391
+ id: { type: "string" },
392
+ version: { type: "string" },
393
+ description: { type: "string" },
394
+ meta: { type: "object" }
395
+ }
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+ };
406
+ return {
407
+ openapi: "3.0.0",
408
+ info: {
409
+ title: info.title ?? "Criterion Decision API",
410
+ version: info.version ?? "1.0.0",
411
+ description: info.description ?? "Auto-generated API for Criterion decisions",
412
+ ...info.contact && { contact: info.contact },
413
+ ...info.license && { license: info.license }
414
+ },
415
+ paths,
416
+ components: {
417
+ schemas
418
+ }
419
+ };
420
+ }
421
+ function generateSwaggerUIHtml(specUrl) {
422
+ return `<!DOCTYPE html>
423
+ <html lang="en">
424
+ <head>
425
+ <meta charset="UTF-8">
426
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
427
+ <title>Criterion API - Swagger UI</title>
428
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
429
+ <style>
430
+ body { margin: 0; padding: 0; }
431
+ .swagger-ui .topbar { display: none; }
432
+ </style>
433
+ </head>
434
+ <body>
435
+ <div id="swagger-ui"></div>
436
+ <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
437
+ <script>
438
+ window.onload = function() {
439
+ SwaggerUIBundle({
440
+ url: "${specUrl}",
441
+ dom_id: '#swagger-ui',
442
+ deepLinking: true,
443
+ presets: [
444
+ SwaggerUIBundle.presets.apis,
445
+ SwaggerUIBundle.SwaggerUIStandalonePreset
446
+ ],
447
+ layout: "BaseLayout"
448
+ });
449
+ };
450
+ </script>
451
+ </body>
452
+ </html>`;
453
+ }
454
+
52
455
  // src/server.ts
53
456
  import { Hono } from "hono";
54
457
  import { cors } from "hono/cors";
55
458
  import { serve } from "@hono/node-server";
56
459
  import { Engine } from "@criterionx/core";
460
+ var SERVER_VERSION = "0.3.2";
461
+ function generateRequestId() {
462
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
463
+ }
464
+ function createErrorResponse(code, message, requestId, details) {
465
+ return {
466
+ error: {
467
+ code,
468
+ message,
469
+ ...details && { details }
470
+ },
471
+ ...requestId && { requestId },
472
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
473
+ };
474
+ }
57
475
  var CriterionServer = class {
58
476
  app;
59
477
  engine;
60
478
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
479
  decisions;
62
480
  profiles;
481
+ hooks;
482
+ metricsCollector = null;
483
+ metricsOptions;
484
+ openApiOptions;
485
+ openApiSpec = null;
486
+ startTime;
63
487
  constructor(options) {
64
488
  this.app = new Hono();
65
489
  this.engine = new Engine();
66
490
  this.decisions = /* @__PURE__ */ new Map();
67
491
  this.profiles = /* @__PURE__ */ new Map();
492
+ this.hooks = options.hooks ?? {};
493
+ this.metricsOptions = options.metrics ?? {};
494
+ this.openApiOptions = options.openapi ?? {};
495
+ this.startTime = /* @__PURE__ */ new Date();
496
+ if (this.metricsOptions.enabled) {
497
+ this.metricsCollector = new MetricsCollector(this.metricsOptions);
498
+ }
68
499
  for (const decision of options.decisions) {
69
500
  this.decisions.set(decision.id, decision);
70
501
  }
@@ -73,6 +504,12 @@ var CriterionServer = class {
73
504
  this.profiles.set(id, profile);
74
505
  }
75
506
  }
507
+ if (this.openApiOptions.enabled) {
508
+ this.openApiSpec = generateOpenAPISpec(
509
+ options.decisions,
510
+ this.openApiOptions.info
511
+ );
512
+ }
76
513
  if (options.cors !== false) {
77
514
  this.app.use("*", cors());
78
515
  }
@@ -82,10 +519,52 @@ var CriterionServer = class {
82
519
  this.app.get("/", (c) => {
83
520
  return c.json({
84
521
  name: "Criterion Server",
85
- version: "0.1.0",
86
- decisions: this.decisions.size
522
+ version: SERVER_VERSION,
523
+ decisions: this.decisions.size,
524
+ docs: "/docs",
525
+ health: "/health"
87
526
  });
88
527
  });
528
+ this.app.get("/health", (c) => {
529
+ const uptime = Math.floor((Date.now() - this.startTime.getTime()) / 1e3);
530
+ const response = {
531
+ status: "healthy",
532
+ version: SERVER_VERSION,
533
+ uptime,
534
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
535
+ checks: {
536
+ decisions: {
537
+ status: this.decisions.size > 0 ? "healthy" : "degraded",
538
+ message: `${this.decisions.size} decision(s) registered`
539
+ },
540
+ engine: {
541
+ status: "healthy",
542
+ message: "Engine operational"
543
+ }
544
+ }
545
+ };
546
+ return c.json(response);
547
+ });
548
+ if (this.metricsCollector) {
549
+ const endpoint = this.metricsOptions.endpoint ?? "/metrics";
550
+ this.app.get(endpoint, (c) => {
551
+ c.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
552
+ return c.text(this.metricsCollector.toPrometheus());
553
+ });
554
+ }
555
+ if (this.openApiSpec) {
556
+ const specEndpoint = this.openApiOptions.endpoint ?? "/openapi.json";
557
+ this.app.get(specEndpoint, (c) => {
558
+ return c.json(this.openApiSpec);
559
+ });
560
+ if (this.openApiOptions.swaggerUI !== false) {
561
+ const swaggerEndpoint = this.openApiOptions.swaggerEndpoint ?? "/swagger";
562
+ this.app.get(swaggerEndpoint, (c) => {
563
+ const html = generateSwaggerUIHtml(specEndpoint);
564
+ return c.html(html);
565
+ });
566
+ }
567
+ }
89
568
  this.app.get("/decisions", (c) => {
90
569
  const decisions = [];
91
570
  for (const decision of this.decisions.values()) {
@@ -102,7 +581,10 @@ var CriterionServer = class {
102
581
  const id = c.req.param("id");
103
582
  const decision = this.decisions.get(id);
104
583
  if (!decision) {
105
- return c.json({ error: `Decision not found: ${id}` }, 404);
584
+ return c.json(
585
+ createErrorResponse("DECISION_NOT_FOUND", `Decision not found: ${id}`),
586
+ 404
587
+ );
106
588
  }
107
589
  const schema = extractDecisionSchema(decision);
108
590
  return c.json(schema);
@@ -111,41 +593,115 @@ var CriterionServer = class {
111
593
  const id = c.req.param("id");
112
594
  const decision = this.decisions.get(id);
113
595
  if (!decision) {
114
- return c.json({ error: `Decision not found: ${id}` }, 404);
596
+ return c.json(
597
+ createErrorResponse("DECISION_NOT_FOUND", `Decision not found: ${id}`),
598
+ 404
599
+ );
115
600
  }
116
601
  const schema = generateEndpointSchema(decision);
117
602
  return c.json(schema);
118
603
  });
119
604
  this.app.post("/decisions/:id", async (c) => {
120
605
  const id = c.req.param("id");
606
+ const requestId = generateRequestId();
121
607
  const decision = this.decisions.get(id);
122
608
  if (!decision) {
123
- return c.json({ error: `Decision not found: ${id}` }, 404);
609
+ return c.json(
610
+ createErrorResponse("DECISION_NOT_FOUND", `Decision not found: ${id}`, requestId),
611
+ 404
612
+ );
124
613
  }
125
614
  let body;
126
615
  try {
127
616
  body = await c.req.json();
128
617
  } catch {
129
- return c.json({ error: "Invalid JSON body" }, 400);
618
+ return c.json(
619
+ createErrorResponse("INVALID_JSON", "Invalid JSON body", requestId),
620
+ 400
621
+ );
130
622
  }
131
623
  if (body.input === void 0) {
132
- return c.json({ error: "Missing 'input' in request body" }, 400);
624
+ return c.json(
625
+ createErrorResponse("MISSING_INPUT", "Missing 'input' in request body", requestId),
626
+ 400
627
+ );
133
628
  }
134
629
  let profile = body.profile;
135
630
  if (!profile) {
136
631
  profile = this.profiles.get(id);
137
632
  if (!profile) {
138
633
  return c.json(
139
- {
140
- error: `No profile provided and no default profile for decision: ${id}`
141
- },
634
+ createErrorResponse(
635
+ "MISSING_PROFILE",
636
+ `No profile provided and no default profile for decision: ${id}`,
637
+ requestId
638
+ ),
142
639
  400
143
640
  );
144
641
  }
145
642
  }
146
- const result = this.engine.run(decision, body.input, { profile });
147
- const statusCode = result.status === "OK" ? 200 : 400;
148
- return c.json(result, statusCode);
643
+ let ctx = {
644
+ decisionId: id,
645
+ input: body.input,
646
+ profile,
647
+ requestId,
648
+ timestamp: /* @__PURE__ */ new Date()
649
+ };
650
+ const startTime = performance.now();
651
+ try {
652
+ if (this.hooks.beforeEvaluate) {
653
+ const modified = await this.hooks.beforeEvaluate(ctx);
654
+ if (modified) {
655
+ ctx = { ...ctx, ...modified };
656
+ }
657
+ }
658
+ const result = this.engine.run(decision, ctx.input, {
659
+ profile: ctx.profile
660
+ });
661
+ if (this.metricsCollector) {
662
+ const durationSeconds = (performance.now() - startTime) / 1e3;
663
+ this.metricsCollector.increment(METRIC_EVALUATIONS_TOTAL, {
664
+ decision_id: id,
665
+ status: result.status
666
+ });
667
+ this.metricsCollector.observe(
668
+ METRIC_EVALUATION_DURATION_SECONDS,
669
+ { decision_id: id },
670
+ durationSeconds
671
+ );
672
+ if (result.meta.matchedRule) {
673
+ this.metricsCollector.increment(METRIC_RULE_MATCHES_TOTAL, {
674
+ decision_id: id,
675
+ rule_id: result.meta.matchedRule
676
+ });
677
+ }
678
+ }
679
+ if (this.hooks.afterEvaluate) {
680
+ await this.hooks.afterEvaluate(ctx, result);
681
+ }
682
+ const statusCode = result.status === "OK" ? 200 : 400;
683
+ return c.json(result, statusCode);
684
+ } catch (error) {
685
+ if (this.metricsCollector) {
686
+ this.metricsCollector.increment(METRIC_EVALUATIONS_TOTAL, {
687
+ decision_id: id,
688
+ status: "ERROR"
689
+ });
690
+ }
691
+ const err = error instanceof Error ? error : new Error(String(error));
692
+ if (this.hooks.onError) {
693
+ await this.hooks.onError(ctx, err);
694
+ }
695
+ return c.json(
696
+ createErrorResponse(
697
+ "EVALUATION_ERROR",
698
+ err.message,
699
+ requestId,
700
+ { decisionId: id }
701
+ ),
702
+ 500
703
+ );
704
+ }
149
705
  });
150
706
  this.app.get("/docs", (c) => {
151
707
  const decisions = Array.from(this.decisions.values()).map((d) => ({
@@ -363,6 +919,12 @@ var CriterionServer = class {
363
919
  get handler() {
364
920
  return this.app;
365
921
  }
922
+ /**
923
+ * Get the metrics collector (if enabled)
924
+ */
925
+ get metrics() {
926
+ return this.metricsCollector;
927
+ }
366
928
  /**
367
929
  * Start the server
368
930
  */
@@ -371,6 +933,18 @@ var CriterionServer = class {
371
933
  console.log(` Decisions: ${this.decisions.size}`);
372
934
  console.log(` Docs: http://localhost:${port}/docs`);
373
935
  console.log(` API: http://localhost:${port}/decisions`);
936
+ if (this.metricsCollector) {
937
+ const endpoint = this.metricsOptions.endpoint ?? "/metrics";
938
+ console.log(` Metrics: http://localhost:${port}${endpoint}`);
939
+ }
940
+ if (this.openApiSpec) {
941
+ const specEndpoint = this.openApiOptions.endpoint ?? "/openapi.json";
942
+ console.log(` OpenAPI: http://localhost:${port}${specEndpoint}`);
943
+ if (this.openApiOptions.swaggerUI !== false) {
944
+ const swaggerEndpoint = this.openApiOptions.swaggerEndpoint ?? "/swagger";
945
+ console.log(` Swagger: http://localhost:${port}${swaggerEndpoint}`);
946
+ }
947
+ }
374
948
  serve({
375
949
  fetch: this.app.fetch,
376
950
  port
@@ -382,8 +956,14 @@ function createServer(options) {
382
956
  }
383
957
  export {
384
958
  CriterionServer,
959
+ METRIC_EVALUATIONS_TOTAL,
960
+ METRIC_EVALUATION_DURATION_SECONDS,
961
+ METRIC_RULE_MATCHES_TOTAL,
962
+ MetricsCollector,
385
963
  createServer,
386
964
  extractDecisionSchema,
387
965
  generateEndpointSchema,
966
+ generateOpenAPISpec,
967
+ generateSwaggerUIHtml,
388
968
  toJsonSchema
389
969
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@criterionx/server",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "HTTP server for Criterion decisions with auto-generated docs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,17 +42,17 @@
42
42
  "@hono/node-server": "^1.13.0",
43
43
  "hono": "^4.6.0",
44
44
  "zod-to-json-schema": "^3.24.0",
45
- "@criterionx/core": "0.3.0"
45
+ "@criterionx/core": "0.3.2"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "zod": "^3.22.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/node": "^22.0.0",
51
+ "@types/node": "^25.0.3",
52
52
  "tsup": "^8.0.0",
53
53
  "tsx": "^4.7.0",
54
54
  "typescript": "^5.3.0",
55
- "vitest": "^2.0.0",
55
+ "vitest": "^4.0.16",
56
56
  "zod": "^3.22.0"
57
57
  },
58
58
  "engines": {