@luanpdd/kit-mcp 1.1.0 → 1.2.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/CHANGELOG.md +100 -0
- package/README.md +32 -0
- package/bin/ui.js +74 -0
- package/package.json +2 -1
- package/src/cli/index.js +211 -10
- package/src/mcp-server/index.js +53 -6
- package/src/ui/auto-spawn.js +108 -0
- package/src/ui/browser.js +78 -0
- package/src/ui/client.js +115 -0
- package/src/ui/events.js +65 -0
- package/src/ui/lockfile.js +147 -0
- package/src/ui/port.js +67 -0
- package/src/ui/server.js +432 -0
- package/src/ui/static/index.html +609 -0
- package/src/ui/wrapper.js +119 -0
package/src/ui/server.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// src/ui/server.js
|
|
2
|
+
// Sidecar HTTP + Server-Sent Events server.
|
|
3
|
+
//
|
|
4
|
+
// Responsibilities:
|
|
5
|
+
// - bind on 127.0.0.1 only (REQ SEC ADR-06)
|
|
6
|
+
// - 5 routes: GET /, GET /events (SSE), GET /healthz, GET /state, POST /publish, POST /shutdown
|
|
7
|
+
// - in-process EventEmitter bus relays POST /publish payloads to SSE subscribers
|
|
8
|
+
// - ring buffer (200 events) for /state hydrate-on-load
|
|
9
|
+
// - cap of 32 simultaneous SSE subscribers
|
|
10
|
+
// - heartbeat every 15s on each open SSE connection
|
|
11
|
+
// - idle shutdown after 30min default (REQ SRV-10)
|
|
12
|
+
// - graceful SIGINT/SIGTERM (REQ SRV-11): emit shutdown event, drain, release lock
|
|
13
|
+
// - Host header validation on every request (REQ SEC-01)
|
|
14
|
+
// - Origin validation on non-GET (REQ SEC-02)
|
|
15
|
+
//
|
|
16
|
+
// Logging discipline: all log output goes to stderr or to a file. Never stdout.
|
|
17
|
+
// (REQ SEC-04, enforced by CI gate in .github/workflows/ci.yml)
|
|
18
|
+
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import { EventEmitter } from 'node:events';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import process from 'node:process';
|
|
25
|
+
|
|
26
|
+
import { findFreePortOrThrow } from './port.js';
|
|
27
|
+
import { acquireLockOrReclaim, releaseLock } from './lockfile.js';
|
|
28
|
+
import { validateEvent, makeEvent, EVENT_TYPES } from './events.js';
|
|
29
|
+
|
|
30
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const STATIC_DIR = path.join(HERE, 'static');
|
|
32
|
+
const HOST = '127.0.0.1';
|
|
33
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
34
|
+
const RING_BUFFER_SIZE = 200;
|
|
35
|
+
const MAX_SSE_SUBSCRIBERS = 32;
|
|
36
|
+
const DEFAULT_IDLE_MS = 30 * 60 * 1000; // 30 minutes
|
|
37
|
+
|
|
38
|
+
const SSE_HEADERS = {
|
|
39
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
40
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
41
|
+
'Connection': 'keep-alive',
|
|
42
|
+
'X-Accel-Buffering': 'no',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const CSP =
|
|
46
|
+
"default-src 'self'; " +
|
|
47
|
+
"connect-src 'self'; " +
|
|
48
|
+
"script-src 'self' 'unsafe-inline'; " +
|
|
49
|
+
"style-src 'self' 'unsafe-inline'; " +
|
|
50
|
+
"img-src 'self' data:; " +
|
|
51
|
+
"frame-ancestors 'none'";
|
|
52
|
+
|
|
53
|
+
function logErr(...args) {
|
|
54
|
+
// Strict stderr discipline — never stdout (collides with MCP JSON-RPC if running in same process).
|
|
55
|
+
process.stderr.write(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Validate Host header against allowed hostnames (REQ SEC-01).
|
|
59
|
+
// Allow 127.0.0.1 and localhost on whatever port we're on.
|
|
60
|
+
function isHostAllowed(req, port) {
|
|
61
|
+
const host = req.headers.host;
|
|
62
|
+
if (!host) return false;
|
|
63
|
+
const expected = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
64
|
+
return expected.includes(host);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Validate Origin header for non-GET requests (REQ SEC-02).
|
|
68
|
+
// Same-origin (no Origin header on same-page fetch) or matching scheme+host+port.
|
|
69
|
+
function isOriginAllowed(req, port) {
|
|
70
|
+
const origin = req.headers.origin;
|
|
71
|
+
if (!origin) return true; // same-origin fetch may omit Origin
|
|
72
|
+
const expected = [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
|
|
73
|
+
return expected.includes(origin);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function send(res, status, headers, body) {
|
|
77
|
+
res.writeHead(status, { ...headers });
|
|
78
|
+
if (body !== undefined) res.end(body);
|
|
79
|
+
else res.end();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sendJson(res, status, obj) {
|
|
83
|
+
send(res, status, { 'Content-Type': 'application/json; charset=utf-8' }, JSON.stringify(obj));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Serialize an event into a single SSE message (with id for Last-Event-ID hint).
|
|
87
|
+
function formatSseMessage(event, seq) {
|
|
88
|
+
const payload = JSON.stringify(event);
|
|
89
|
+
// SSE spec: \r\n is fine but \n is canonical; payload can contain \n which we must split.
|
|
90
|
+
const dataLines = payload.split('\n').map((line) => `data: ${line}`).join('\n');
|
|
91
|
+
return `id: ${seq}\nevent: ${event.type}\n${dataLines}\n\n`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Read a request body up to maxBytes. Resolves with Buffer; rejects on overflow.
|
|
95
|
+
function readBody(req, maxBytes = 64 * 1024) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const chunks = [];
|
|
98
|
+
let size = 0;
|
|
99
|
+
let aborted = false;
|
|
100
|
+
req.on('data', (chunk) => {
|
|
101
|
+
if (aborted) return;
|
|
102
|
+
size += chunk.length;
|
|
103
|
+
if (size > maxBytes) {
|
|
104
|
+
aborted = true;
|
|
105
|
+
// Don't destroy the request — let the caller send a 413 response first.
|
|
106
|
+
// We just stop accumulating; further chunks (and 'end') are ignored.
|
|
107
|
+
const err = new Error(`Request body exceeds ${maxBytes} bytes`);
|
|
108
|
+
err.code = 'EBODYTOOBIG';
|
|
109
|
+
reject(err);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
chunks.push(chunk);
|
|
113
|
+
});
|
|
114
|
+
req.once('end', () => {
|
|
115
|
+
if (aborted) return;
|
|
116
|
+
resolve(Buffer.concat(chunks));
|
|
117
|
+
});
|
|
118
|
+
req.once('error', (err) => {
|
|
119
|
+
if (aborted) return;
|
|
120
|
+
reject(err);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function loadStaticIndex() {
|
|
126
|
+
// src/ui/static/index.html — written in Phase 14. We tolerate it missing in
|
|
127
|
+
// unit tests by serving a placeholder so the server module is testable in isolation.
|
|
128
|
+
try {
|
|
129
|
+
return readFileSync(path.join(STATIC_DIR, 'index.html'), 'utf8');
|
|
130
|
+
} catch {
|
|
131
|
+
return `<!doctype html><meta charset="utf-8"><title>kit-mcp sidecar</title>
|
|
132
|
+
<body><pre>UI not yet packaged. Run \`kit ui\` after Phase 14 is shipped.</pre></body>`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createServer({
|
|
137
|
+
projectRoot,
|
|
138
|
+
version = null,
|
|
139
|
+
idleMs = DEFAULT_IDLE_MS,
|
|
140
|
+
maxSubscribers = MAX_SSE_SUBSCRIBERS,
|
|
141
|
+
ringSize = RING_BUFFER_SIZE,
|
|
142
|
+
staticHtml,
|
|
143
|
+
} = {}) {
|
|
144
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
145
|
+
throw new TypeError('createServer requires projectRoot: string');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const bus = new EventEmitter();
|
|
149
|
+
bus.setMaxListeners(maxSubscribers + 4);
|
|
150
|
+
|
|
151
|
+
// Ring buffer for hydrate-on-load
|
|
152
|
+
const ring = [];
|
|
153
|
+
let nextSeq = 1;
|
|
154
|
+
function pushEvent(evt) {
|
|
155
|
+
evt._seq = nextSeq++;
|
|
156
|
+
ring.push(evt);
|
|
157
|
+
if (ring.length > ringSize) ring.shift();
|
|
158
|
+
bus.emit('event', evt);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const subscribers = new Set();
|
|
162
|
+
const activeSockets = new Set();
|
|
163
|
+
let server = null;
|
|
164
|
+
let listeningPort = 0;
|
|
165
|
+
let lockMeta = null;
|
|
166
|
+
let idleTimer = null;
|
|
167
|
+
let lastEventTs = Date.now();
|
|
168
|
+
let shuttingDown = false;
|
|
169
|
+
let signalHandlers = null;
|
|
170
|
+
const startedAt = Date.now();
|
|
171
|
+
|
|
172
|
+
function resetIdleTimer() {
|
|
173
|
+
if (idleMs <= 0) return;
|
|
174
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
175
|
+
idleTimer = setTimeout(() => {
|
|
176
|
+
// Only auto-shutdown if no subscribers AND no recent events
|
|
177
|
+
if (subscribers.size === 0) {
|
|
178
|
+
logErr('[kit-mcp ui] idle shutdown after', Math.round(idleMs / 1000), 's');
|
|
179
|
+
// eslint-disable-next-line no-use-before-define
|
|
180
|
+
shutdown('idle').catch((err) => logErr('idle shutdown error:', err.message));
|
|
181
|
+
} else {
|
|
182
|
+
// Subscribers connected — push idle timer forward
|
|
183
|
+
resetIdleTimer();
|
|
184
|
+
}
|
|
185
|
+
}, idleMs);
|
|
186
|
+
// Don't keep event loop alive just for the idle timer
|
|
187
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function shutdown(reason = 'sigterm') {
|
|
191
|
+
if (shuttingDown) return;
|
|
192
|
+
shuttingDown = true;
|
|
193
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
194
|
+
|
|
195
|
+
// Notify subscribers
|
|
196
|
+
const final = makeEvent({ type: 'shutdown', payload: { reason } });
|
|
197
|
+
pushEvent(final);
|
|
198
|
+
|
|
199
|
+
// Drain SSE
|
|
200
|
+
for (const sub of subscribers) {
|
|
201
|
+
try { sub.res.end(); } catch { /* noop */ }
|
|
202
|
+
}
|
|
203
|
+
subscribers.clear();
|
|
204
|
+
|
|
205
|
+
// Stop accepting new connections AND destroy lingering sockets so close() resolves quickly.
|
|
206
|
+
if (server) {
|
|
207
|
+
for (const sock of activeSockets) {
|
|
208
|
+
try { sock.destroy(); } catch { /* noop */ }
|
|
209
|
+
}
|
|
210
|
+
activeSockets.clear();
|
|
211
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
212
|
+
server = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Detach signal handlers so test harnesses don't accumulate listeners.
|
|
216
|
+
if (signalHandlers) {
|
|
217
|
+
try { process.removeListener('SIGINT', signalHandlers.sigint); } catch { /* noop */ }
|
|
218
|
+
try { process.removeListener('SIGTERM', signalHandlers.sigterm); } catch { /* noop */ }
|
|
219
|
+
signalHandlers = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Release lockfile
|
|
223
|
+
if (lockMeta) {
|
|
224
|
+
try { releaseLock(projectRoot); } catch { /* noop */ }
|
|
225
|
+
lockMeta = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function handleEvents(req, res) {
|
|
230
|
+
if (subscribers.size >= maxSubscribers) {
|
|
231
|
+
sendJson(res, 503, { error: 'too_many_subscribers', max: maxSubscribers });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
res.writeHead(200, SSE_HEADERS);
|
|
236
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
237
|
+
|
|
238
|
+
// Optional retry hint for the browser EventSource (3s)
|
|
239
|
+
res.write('retry: 3000\n\n');
|
|
240
|
+
|
|
241
|
+
const sub = { req, res };
|
|
242
|
+
subscribers.add(sub);
|
|
243
|
+
|
|
244
|
+
const onEvent = (evt) => {
|
|
245
|
+
try {
|
|
246
|
+
res.write(formatSseMessage(evt, evt._seq ?? 0));
|
|
247
|
+
} catch {
|
|
248
|
+
cleanup();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
bus.on('event', onEvent);
|
|
252
|
+
|
|
253
|
+
const heartbeat = setInterval(() => {
|
|
254
|
+
try { res.write(`: ping ${Date.now()}\n\n`); } catch { cleanup(); }
|
|
255
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
256
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref();
|
|
257
|
+
|
|
258
|
+
function cleanup() {
|
|
259
|
+
if (!subscribers.has(sub)) return;
|
|
260
|
+
subscribers.delete(sub);
|
|
261
|
+
clearInterval(heartbeat);
|
|
262
|
+
bus.off('event', onEvent);
|
|
263
|
+
try { res.end(); } catch { /* noop */ }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
req.on('close', cleanup);
|
|
267
|
+
req.on('error', cleanup);
|
|
268
|
+
res.on('close', cleanup);
|
|
269
|
+
res.on('error', cleanup);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function handlePublish(req, res) {
|
|
273
|
+
if (!isOriginAllowed(req, listeningPort)) {
|
|
274
|
+
sendJson(res, 403, { error: 'origin_not_allowed' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
let body;
|
|
278
|
+
try {
|
|
279
|
+
body = await readBody(req, 64 * 1024);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
const status = err.code === 'EBODYTOOBIG' ? 413 : 400;
|
|
282
|
+
sendJson(res, status, { error: err.message });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
let parsed;
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(body.toString('utf8'));
|
|
288
|
+
} catch (err) {
|
|
289
|
+
sendJson(res, 400, { error: `invalid_json: ${err.message}` });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const validationErr = validateEvent(parsed);
|
|
293
|
+
if (validationErr) {
|
|
294
|
+
sendJson(res, 400, { error: validationErr.message });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
pushEvent(parsed);
|
|
298
|
+
lastEventTs = Date.now();
|
|
299
|
+
resetIdleTimer();
|
|
300
|
+
sendJson(res, 202, { ok: true, seq: parsed._seq });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function handleHealthz(res) {
|
|
304
|
+
sendJson(res, 200, {
|
|
305
|
+
ok: true,
|
|
306
|
+
version,
|
|
307
|
+
uptime: Date.now() - startedAt,
|
|
308
|
+
port: listeningPort,
|
|
309
|
+
subscribers: subscribers.size,
|
|
310
|
+
eventsTotal: nextSeq - 1,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function handleState(res) {
|
|
315
|
+
sendJson(res, 200, {
|
|
316
|
+
version,
|
|
317
|
+
port: listeningPort,
|
|
318
|
+
eventsTotal: nextSeq - 1,
|
|
319
|
+
events: ring.slice(),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function handleShutdownRequest(res) {
|
|
324
|
+
sendJson(res, 200, { ok: true, draining: true });
|
|
325
|
+
setImmediate(() => {
|
|
326
|
+
shutdown('explicit').catch((err) => logErr('shutdown error:', err.message));
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function handleIndex(res) {
|
|
331
|
+
const html = staticHtml ?? loadStaticIndex();
|
|
332
|
+
res.writeHead(200, {
|
|
333
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
334
|
+
'Content-Security-Policy': CSP,
|
|
335
|
+
'X-Content-Type-Options': 'nosniff',
|
|
336
|
+
'Referrer-Policy': 'no-referrer',
|
|
337
|
+
});
|
|
338
|
+
res.end(html);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function handleRequest(req, res) {
|
|
342
|
+
if (!isHostAllowed(req, listeningPort)) {
|
|
343
|
+
sendJson(res, 403, { error: 'host_not_allowed', expected: ['127.0.0.1', 'localhost'] });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const url = new URL(req.url, `http://${HOST}:${listeningPort}`);
|
|
347
|
+
const route = `${req.method} ${url.pathname}`;
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
switch (route) {
|
|
351
|
+
case 'GET /':
|
|
352
|
+
case 'GET /index.html':
|
|
353
|
+
return handleIndex(res);
|
|
354
|
+
case 'GET /events':
|
|
355
|
+
return handleEvents(req, res);
|
|
356
|
+
case 'GET /healthz':
|
|
357
|
+
return handleHealthz(res);
|
|
358
|
+
case 'GET /state':
|
|
359
|
+
return handleState(res);
|
|
360
|
+
case 'POST /publish':
|
|
361
|
+
return handlePublish(req, res);
|
|
362
|
+
case 'POST /shutdown':
|
|
363
|
+
return handleShutdownRequest(res);
|
|
364
|
+
default:
|
|
365
|
+
return sendJson(res, 404, { error: 'not_found', route });
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
logErr('handler error:', err.message);
|
|
369
|
+
try { sendJson(res, 500, { error: 'internal_error' }); } catch { /* noop */ }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function start({ port } = {}) {
|
|
374
|
+
listeningPort = port ?? (await findFreePortOrThrow());
|
|
375
|
+
lockMeta = await acquireLockOrReclaim({
|
|
376
|
+
projectRoot,
|
|
377
|
+
port: listeningPort,
|
|
378
|
+
version,
|
|
379
|
+
startedAt,
|
|
380
|
+
});
|
|
381
|
+
server = http.createServer(handleRequest);
|
|
382
|
+
server.on('connection', (sock) => {
|
|
383
|
+
activeSockets.add(sock);
|
|
384
|
+
sock.on('close', () => activeSockets.delete(sock));
|
|
385
|
+
});
|
|
386
|
+
await new Promise((resolve, reject) => {
|
|
387
|
+
server.once('error', reject);
|
|
388
|
+
server.listen(listeningPort, HOST, () => {
|
|
389
|
+
server.removeListener('error', reject);
|
|
390
|
+
resolve();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
resetIdleTimer();
|
|
394
|
+
|
|
395
|
+
// Graceful shutdown handlers (REQ SRV-11). Stored so we can detach in shutdown().
|
|
396
|
+
const sigint = () => {
|
|
397
|
+
logErr('[kit-mcp ui] received SIGINT, shutting down');
|
|
398
|
+
shutdown('SIGINT').catch((err) => logErr('shutdown error:', err.message));
|
|
399
|
+
};
|
|
400
|
+
const sigterm = () => {
|
|
401
|
+
logErr('[kit-mcp ui] received SIGTERM, shutting down');
|
|
402
|
+
shutdown('SIGTERM').catch((err) => logErr('shutdown error:', err.message));
|
|
403
|
+
};
|
|
404
|
+
signalHandlers = { sigint, sigterm };
|
|
405
|
+
process.on('SIGINT', sigint);
|
|
406
|
+
process.on('SIGTERM', sigterm);
|
|
407
|
+
|
|
408
|
+
// run.start event
|
|
409
|
+
pushEvent(makeEvent({ type: 'run.start', payload: { server: 'sidecar', version, port: listeningPort } }));
|
|
410
|
+
|
|
411
|
+
return { port: listeningPort, lockMeta };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
start,
|
|
416
|
+
shutdown,
|
|
417
|
+
pushEvent, // for tests
|
|
418
|
+
get url() { return `http://${HOST}:${listeningPort}/`; },
|
|
419
|
+
get port() { return listeningPort; },
|
|
420
|
+
get subscriberCount() { return subscribers.size; },
|
|
421
|
+
get eventsTotal() { return nextSeq - 1; },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export const __test = {
|
|
426
|
+
RING_BUFFER_SIZE,
|
|
427
|
+
MAX_SSE_SUBSCRIBERS,
|
|
428
|
+
DEFAULT_IDLE_MS,
|
|
429
|
+
HEARTBEAT_INTERVAL_MS,
|
|
430
|
+
CSP,
|
|
431
|
+
EVENT_TYPES,
|
|
432
|
+
};
|