@criterionx/server 0.3.0 → 0.3.1

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 +221 -29
  2. package/dist/index.js +538 -17
  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
@@ -31,45 +130,129 @@ interface DecisionInfo {
31
130
  description?: string;
32
131
  meta?: Record<string, unknown>;
33
132
  }
133
+
34
134
  /**
35
- * JSON Schema representation
135
+ * Generate OpenAPI-compatible schema for a decision endpoint
36
136
  */
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;
137
+ declare function generateEndpointSchema(decision: Decision<unknown, unknown, unknown>): {
138
+ requestBody: JsonSchema;
139
+ response: JsonSchema;
140
+ };
141
+
142
+ /**
143
+ * Lightweight metrics collector for Criterion Server
144
+ *
145
+ * Exposes metrics in Prometheus format without external dependencies.
146
+ */
147
+ interface MetricsOptions {
148
+ /** Enable metrics collection (default: false) */
149
+ enabled?: boolean;
150
+ /** Endpoint path for metrics (default: /metrics) */
151
+ endpoint?: string;
152
+ /** Histogram buckets for latency in seconds */
153
+ buckets?: number[];
154
+ }
155
+ interface MetricLabels {
156
+ [key: string]: string;
46
157
  }
47
158
  /**
48
- * Decision schema export
159
+ * Simple metrics collector that outputs Prometheus format
49
160
  */
50
- interface DecisionSchema {
51
- id: string;
52
- version: string;
53
- inputSchema: JsonSchema;
54
- outputSchema: JsonSchema;
55
- profileSchema: JsonSchema;
161
+ declare class MetricsCollector {
162
+ private counters;
163
+ private histograms;
164
+ private buckets;
165
+ constructor(options?: MetricsOptions);
166
+ /**
167
+ * Increment a counter metric
168
+ */
169
+ increment(name: string, labels?: MetricLabels, value?: number): void;
170
+ /**
171
+ * Record a value in a histogram metric
172
+ */
173
+ observe(name: string, labels: MetricLabels, value: number): void;
174
+ /**
175
+ * Get a counter value
176
+ */
177
+ getCounter(name: string, labels?: MetricLabels): number;
178
+ /**
179
+ * Get histogram stats
180
+ */
181
+ getHistogram(name: string, labels: MetricLabels): {
182
+ sum: number;
183
+ count: number;
184
+ } | undefined;
185
+ /**
186
+ * Export all metrics in Prometheus format
187
+ */
188
+ toPrometheus(): string;
189
+ /**
190
+ * Reset all metrics
191
+ */
192
+ reset(): void;
193
+ private findCounter;
194
+ private findHistogram;
195
+ private labelsMatch;
196
+ private formatLabels;
56
197
  }
198
+ declare const METRIC_EVALUATIONS_TOTAL = "criterion_evaluations_total";
199
+ declare const METRIC_EVALUATION_DURATION_SECONDS = "criterion_evaluation_duration_seconds";
200
+ declare const METRIC_RULE_MATCHES_TOTAL = "criterion_rule_matches_total";
57
201
 
58
202
  /**
59
- * Convert a Zod schema to JSON Schema
203
+ * OpenAPI 3.0 spec generator for Criterion decisions
60
204
  */
61
- declare function toJsonSchema(schema: ZodSchema): JsonSchema;
205
+
62
206
  /**
63
- * Extract JSON Schemas from a decision
207
+ * OpenAPI 3.0 specification
64
208
  */
65
- declare function extractDecisionSchema(decision: Decision<unknown, unknown, unknown>): DecisionSchema;
209
+ interface OpenAPISpec {
210
+ openapi: "3.0.0";
211
+ info: OpenAPIInfo;
212
+ paths: Record<string, PathItem>;
213
+ components: {
214
+ schemas: Record<string, JsonSchema>;
215
+ };
216
+ }
217
+ interface PathItem {
218
+ post?: Operation;
219
+ get?: Operation;
220
+ }
221
+ interface Operation {
222
+ operationId: string;
223
+ summary: string;
224
+ description?: string;
225
+ tags?: string[];
226
+ requestBody?: {
227
+ required: boolean;
228
+ content: {
229
+ "application/json": {
230
+ schema: {
231
+ $ref: string;
232
+ } | JsonSchema;
233
+ };
234
+ };
235
+ };
236
+ responses: Record<string, Response>;
237
+ }
238
+ interface Response {
239
+ description: string;
240
+ content?: {
241
+ "application/json": {
242
+ schema: {
243
+ $ref: string;
244
+ } | JsonSchema;
245
+ };
246
+ };
247
+ }
66
248
  /**
67
- * Generate OpenAPI-compatible schema for a decision endpoint
249
+ * Generate OpenAPI spec from decisions
68
250
  */
69
- declare function generateEndpointSchema(decision: Decision<unknown, unknown, unknown>): {
70
- requestBody: JsonSchema;
71
- response: JsonSchema;
72
- };
251
+ declare function generateOpenAPISpec(decisions: Decision<any, any, any>[], info?: Partial<OpenAPIInfo>): OpenAPISpec;
252
+ /**
253
+ * Generate Swagger UI HTML
254
+ */
255
+ declare function generateSwaggerUIHtml(specUrl: string): string;
73
256
 
74
257
  /**
75
258
  * Criterion Server
@@ -81,6 +264,11 @@ declare class CriterionServer {
81
264
  private engine;
82
265
  private decisions;
83
266
  private profiles;
267
+ private hooks;
268
+ private metricsCollector;
269
+ private metricsOptions;
270
+ private openApiOptions;
271
+ private openApiSpec;
84
272
  constructor(options: ServerOptions);
85
273
  private setupRoutes;
86
274
  private generateDocsHtml;
@@ -88,6 +276,10 @@ declare class CriterionServer {
88
276
  * Get the Hono app instance (for custom middleware)
89
277
  */
90
278
  get handler(): Hono;
279
+ /**
280
+ * Get the metrics collector (if enabled)
281
+ */
282
+ get metrics(): MetricsCollector | null;
91
283
  /**
92
284
  * Start the server
93
285
  */
@@ -98,4 +290,4 @@ declare class CriterionServer {
98
290
  */
99
291
  declare function createServer(options: ServerOptions): CriterionServer;
100
292
 
101
- export { CriterionServer, type DecisionInfo, type DecisionSchema, type EvaluateRequest, type JsonSchema, type ServerOptions, createServer, extractDecisionSchema, generateEndpointSchema, toJsonSchema };
293
+ export { type AfterEvaluateHook, type BeforeEvaluateHook, CriterionServer, type DecisionInfo, type EvaluateRequest, 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,448 @@ 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
+ function generateRequestId() {
461
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
462
+ }
57
463
  var CriterionServer = class {
58
464
  app;
59
465
  engine;
60
466
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
467
  decisions;
62
468
  profiles;
469
+ hooks;
470
+ metricsCollector = null;
471
+ metricsOptions;
472
+ openApiOptions;
473
+ openApiSpec = null;
63
474
  constructor(options) {
64
475
  this.app = new Hono();
65
476
  this.engine = new Engine();
66
477
  this.decisions = /* @__PURE__ */ new Map();
67
478
  this.profiles = /* @__PURE__ */ new Map();
479
+ this.hooks = options.hooks ?? {};
480
+ this.metricsOptions = options.metrics ?? {};
481
+ this.openApiOptions = options.openapi ?? {};
482
+ if (this.metricsOptions.enabled) {
483
+ this.metricsCollector = new MetricsCollector(this.metricsOptions);
484
+ }
68
485
  for (const decision of options.decisions) {
69
486
  this.decisions.set(decision.id, decision);
70
487
  }
@@ -73,6 +490,12 @@ var CriterionServer = class {
73
490
  this.profiles.set(id, profile);
74
491
  }
75
492
  }
493
+ if (this.openApiOptions.enabled) {
494
+ this.openApiSpec = generateOpenAPISpec(
495
+ options.decisions,
496
+ this.openApiOptions.info
497
+ );
498
+ }
76
499
  if (options.cors !== false) {
77
500
  this.app.use("*", cors());
78
501
  }
@@ -83,9 +506,30 @@ var CriterionServer = class {
83
506
  return c.json({
84
507
  name: "Criterion Server",
85
508
  version: "0.1.0",
86
- decisions: this.decisions.size
509
+ decisions: this.decisions.size,
510
+ metrics: this.metricsCollector !== null
87
511
  });
88
512
  });
513
+ if (this.metricsCollector) {
514
+ const endpoint = this.metricsOptions.endpoint ?? "/metrics";
515
+ this.app.get(endpoint, (c) => {
516
+ c.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
517
+ return c.text(this.metricsCollector.toPrometheus());
518
+ });
519
+ }
520
+ if (this.openApiSpec) {
521
+ const specEndpoint = this.openApiOptions.endpoint ?? "/openapi.json";
522
+ this.app.get(specEndpoint, (c) => {
523
+ return c.json(this.openApiSpec);
524
+ });
525
+ if (this.openApiOptions.swaggerUI !== false) {
526
+ const swaggerEndpoint = this.openApiOptions.swaggerEndpoint ?? "/swagger";
527
+ this.app.get(swaggerEndpoint, (c) => {
528
+ const html = generateSwaggerUIHtml(specEndpoint);
529
+ return c.html(html);
530
+ });
531
+ }
532
+ }
89
533
  this.app.get("/decisions", (c) => {
90
534
  const decisions = [];
91
535
  for (const decision of this.decisions.values()) {
@@ -143,9 +587,62 @@ var CriterionServer = class {
143
587
  );
144
588
  }
145
589
  }
146
- const result = this.engine.run(decision, body.input, { profile });
147
- const statusCode = result.status === "OK" ? 200 : 400;
148
- return c.json(result, statusCode);
590
+ let ctx = {
591
+ decisionId: id,
592
+ input: body.input,
593
+ profile,
594
+ requestId: generateRequestId(),
595
+ timestamp: /* @__PURE__ */ new Date()
596
+ };
597
+ const startTime = performance.now();
598
+ try {
599
+ if (this.hooks.beforeEvaluate) {
600
+ const modified = await this.hooks.beforeEvaluate(ctx);
601
+ if (modified) {
602
+ ctx = { ...ctx, ...modified };
603
+ }
604
+ }
605
+ const result = this.engine.run(decision, ctx.input, {
606
+ profile: ctx.profile
607
+ });
608
+ if (this.metricsCollector) {
609
+ const durationSeconds = (performance.now() - startTime) / 1e3;
610
+ this.metricsCollector.increment(METRIC_EVALUATIONS_TOTAL, {
611
+ decision_id: id,
612
+ status: result.status
613
+ });
614
+ this.metricsCollector.observe(
615
+ METRIC_EVALUATION_DURATION_SECONDS,
616
+ { decision_id: id },
617
+ durationSeconds
618
+ );
619
+ if (result.meta.matchedRule) {
620
+ this.metricsCollector.increment(METRIC_RULE_MATCHES_TOTAL, {
621
+ decision_id: id,
622
+ rule_id: result.meta.matchedRule
623
+ });
624
+ }
625
+ }
626
+ if (this.hooks.afterEvaluate) {
627
+ await this.hooks.afterEvaluate(ctx, result);
628
+ }
629
+ const statusCode = result.status === "OK" ? 200 : 400;
630
+ return c.json(result, statusCode);
631
+ } catch (error) {
632
+ if (this.metricsCollector) {
633
+ this.metricsCollector.increment(METRIC_EVALUATIONS_TOTAL, {
634
+ decision_id: id,
635
+ status: "ERROR"
636
+ });
637
+ }
638
+ if (this.hooks.onError) {
639
+ await this.hooks.onError(
640
+ ctx,
641
+ error instanceof Error ? error : new Error(String(error))
642
+ );
643
+ }
644
+ throw error;
645
+ }
149
646
  });
150
647
  this.app.get("/docs", (c) => {
151
648
  const decisions = Array.from(this.decisions.values()).map((d) => ({
@@ -363,6 +860,12 @@ var CriterionServer = class {
363
860
  get handler() {
364
861
  return this.app;
365
862
  }
863
+ /**
864
+ * Get the metrics collector (if enabled)
865
+ */
866
+ get metrics() {
867
+ return this.metricsCollector;
868
+ }
366
869
  /**
367
870
  * Start the server
368
871
  */
@@ -371,6 +874,18 @@ var CriterionServer = class {
371
874
  console.log(` Decisions: ${this.decisions.size}`);
372
875
  console.log(` Docs: http://localhost:${port}/docs`);
373
876
  console.log(` API: http://localhost:${port}/decisions`);
877
+ if (this.metricsCollector) {
878
+ const endpoint = this.metricsOptions.endpoint ?? "/metrics";
879
+ console.log(` Metrics: http://localhost:${port}${endpoint}`);
880
+ }
881
+ if (this.openApiSpec) {
882
+ const specEndpoint = this.openApiOptions.endpoint ?? "/openapi.json";
883
+ console.log(` OpenAPI: http://localhost:${port}${specEndpoint}`);
884
+ if (this.openApiOptions.swaggerUI !== false) {
885
+ const swaggerEndpoint = this.openApiOptions.swaggerEndpoint ?? "/swagger";
886
+ console.log(` Swagger: http://localhost:${port}${swaggerEndpoint}`);
887
+ }
888
+ }
374
889
  serve({
375
890
  fetch: this.app.fetch,
376
891
  port
@@ -382,8 +897,14 @@ function createServer(options) {
382
897
  }
383
898
  export {
384
899
  CriterionServer,
900
+ METRIC_EVALUATIONS_TOTAL,
901
+ METRIC_EVALUATION_DURATION_SECONDS,
902
+ METRIC_RULE_MATCHES_TOTAL,
903
+ MetricsCollector,
385
904
  createServer,
386
905
  extractDecisionSchema,
387
906
  generateEndpointSchema,
907
+ generateOpenAPISpec,
908
+ generateSwaggerUIHtml,
388
909
  toJsonSchema
389
910
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@criterionx/server",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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.1"
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": {