@a-company/sentinel 3.5.0 → 3.6.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.
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  loadConfig,
14
14
  loadServerConfig,
15
15
  writeConfig
16
- } from "./chunk-FOF7CPJ6.js";
16
+ } from "./chunk-NTX74ZPM.js";
17
17
 
18
18
  // src/matcher.ts
19
19
  var DEFAULT_CONFIG = {
@@ -2195,6 +2195,152 @@ var PatternImporter = class {
2195
2195
  };
2196
2196
  }
2197
2197
  };
2198
+
2199
+ // src/schema/builtin-paradigm.ts
2200
+ var PARADIGM_SCHEMA = {
2201
+ id: "paradigm-logger",
2202
+ version: "1.0.0",
2203
+ name: "Paradigm Logger",
2204
+ description: "Structured logs from @a-company/paradigm-logger with symbolic context",
2205
+ scope: {
2206
+ field: "correlationId",
2207
+ type: "string",
2208
+ label: "Correlation",
2209
+ ordering: "independent",
2210
+ sessionField: "sessionId"
2211
+ },
2212
+ eventTypes: [
2213
+ {
2214
+ type: "log:debug",
2215
+ category: "logs",
2216
+ label: "Debug Log",
2217
+ severity: "debug",
2218
+ frequency: "high",
2219
+ fields: [
2220
+ { name: "symbol", type: "string", indexed: true, display: true },
2221
+ { name: "symbolType", type: "string", indexed: true, display: true },
2222
+ { name: "message", type: "string", display: true },
2223
+ { name: "service", type: "string", indexed: true, display: true },
2224
+ { name: "durationMs", type: "number", display: true }
2225
+ ]
2226
+ },
2227
+ {
2228
+ type: "log:info",
2229
+ category: "logs",
2230
+ label: "Info Log",
2231
+ severity: "info",
2232
+ frequency: "high",
2233
+ fields: [
2234
+ { name: "symbol", type: "string", indexed: true, display: true },
2235
+ { name: "symbolType", type: "string", indexed: true, display: true },
2236
+ { name: "message", type: "string", display: true },
2237
+ { name: "service", type: "string", indexed: true, display: true },
2238
+ { name: "durationMs", type: "number", display: true }
2239
+ ]
2240
+ },
2241
+ {
2242
+ type: "log:warn",
2243
+ category: "logs",
2244
+ label: "Warning Log",
2245
+ severity: "warn",
2246
+ frequency: "medium",
2247
+ fields: [
2248
+ { name: "symbol", type: "string", indexed: true, display: true },
2249
+ { name: "symbolType", type: "string", indexed: true, display: true },
2250
+ { name: "message", type: "string", display: true },
2251
+ { name: "service", type: "string", indexed: true, display: true }
2252
+ ]
2253
+ },
2254
+ {
2255
+ type: "log:error",
2256
+ category: "logs",
2257
+ label: "Error Log",
2258
+ severity: "error",
2259
+ frequency: "low",
2260
+ fields: [
2261
+ { name: "symbol", type: "string", indexed: true, display: true },
2262
+ { name: "symbolType", type: "string", indexed: true, display: true },
2263
+ { name: "message", type: "string", display: true },
2264
+ { name: "service", type: "string", indexed: true, display: true }
2265
+ ]
2266
+ },
2267
+ {
2268
+ type: "metric:counter",
2269
+ category: "metrics",
2270
+ label: "Counter Metric",
2271
+ severity: "info",
2272
+ frequency: "high",
2273
+ fields: [
2274
+ { name: "name", type: "string", indexed: true, display: true },
2275
+ { name: "value", type: "number", display: true },
2276
+ { name: "tags", type: "object" }
2277
+ ]
2278
+ },
2279
+ {
2280
+ type: "metric:gauge",
2281
+ category: "metrics",
2282
+ label: "Gauge Metric",
2283
+ severity: "info",
2284
+ frequency: "medium",
2285
+ fields: [
2286
+ { name: "name", type: "string", indexed: true, display: true },
2287
+ { name: "value", type: "number", display: true },
2288
+ { name: "tags", type: "object" }
2289
+ ]
2290
+ },
2291
+ {
2292
+ type: "metric:histogram",
2293
+ category: "metrics",
2294
+ label: "Histogram Metric",
2295
+ severity: "info",
2296
+ frequency: "medium",
2297
+ fields: [
2298
+ { name: "name", type: "string", indexed: true, display: true },
2299
+ { name: "value", type: "number", display: true },
2300
+ { name: "tags", type: "object" }
2301
+ ]
2302
+ },
2303
+ {
2304
+ type: "trace:span",
2305
+ category: "traces",
2306
+ label: "Trace Span",
2307
+ severity: "info",
2308
+ frequency: "medium",
2309
+ fields: [
2310
+ { name: "traceId", type: "string", indexed: true, display: true },
2311
+ { name: "spanId", type: "string", indexed: true },
2312
+ { name: "operation", type: "string", display: true },
2313
+ { name: "durationMs", type: "number", display: true },
2314
+ { name: "status", type: "string", display: true }
2315
+ ]
2316
+ },
2317
+ {
2318
+ type: "incident:recorded",
2319
+ category: "incidents",
2320
+ label: "Incident Recorded",
2321
+ severity: "error",
2322
+ frequency: "low",
2323
+ fields: [
2324
+ { name: "incidentId", type: "string", indexed: true, display: true },
2325
+ { name: "errorMessage", type: "string", display: true },
2326
+ { name: "symbols", type: "object" },
2327
+ { name: "environment", type: "string", display: true }
2328
+ ]
2329
+ }
2330
+ ],
2331
+ visualization: {
2332
+ defaultView: "table",
2333
+ categoryColors: {
2334
+ logs: "#3b82f6",
2335
+ metrics: "#22c55e",
2336
+ traces: "#a855f7",
2337
+ incidents: "#ef4444"
2338
+ },
2339
+ summaryFields: ["symbol", "message", "service"],
2340
+ defaultExcluded: ["log:debug"]
2341
+ },
2342
+ tags: ["builtin", "paradigm"]
2343
+ };
2198
2344
  export {
2199
2345
  ContextEnricher,
2200
2346
  DEFAULT_AUTH_CONFIG,
@@ -2202,6 +2348,7 @@ export {
2202
2348
  DEFAULT_SERVER_CONFIG,
2203
2349
  FlowTracker,
2204
2350
  IncidentGrouper,
2351
+ PARADIGM_SCHEMA,
2205
2352
  PatternImporter,
2206
2353
  PatternMatcher,
2207
2354
  PatternSuggester,
package/dist/mcp.js CHANGED
@@ -13,7 +13,7 @@ import initSqlJs from "sql.js";
13
13
  import { v4 as uuidv4 } from "uuid";
14
14
  import * as path from "path";
15
15
  import * as fs from "fs";
16
- var SCHEMA_VERSION = 4;
16
+ var SCHEMA_VERSION = 5;
17
17
  var DEFAULT_CONFIDENCE = {
18
18
  score: 50,
19
19
  timesMatched: 0,
@@ -444,6 +444,54 @@ var SentinelStorage = class {
444
444
  this.db.run(
445
445
  "INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', '4')"
446
446
  );
447
+ currentVersion = 4;
448
+ }
449
+ if (currentVersion < 5) {
450
+ try {
451
+ this.db.run(`
452
+ CREATE TABLE IF NOT EXISTS schemas (
453
+ id TEXT PRIMARY KEY,
454
+ version TEXT NOT NULL,
455
+ name TEXT NOT NULL,
456
+ description TEXT,
457
+ scope_json TEXT NOT NULL,
458
+ event_types_json TEXT NOT NULL,
459
+ causality_json TEXT,
460
+ visualization_json TEXT,
461
+ tags_json TEXT DEFAULT '[]',
462
+ registered_at TEXT NOT NULL,
463
+ updated_at TEXT NOT NULL
464
+ );
465
+
466
+ CREATE TABLE IF NOT EXISTS events (
467
+ id TEXT PRIMARY KEY,
468
+ schema_id TEXT NOT NULL,
469
+ event_type TEXT NOT NULL,
470
+ category TEXT NOT NULL,
471
+ timestamp TEXT NOT NULL,
472
+ scope_value TEXT,
473
+ scope_ordinal INTEGER,
474
+ session_id TEXT,
475
+ service TEXT NOT NULL,
476
+ data_json TEXT,
477
+ severity TEXT DEFAULT 'info',
478
+ parent_event_id TEXT,
479
+ depth INTEGER DEFAULT 0
480
+ );
481
+
482
+ CREATE INDEX IF NOT EXISTS idx_events_schema ON events(schema_id);
483
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
484
+ CREATE INDEX IF NOT EXISTS idx_events_scope ON events(schema_id, scope_value);
485
+ CREATE INDEX IF NOT EXISTS idx_events_scope_ord ON events(schema_id, scope_ordinal);
486
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
487
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
488
+ CREATE INDEX IF NOT EXISTS idx_events_service ON events(service);
489
+ `);
490
+ } catch {
491
+ }
492
+ this.db.run(
493
+ "INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', '5')"
494
+ );
447
495
  }
448
496
  }
449
497
  /**
@@ -2032,6 +2080,341 @@ var SentinelStorage = class {
2032
2080
  logs: obj.log_ids_json ? JSON.parse(obj.log_ids_json) : []
2033
2081
  };
2034
2082
  }
2083
+ // ─── Schema Registry ─────────────────────────────────────────────
2084
+ registerSchema(schema) {
2085
+ this.initializeSync();
2086
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2087
+ this.db.run(
2088
+ `INSERT INTO schemas (
2089
+ id, version, name, description, scope_json, event_types_json,
2090
+ causality_json, visualization_json, tags_json, registered_at, updated_at
2091
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2092
+ ON CONFLICT(id) DO UPDATE SET
2093
+ version = excluded.version,
2094
+ name = excluded.name,
2095
+ description = excluded.description,
2096
+ scope_json = excluded.scope_json,
2097
+ event_types_json = excluded.event_types_json,
2098
+ causality_json = excluded.causality_json,
2099
+ visualization_json = excluded.visualization_json,
2100
+ tags_json = excluded.tags_json,
2101
+ updated_at = excluded.updated_at`,
2102
+ [
2103
+ schema.id,
2104
+ schema.version,
2105
+ schema.name,
2106
+ schema.description || null,
2107
+ JSON.stringify(schema.scope),
2108
+ JSON.stringify(schema.eventTypes),
2109
+ schema.causality ? JSON.stringify(schema.causality) : null,
2110
+ schema.visualization ? JSON.stringify(schema.visualization) : null,
2111
+ JSON.stringify(schema.tags || []),
2112
+ now,
2113
+ now
2114
+ ]
2115
+ );
2116
+ this.save();
2117
+ return {
2118
+ id: schema.id,
2119
+ version: schema.version,
2120
+ name: schema.name,
2121
+ description: schema.description,
2122
+ scope: schema.scope,
2123
+ eventTypes: schema.eventTypes,
2124
+ causality: schema.causality,
2125
+ visualization: schema.visualization,
2126
+ tags: schema.tags || [],
2127
+ registeredAt: now,
2128
+ updatedAt: now
2129
+ };
2130
+ }
2131
+ getSchema(id) {
2132
+ this.initializeSync();
2133
+ const result = this.db.exec("SELECT * FROM schemas WHERE id = ?", [id]);
2134
+ if (result.length === 0 || result[0].values.length === 0) return null;
2135
+ return this.rowToSchema(result[0].columns, result[0].values[0]);
2136
+ }
2137
+ listSchemas() {
2138
+ this.initializeSync();
2139
+ const result = this.db.exec("SELECT * FROM schemas ORDER BY name ASC");
2140
+ if (result.length === 0) return [];
2141
+ return result[0].values.map(
2142
+ (row) => this.rowToSchema(result[0].columns, row)
2143
+ );
2144
+ }
2145
+ rowToSchema(columns, row) {
2146
+ const obj = {};
2147
+ columns.forEach((col, i) => {
2148
+ obj[col] = row[i];
2149
+ });
2150
+ return {
2151
+ id: obj.id,
2152
+ version: obj.version,
2153
+ name: obj.name,
2154
+ description: obj.description || void 0,
2155
+ scope: JSON.parse(obj.scope_json),
2156
+ eventTypes: JSON.parse(obj.event_types_json),
2157
+ causality: obj.causality_json ? JSON.parse(obj.causality_json) : void 0,
2158
+ visualization: obj.visualization_json ? JSON.parse(obj.visualization_json) : void 0,
2159
+ tags: JSON.parse(obj.tags_json || "[]"),
2160
+ registeredAt: obj.registered_at,
2161
+ updatedAt: obj.updated_at
2162
+ };
2163
+ }
2164
+ // ─── Generic Events ────────────────────────────────────────────
2165
+ insertEventBatch(schemaId, service, inputs) {
2166
+ this.initializeSync();
2167
+ const schema = this.getSchema(schemaId);
2168
+ const typeMap = /* @__PURE__ */ new Map();
2169
+ if (schema) {
2170
+ for (const et of schema.eventTypes) {
2171
+ typeMap.set(et.type, {
2172
+ category: et.category,
2173
+ severity: et.severity || "info"
2174
+ });
2175
+ }
2176
+ }
2177
+ let accepted = 0;
2178
+ const errors = [];
2179
+ for (const input of inputs) {
2180
+ try {
2181
+ const id = input.id || uuidv4();
2182
+ const timestamp = input.timestamp || (/* @__PURE__ */ new Date()).toISOString();
2183
+ const resolved = typeMap.get(input.type);
2184
+ const category = resolved?.category || "unknown";
2185
+ const severity = input.severity || resolved?.severity || "info";
2186
+ const scopeValue = input.scopeValue != null ? String(input.scopeValue) : null;
2187
+ const scopeOrdinal = typeof input.scopeValue === "number" ? input.scopeValue : null;
2188
+ this.db.run(
2189
+ `INSERT INTO events (
2190
+ id, schema_id, event_type, category, timestamp, scope_value,
2191
+ scope_ordinal, session_id, service, data_json, severity,
2192
+ parent_event_id, depth
2193
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2194
+ [
2195
+ id,
2196
+ schemaId,
2197
+ input.type,
2198
+ category,
2199
+ timestamp,
2200
+ scopeValue,
2201
+ scopeOrdinal,
2202
+ input.sessionId || null,
2203
+ service,
2204
+ input.data ? JSON.stringify(input.data) : null,
2205
+ severity,
2206
+ input.parentEventId || null,
2207
+ input.depth ?? 0
2208
+ ]
2209
+ );
2210
+ accepted++;
2211
+ } catch (err) {
2212
+ errors.push(err instanceof Error ? err.message : String(err));
2213
+ }
2214
+ }
2215
+ this.save();
2216
+ return { accepted, errors };
2217
+ }
2218
+ queryEvents(options = {}) {
2219
+ this.initializeSync();
2220
+ const { limit = 100, offset = 0 } = options;
2221
+ const conditions = [];
2222
+ const params = [];
2223
+ if (options.schemaId) {
2224
+ conditions.push("schema_id = ?");
2225
+ params.push(options.schemaId);
2226
+ }
2227
+ if (options.eventType) {
2228
+ conditions.push("event_type = ?");
2229
+ params.push(options.eventType);
2230
+ }
2231
+ if (options.category) {
2232
+ conditions.push("category = ?");
2233
+ params.push(options.category);
2234
+ }
2235
+ if (options.service) {
2236
+ conditions.push("service = ?");
2237
+ params.push(options.service);
2238
+ }
2239
+ if (options.sessionId) {
2240
+ conditions.push("session_id = ?");
2241
+ params.push(options.sessionId);
2242
+ }
2243
+ if (options.scopeValue) {
2244
+ conditions.push("scope_value = ?");
2245
+ params.push(options.scopeValue);
2246
+ }
2247
+ if (options.scopeFrom) {
2248
+ conditions.push("scope_value >= ?");
2249
+ params.push(options.scopeFrom);
2250
+ }
2251
+ if (options.scopeTo) {
2252
+ conditions.push("scope_value <= ?");
2253
+ params.push(options.scopeTo);
2254
+ }
2255
+ if (options.severity) {
2256
+ conditions.push("severity = ?");
2257
+ params.push(options.severity);
2258
+ }
2259
+ if (options.since) {
2260
+ conditions.push("timestamp >= ?");
2261
+ params.push(options.since);
2262
+ }
2263
+ if (options.until) {
2264
+ conditions.push("timestamp <= ?");
2265
+ params.push(options.until);
2266
+ }
2267
+ if (options.search) {
2268
+ conditions.push("data_json LIKE ?");
2269
+ params.push(`%${options.search}%`);
2270
+ }
2271
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2272
+ const result = this.db.exec(
2273
+ `SELECT * FROM events ${whereClause} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
2274
+ [...params, limit, offset]
2275
+ );
2276
+ if (result.length === 0) return [];
2277
+ return result[0].values.map(
2278
+ (row) => this.rowToGenericEvent(result[0].columns, row)
2279
+ );
2280
+ }
2281
+ queryEventsByScope(schemaId, scopeValue) {
2282
+ this.initializeSync();
2283
+ const result = this.db.exec(
2284
+ `SELECT * FROM events
2285
+ WHERE schema_id = ? AND scope_value = ?
2286
+ ORDER BY timestamp ASC`,
2287
+ [schemaId, scopeValue]
2288
+ );
2289
+ if (result.length === 0) return [];
2290
+ return result[0].values.map(
2291
+ (row) => this.rowToGenericEvent(result[0].columns, row)
2292
+ );
2293
+ }
2294
+ getEventScopes(schemaId, options = {}) {
2295
+ this.initializeSync();
2296
+ const { limit = 100, offset = 0 } = options;
2297
+ const conditions = ["schema_id = ?"];
2298
+ const params = [schemaId];
2299
+ if (options.sessionId) {
2300
+ conditions.push("session_id = ?");
2301
+ params.push(options.sessionId);
2302
+ }
2303
+ const whereClause = `WHERE ${conditions.join(" AND ")}`;
2304
+ const result = this.db.exec(
2305
+ `SELECT
2306
+ scope_value,
2307
+ MIN(scope_ordinal) as scope_ordinal,
2308
+ COUNT(*) as event_count,
2309
+ MIN(timestamp) as first_timestamp,
2310
+ MAX(timestamp) as last_timestamp
2311
+ FROM events
2312
+ ${whereClause}
2313
+ AND scope_value IS NOT NULL
2314
+ GROUP BY scope_value
2315
+ ORDER BY MIN(COALESCE(scope_ordinal, 0)) DESC, MIN(timestamp) DESC
2316
+ LIMIT ? OFFSET ?`,
2317
+ [...params, limit, offset]
2318
+ );
2319
+ if (result.length === 0) return [];
2320
+ const scopes = [];
2321
+ for (const row of result[0].values) {
2322
+ const scopeValue = row[0];
2323
+ const scopeOrdinal = row[1] != null ? row[1] : void 0;
2324
+ const eventCount = row[2];
2325
+ const firstTimestamp = row[3];
2326
+ const lastTimestamp = row[4];
2327
+ const catResult = this.db.exec(
2328
+ `SELECT category, COUNT(*) as count FROM events
2329
+ WHERE schema_id = ? AND scope_value = ?
2330
+ GROUP BY category`,
2331
+ [schemaId, scopeValue]
2332
+ );
2333
+ const categories = {};
2334
+ if (catResult.length > 0) {
2335
+ for (const catRow of catResult[0].values) {
2336
+ categories[catRow[0]] = catRow[1];
2337
+ }
2338
+ }
2339
+ scopes.push({
2340
+ scopeValue,
2341
+ scopeOrdinal,
2342
+ eventCount,
2343
+ categories,
2344
+ firstTimestamp,
2345
+ lastTimestamp
2346
+ });
2347
+ }
2348
+ return scopes;
2349
+ }
2350
+ getEventCount(options = {}) {
2351
+ this.initializeSync();
2352
+ const conditions = [];
2353
+ const params = [];
2354
+ if (options.schemaId) {
2355
+ conditions.push("schema_id = ?");
2356
+ params.push(options.schemaId);
2357
+ }
2358
+ if (options.eventType) {
2359
+ conditions.push("event_type = ?");
2360
+ params.push(options.eventType);
2361
+ }
2362
+ if (options.service) {
2363
+ conditions.push("service = ?");
2364
+ params.push(options.service);
2365
+ }
2366
+ if (options.since) {
2367
+ conditions.push("timestamp >= ?");
2368
+ params.push(options.since);
2369
+ }
2370
+ if (options.until) {
2371
+ conditions.push("timestamp <= ?");
2372
+ params.push(options.until);
2373
+ }
2374
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2375
+ const result = this.db.exec(
2376
+ `SELECT COUNT(*) as count FROM events ${whereClause}`,
2377
+ params
2378
+ );
2379
+ if (result.length === 0 || result[0].values.length === 0) return 0;
2380
+ return result[0].values[0][0];
2381
+ }
2382
+ pruneEvents(maxCount) {
2383
+ this.initializeSync();
2384
+ if (maxCount <= 0) return 0;
2385
+ const currentCount = this.getEventCount();
2386
+ if (currentCount <= maxCount) return 0;
2387
+ const deleteCount = currentCount - maxCount;
2388
+ this.db.run(
2389
+ `DELETE FROM events WHERE id IN (
2390
+ SELECT id FROM events ORDER BY timestamp ASC LIMIT ?
2391
+ )`,
2392
+ [deleteCount]
2393
+ );
2394
+ this.save();
2395
+ return deleteCount;
2396
+ }
2397
+ rowToGenericEvent(columns, row) {
2398
+ const obj = {};
2399
+ columns.forEach((col, i) => {
2400
+ obj[col] = row[i];
2401
+ });
2402
+ return {
2403
+ id: obj.id,
2404
+ schemaId: obj.schema_id,
2405
+ eventType: obj.event_type,
2406
+ category: obj.category,
2407
+ timestamp: obj.timestamp,
2408
+ scopeValue: obj.scope_value || void 0,
2409
+ scopeOrdinal: obj.scope_ordinal != null ? obj.scope_ordinal : void 0,
2410
+ sessionId: obj.session_id || void 0,
2411
+ service: obj.service,
2412
+ data: obj.data_json ? JSON.parse(obj.data_json) : void 0,
2413
+ severity: obj.severity || "info",
2414
+ parentEventId: obj.parent_event_id || void 0,
2415
+ depth: obj.depth || 0
2416
+ };
2417
+ }
2035
2418
  close() {
2036
2419
  if (this.db) {
2037
2420
  this.save();
@@ -1,4 +1,4 @@
1
- import { S as SentinelStorage } from './storage-BqCJqZat.js';
1
+ import { S as SentinelStorage } from './storage-BKyt7aPJ.js';
2
2
  import { a as SymbolicIncidentRecord, K as MatcherConfig, X as PatternMatch, e as FailurePattern, a2 as PatternTestResult, ae as SentinelConfig, C as ComponentContext, al as SymbolicContext, u as FlowPosition } from './types-BmVoO1iF.js';
3
3
 
4
4
  /**
@@ -1,5 +1,5 @@
1
1
  import { Express } from 'express';
2
- import { S as SentinelStorage } from '../storage-BqCJqZat.js';
2
+ import { S as SentinelStorage, G as GenericEvent } from '../storage-BKyt7aPJ.js';
3
3
  import { S as SentinelServerConfig, L as LogEntry } from '../types-BmVoO1iF.js';
4
4
 
5
5
  /**
@@ -89,6 +89,7 @@ declare function createApp(options: ServerOptions & {
89
89
  known: boolean;
90
90
  suggestion?: string;
91
91
  }) => void;
92
+ onEventReceived?: (event: GenericEvent) => void;
92
93
  }): Express;
93
94
  /**
94
95
  * Start the Sentinel server with WebSocket support