@rdfc/js-runner 3.0.3 → 3.0.4-remote

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 (45) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/README.md +37 -1
  3. package/bin/runner.js +7 -1
  4. package/bin/server.js +13 -0
  5. package/examples/echo/.idea/echo.iml +9 -0
  6. package/examples/echo/.idea/misc.xml +6 -0
  7. package/examples/echo/.idea/modules.xml +8 -0
  8. package/examples/echo/.idea/vcs.xml +7 -0
  9. package/examples/echo/.swls/config.json +1 -0
  10. package/examples/echo/package-lock.json +27 -29
  11. package/examples/echo/pipeline.ttl +0 -1
  12. package/examples/echo/processors.ttl +1 -1
  13. package/examples/echo/remote_pipeline.ttl +18 -0
  14. package/examples/echo/server.ttl +5 -0
  15. package/examples/echo/untitled:/types/MyType.ttl +0 -0
  16. package/file:/home/silvius/Projects/mumo-pipeline/ldes/http_3A_2F_2Fdata.mumo.be_2Fstreams_2Fnodes_2Fdefault/root/index.trig +3 -0
  17. package/ldes/http_3A_2F_2Fdata.mumo.be_2Fstreams_2Fnodes_2Fdefault/root/index.trig +3 -0
  18. package/lib/client.d.ts +2 -1
  19. package/lib/client.js +70 -22
  20. package/lib/index.d.ts +2 -0
  21. package/lib/index.js +3 -1
  22. package/lib/jsonld.d.ts +17 -0
  23. package/lib/jsonld.js +135 -0
  24. package/lib/reader.d.ts +4 -1
  25. package/lib/reader.js +11 -3
  26. package/lib/runner.d.ts +6 -1
  27. package/lib/runner.js +43 -15
  28. package/lib/server.d.ts +9 -0
  29. package/lib/server.js +459 -0
  30. package/lib/state.d.ts +32 -0
  31. package/lib/state.js +71 -0
  32. package/lib/testUtils.d.ts +24 -0
  33. package/lib/testUtils.js +150 -0
  34. package/lib/tsconfig.tsbuildinfo +1 -1
  35. package/lib/writer.d.ts +5 -1
  36. package/lib/writer.js +26 -10
  37. package/minimal.ttl +99 -0
  38. package/package.json +12 -11
  39. package/src/client.ts +99 -24
  40. package/src/index.ts +2 -0
  41. package/src/reader.ts +11 -1
  42. package/src/runner.ts +58 -11
  43. package/src/server.ts +545 -0
  44. package/src/state.ts +105 -0
  45. package/src/writer.ts +36 -12
package/src/server.ts ADDED
@@ -0,0 +1,545 @@
1
+ import { createServer, IncomingMessage } from 'node:http'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { resolve, relative } from 'node:path'
4
+ import { Parser } from 'n3'
5
+ import { extractShapes } from 'rdf-lens'
6
+ import { start } from './client.js'
7
+ import { State } from './state.js'
8
+
9
+ const RDFC = 'https://w3id.org/rdf-connect#'
10
+ const OWL_IMPORTS = 'http://www.w3.org/2002/07/owl#imports'
11
+ const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'
12
+ const RDFS_LABEL = 'http://www.w3.org/2000/01/rdf-schema#label'
13
+ const RDFS_COMMENT = 'http://www.w3.org/2000/01/rdf-schema#comment'
14
+
15
+ // SHACL shape for rdfc:JsRunnerServer config files
16
+ const CONFIG_SHAPE_TTL = `
17
+ @prefix rdfc: <https://w3id.org/rdf-connect#>.
18
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
19
+ @prefix sh: <http://www.w3.org/ns/shacl#>.
20
+
21
+ [] a sh:NodeShape;
22
+ sh:targetClass rdfc:JsRunnerServer;
23
+ sh:property [
24
+ sh:path rdfc:port;
25
+ sh:name "port";
26
+ sh:maxCount 1;
27
+ sh:datatype xsd:integer;
28
+ ], [
29
+ sh:path rdfc:processorConfig;
30
+ sh:name "processorConfigs";
31
+ sh:datatype xsd:string;
32
+ ].
33
+ `
34
+
35
+ const { lenses } = extractShapes(new Parser().parse(CONFIG_SHAPE_TTL))
36
+
37
+ interface ServerConfig {
38
+ port: number
39
+ processorPaths: string[]
40
+ }
41
+
42
+ interface ProcessorDescription {
43
+ uri: string
44
+ label?: string
45
+ comment?: string
46
+ sourceFile: string
47
+ }
48
+
49
+ function readBody(req: IncomingMessage): Promise<string> {
50
+ return new Promise((resolve, reject) => {
51
+ let body = ''
52
+ req.on('data', (chunk) => (body += chunk))
53
+ req.on('end', () => resolve(body))
54
+ req.on('error', reject)
55
+ })
56
+ }
57
+
58
+ export async function parseServerConfig(
59
+ configPath: string,
60
+ ): Promise<ServerConfig> {
61
+ const absConfig = resolve(configPath)
62
+ const content = await readFile(absConfig, { encoding: 'utf8' })
63
+ const quads = new Parser({ baseIRI: 'file://' + absConfig }).parse(content)
64
+
65
+ const serverSubject = quads.find(
66
+ (q) =>
67
+ q.predicate.value === RDF_TYPE &&
68
+ q.object.value === RDFC + 'JsRunnerServer',
69
+ )?.subject
70
+
71
+ if (!serverSubject) {
72
+ throw new Error(`No rdfc:JsRunnerServer found in ${absConfig}`)
73
+ }
74
+
75
+ const config = lenses[RDFC + 'JsRunnerServer'].execute({
76
+ id: serverSubject,
77
+ quads,
78
+ }) as { port?: number; processorConfigs?: string[] }
79
+
80
+ const port = config.port ?? 3000
81
+ const processorPaths = (config.processorConfigs ?? []).map((val) =>
82
+ val.startsWith('file://') ? val.slice('file://'.length) : val,
83
+ )
84
+
85
+ return { port, processorPaths }
86
+ }
87
+
88
+ export async function buildWhitelist(
89
+ processorPaths: string[],
90
+ ): Promise<Set<string>> {
91
+ const whitelist = new Set<string>()
92
+ const done = new Set<string>()
93
+ const todo: string[] = [...processorPaths]
94
+
95
+ while (todo.length > 0) {
96
+ const filePath = todo.pop()!
97
+ if (done.has(filePath)) continue
98
+ done.add(filePath)
99
+ whitelist.add(filePath)
100
+
101
+ let content: string
102
+ try {
103
+ content = await readFile(filePath, { encoding: 'utf8' })
104
+ } catch {
105
+ continue
106
+ }
107
+
108
+ const baseIRI = 'file://' + filePath
109
+ const quads = new Parser({ baseIRI }).parse(content)
110
+
111
+ for (const quad of quads) {
112
+ if (
113
+ quad.subject.value === baseIRI &&
114
+ quad.predicate.value === OWL_IMPORTS
115
+ ) {
116
+ const importVal = quad.object.value
117
+ if (importVal.startsWith('file://')) {
118
+ todo.push(importVal.slice('file://'.length))
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ return whitelist
125
+ }
126
+
127
+ async function extractProcessorDescriptions(
128
+ processorPaths: string[],
129
+ ): Promise<ProcessorDescription[]> {
130
+ const descriptions: ProcessorDescription[] = []
131
+
132
+ for (const filePath of processorPaths) {
133
+ let content: string
134
+ try {
135
+ content = await readFile(filePath, { encoding: 'utf8' })
136
+ } catch {
137
+ continue
138
+ }
139
+
140
+ const baseIRI = 'file://' + filePath
141
+ const quads = new Parser({ baseIRI }).parse(content)
142
+
143
+ const seen = new Set<string>()
144
+ for (const quad of quads) {
145
+ if (quad.predicate.value !== RDFC + 'jsImplementationOf') continue
146
+ const uri = quad.subject.value
147
+ if (seen.has(uri)) continue
148
+ seen.add(uri)
149
+
150
+ const labelQuad = quads.find(
151
+ (q) => q.subject.value === uri && q.predicate.value === RDFS_LABEL,
152
+ )
153
+ const commentQuad = quads.find(
154
+ (q) => q.subject.value === uri && q.predicate.value === RDFS_COMMENT,
155
+ )
156
+
157
+ descriptions.push({
158
+ uri,
159
+ label: labelQuad?.object.value,
160
+ comment: commentQuad?.object.value,
161
+ sourceFile: filePath,
162
+ })
163
+ }
164
+ }
165
+
166
+ return descriptions
167
+ }
168
+
169
+ export async function generateIndexTtl(
170
+ processorPaths: string[],
171
+ cwd: string,
172
+ ): Promise<string> {
173
+ const descriptions = await extractProcessorDescriptions(processorPaths)
174
+
175
+ const lines = `
176
+ @prefix prov: <http://www.w3.org/ns/prov#>.
177
+ @prefix sds: <https://w3id.org/sds#>.
178
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
179
+ @prefix owl: <http://www.w3.org/2002/07/owl#>.
180
+ @prefix rdfl: <https://w3id.org/rdf-lens/ontology#>.
181
+ @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>.
182
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
183
+ @prefix sh: <http://www.w3.org/ns/shacl#>.
184
+ @prefix rdfc: <https://w3id.org/rdf-connect#>.
185
+
186
+ sds:Activity rdfs:subClassOf prov:Activity.
187
+ rdfc:Processor rdfs:subClassOf sds:Activity.
188
+ sds:implementationOf rdfs:subPropertyOf rdfs:subClassOf.
189
+ rdfc:jsImplementationOf rdfs:subPropertyOf sds:implementationOf.
190
+ [ ] a sh:NodeShape;
191
+ sh:targetSubjectsOf rdfc:jsImplementationOf;
192
+ sh:property [
193
+ sh:path rdfc:entrypoint;
194
+ sh:name "location";
195
+ sh:minCount 1;
196
+ sh:maxCount 1;
197
+ sh:datatype xsd:string;
198
+ ], [
199
+ sh:path rdfc:file;
200
+ sh:name "file";
201
+ sh:minCount 1;
202
+ sh:maxCount 1;
203
+ sh:datatype xsd:string;
204
+ ], [
205
+ sh:path rdfc:class;
206
+ sh:name "clazz";
207
+ sh:maxCount 1;
208
+ sh:datatype xsd:string;
209
+ ].
210
+
211
+ <runner> a rdfc:HttpRunner;
212
+ rdfc:handlesSubjectsOf rdfc:jsImplementationOf;
213
+ rdfc:endpoint <./connect>.
214
+ `.split('\n')
215
+
216
+ for (const desc of descriptions) {
217
+ const relPath = relative(cwd, desc.sourceFile)
218
+ lines.push(`<${desc.uri}> a rdfc:Processor;`)
219
+ if (desc.label) {
220
+ lines.push(
221
+ ` rdfs:label "${desc.label.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}";`,
222
+ )
223
+ }
224
+ if (desc.comment) {
225
+ lines.push(
226
+ ` rdfs:comment "${desc.comment.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}";`,
227
+ )
228
+ }
229
+ lines.push(` rdfs:isDefinedBy <${relPath}>.`)
230
+ lines.push('')
231
+ }
232
+
233
+ return lines.join('\n')
234
+ }
235
+
236
+ function dashboardHtml(): string {
237
+ return `<!DOCTYPE html>
238
+ <html lang="en">
239
+ <head>
240
+ <meta charset="UTF-8">
241
+ <title>js-runner dashboard</title>
242
+ <style>
243
+ * { box-sizing: border-box; margin: 0; padding: 0; }
244
+ body { font-family: 'Courier New', monospace; background: #111; color: #ccc; padding: 1.5rem; }
245
+ h1 { color: #7ec8e3; margin-bottom: 1rem; font-size: 1.4rem; }
246
+ #updated { font-size: 0.75rem; color: #555; margin-bottom: 1.5rem; }
247
+ .runner { border: 1px solid #333; border-radius: 6px; padding: 1rem; margin-bottom: 1.25rem; }
248
+ .runner-meta { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: 0.75rem; font-size: 0.85rem; }
249
+ .runner-meta span { color: #888; }
250
+ .runner-meta strong { color: #aaa; }
251
+ .status { font-weight: bold; }
252
+ .s-connecting { color: #f0a500; }
253
+ .s-running { color: #4caf50; }
254
+ .s-done { color: #666; }
255
+ .s-error { color: #f44336; }
256
+ .g-IDLE { color: #888; }
257
+ .g-CONNECTING { color: #f0a500; }
258
+ .g-READY { color: #4caf50; }
259
+ .g-TRANSIENT_FAILURE { color: #f44336; }
260
+ .g-SHUTDOWN { color: #666; }
261
+ table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
262
+ th, td { border: 1px solid #2a2a2a; padding: 0.4rem 0.8rem; text-align: left; white-space: nowrap; }
263
+ th { background: #1a1a1a; color: #7ec8e3; }
264
+ td.uri { color: #aaa; max-width: 28rem; overflow: hidden; text-overflow: ellipsis; }
265
+ td.role-reader { color: #81c784; }
266
+ td.role-writer { color: #64b5f6; }
267
+ .empty { color: #555; font-size: 0.8rem; padding-top: 0.5rem; }
268
+ .no-runners { color: #555; }
269
+ </style>
270
+ </head>
271
+ <body>
272
+ <h1>js-runner dashboard</h1>
273
+ <div id="updated"></div>
274
+ <div id="content"><p class="no-runners">Loading…</p></div>
275
+ <script>
276
+ const throughputState = {}
277
+
278
+ function esc(s) {
279
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
280
+ }
281
+ function fmtBytes(b) {
282
+ if (b < 1024) return b + ' B'
283
+ if (b < 1048576) return (b/1024).toFixed(1) + ' KB'
284
+ return (b/1048576).toFixed(1) + ' MB'
285
+ }
286
+ function fmtAge(ms) {
287
+ if (!ms) return '-'
288
+ const s = Math.floor((Date.now() - ms) / 1000)
289
+ if (s < 5) return 'just now'
290
+ if (s < 60) return s + 's ago'
291
+ if (s < 3600) return Math.floor(s/60) + 'm ago'
292
+ return Math.floor(s/3600) + 'h ago'
293
+ }
294
+ function fmtDuration(ms) {
295
+ const s = Math.floor((Date.now() - ms) / 1000)
296
+ if (s < 60) return s + 's'
297
+ if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's'
298
+ return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm'
299
+ }
300
+ function pct(arr, p) {
301
+ if (!arr || !arr.length) return '-'
302
+ const s = [...arr].sort((a,b) => a-b)
303
+ return s[Math.floor(p/100*s.length)] + 'ms'
304
+ }
305
+ function avg(arr) {
306
+ if (!arr || !arr.length) return '-'
307
+ return (arr.reduce((a,b)=>a+b,0)/arr.length).toFixed(1) + 'ms'
308
+ }
309
+ function throughput(key, count) {
310
+ const now = Date.now()
311
+ if (!throughputState[key]) { throughputState[key] = {count, time: now}; return '-' }
312
+ const dt = (now - throughputState[key].time) / 1000
313
+ const dc = count - throughputState[key].count
314
+ throughputState[key] = {count, time: now}
315
+ if (dt < 0.1) return '-'
316
+ return (dc / dt).toFixed(1) + '/s'
317
+ }
318
+
319
+ function renderChannels(runnerId, channels) {
320
+ const entries = Object.values(channels)
321
+ if (!entries.length) return '<p class="empty">No channels yet.</p>'
322
+ return \`<table>
323
+ <thead>
324
+ <tr>
325
+ <th>Channel URI</th>
326
+ <th>Role</th>
327
+ <th>Messages</th>
328
+ <th>Throughput</th>
329
+ <th>Bytes</th>
330
+ <th>Last msg</th>
331
+ <th>Avg latency</th>
332
+ <th>p50</th>
333
+ <th>p99</th>
334
+ </tr>
335
+ </thead>
336
+ <tbody>
337
+ \${entries.map(ch => {
338
+ const key = runnerId + ':' + ch.uri
339
+ const tp = throughput(key, ch.messageCount)
340
+ return \`<tr>
341
+ <td class="uri" title="\${esc(ch.uri)}">\${esc(ch.uri)}</td>
342
+ <td class="role-\${ch.role}">\${ch.role}</td>
343
+ <td>\${ch.messageCount}</td>
344
+ <td>\${tp}</td>
345
+ <td>\${fmtBytes(ch.bytesTotal)}</td>
346
+ <td>\${fmtAge(ch.lastMessageAt)}</td>
347
+ <td>\${ch.role === 'writer' ? avg(ch.latenciesMs) : '-'}</td>
348
+ <td>\${ch.role === 'writer' ? pct(ch.latenciesMs, 50) : '-'}</td>
349
+ <td>\${ch.role === 'writer' ? pct(ch.latenciesMs, 99) : '-'}</td>
350
+ </tr>\`
351
+ }).join('')}
352
+ </tbody>
353
+ </table>\`
354
+ }
355
+
356
+ function render(runners) {
357
+ const el = document.getElementById('content')
358
+ if (!runners.length) {
359
+ el.innerHTML = '<p class="no-runners">No runners registered yet.</p>'
360
+ return
361
+ }
362
+ el.innerHTML = runners.map(r => \`
363
+ <div class="runner">
364
+ <div class="runner-meta">
365
+ <span><strong>URI:</strong> \${esc(r.uri)}</span>
366
+ <span><strong>Host:</strong> \${esc(r.host)}</span>
367
+ <span><strong>Status:</strong> <span class="status s-\${r.status}">\${r.status}</span></span>
368
+ <span><strong>gRPC:</strong> <span class="g-\${r.grpcState}">\${r.grpcState}</span></span>
369
+ <span><strong>Uptime:</strong> \${fmtDuration(r.connectedAt)}</span>
370
+ </div>
371
+ \${renderChannels(r.id, r.channels)}
372
+ </div>
373
+ \`).join('')
374
+ }
375
+
376
+ async function refresh() {
377
+ try {
378
+ const res = await fetch('/api/state')
379
+ const data = await res.json()
380
+ render(data)
381
+ document.getElementById('updated').textContent =
382
+ 'Last updated: ' + new Date().toLocaleTimeString()
383
+ } catch (e) {
384
+ document.getElementById('content').innerHTML =
385
+ '<p style="color:#f44336">Failed to fetch state: ' + esc(e.message) + '</p>'
386
+ }
387
+ }
388
+
389
+ refresh()
390
+ setInterval(refresh, 2000)
391
+ </script>
392
+ </body>
393
+ </html>`
394
+ }
395
+
396
+ export async function serve(configPath: string): Promise<void> {
397
+ const absConfig = resolve(configPath)
398
+ const { port, processorPaths } = await parseServerConfig(absConfig)
399
+ const whitelist = await buildWhitelist(processorPaths)
400
+ const cwd = process.cwd()
401
+ const indexTtl = await generateIndexTtl(processorPaths, cwd)
402
+ const state = new State()
403
+
404
+ const activeConnections = new Set<AbortController>()
405
+
406
+ const shutdown = (signal: string) => {
407
+ console.log(
408
+ `\nReceived ${signal}, closing ${activeConnections.size} active gRPC connection(s)...`,
409
+ )
410
+ for (const ctrl of activeConnections) ctrl.abort()
411
+ server.close(() => process.exit(0))
412
+ }
413
+
414
+ process.on('SIGINT', () => shutdown('SIGINT'))
415
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
416
+
417
+ const server = createServer(async (req, res) => {
418
+ const method = req.method ?? 'GET'
419
+ const url = req.url ?? '/'
420
+
421
+ // --- Health check ---
422
+ if (method === 'GET' && url === '/health') {
423
+ res.writeHead(200, { 'Content-Type': 'application/json' })
424
+ res.end(
425
+ JSON.stringify({
426
+ status: 'ok',
427
+ runners: state.snapshot().length,
428
+ activeConnections: activeConnections.size,
429
+ }),
430
+ )
431
+ return
432
+ }
433
+
434
+ // --- State API (JSON) ---
435
+ if (method === 'GET' && url === '/api/state') {
436
+ res.writeHead(200, {
437
+ 'Content-Type': 'application/json',
438
+ 'Cache-Control': 'no-store',
439
+ })
440
+ res.end(JSON.stringify(state.snapshot()))
441
+ return
442
+ }
443
+
444
+ // --- Dashboard (HTML) ---
445
+ if (method === 'GET' && url === '/dashboard') {
446
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
447
+ res.end(dashboardHtml())
448
+ return
449
+ }
450
+
451
+ // --- Index Turtle ---
452
+ if (method === 'GET' && url === '/') {
453
+ res.writeHead(200, { 'Content-Type': 'text/turtle' })
454
+ res.end(indexTtl)
455
+ return
456
+ }
457
+
458
+ // --- Whitelisted processor files ---
459
+ if (method === 'GET') {
460
+ const reqPath = url.startsWith('/') ? url.slice(1) : url
461
+ const absPath = resolve(cwd, reqPath)
462
+
463
+ if (!whitelist.has(absPath)) {
464
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
465
+ res.end('Forbidden')
466
+ return
467
+ }
468
+
469
+ let content: string
470
+ try {
471
+ content = await readFile(absPath, { encoding: 'utf8' })
472
+ } catch {
473
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
474
+ res.end('Not found')
475
+ return
476
+ }
477
+
478
+ res.writeHead(200, { 'Content-Type': 'text/turtle' })
479
+ res.end(content)
480
+ return
481
+ }
482
+
483
+ // --- Connect: instantiate a new runner ---
484
+ if (method === 'POST' && url === '/connect') {
485
+ let body: string
486
+ try {
487
+ body = await readBody(req)
488
+ } catch {
489
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
490
+ res.end('Failed to read request body')
491
+ return
492
+ }
493
+
494
+ let parsed: { host?: string; uri?: string }
495
+ try {
496
+ parsed = JSON.parse(body)
497
+ } catch {
498
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
499
+ res.end('Invalid JSON')
500
+ return
501
+ }
502
+
503
+ const { host, uri } = parsed
504
+ if (typeof host !== 'string' || typeof uri !== 'string') {
505
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
506
+ res.end('Missing host or uri')
507
+ return
508
+ }
509
+
510
+ const runnerId = state.registerRunner(host, uri)
511
+ const ctrl = new AbortController()
512
+ activeConnections.add(ctrl)
513
+
514
+ start(host, uri, absConfig, ctrl.signal, state, runnerId)
515
+ .catch((err) => {
516
+ console.error('gRPC connection error:', err)
517
+ state.markError(runnerId)
518
+ })
519
+ .finally(() => {
520
+ activeConnections.delete(ctrl)
521
+ state.deregisterRunner(runnerId)
522
+ })
523
+
524
+ res.writeHead(202, {
525
+ 'Content-Type': 'application/json',
526
+ Location: '/dashboard',
527
+ })
528
+ res.end(JSON.stringify({ runnerId }))
529
+ return
530
+ }
531
+
532
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
533
+ res.end('Not found')
534
+ })
535
+
536
+ await new Promise<void>((resolve) => {
537
+ server.listen(port, () => {
538
+ console.log(`js-runner server listening on port ${port}`)
539
+ console.log(` Dashboard: http://localhost:${port}/dashboard`)
540
+ console.log(` Health: http://localhost:${port}/health`)
541
+ console.log(` State API: http://localhost:${port}/api/state`)
542
+ resolve()
543
+ })
544
+ })
545
+ }
package/src/state.ts ADDED
@@ -0,0 +1,105 @@
1
+ export type RunnerStatus = 'connecting' | 'running' | 'done' | 'error'
2
+
3
+ export interface ChannelStats {
4
+ uri: string
5
+ role: 'reader' | 'writer'
6
+ messageCount: number
7
+ bytesTotal: number
8
+ lastMessageAt: number | null
9
+ /** Last N round-trip latencies in ms (writers only) */
10
+ latenciesMs: number[]
11
+ }
12
+
13
+ export interface RunnerStats {
14
+ id: string
15
+ host: string
16
+ uri: string
17
+ connectedAt: number
18
+ status: RunnerStatus
19
+ grpcState: string
20
+ channels: Record<string, ChannelStats>
21
+ }
22
+
23
+ export interface ChannelTracker {
24
+ recordMessage(bytes: number, latencyMs?: number): void
25
+ }
26
+
27
+ const MAX_LATENCY_SAMPLES = 100
28
+
29
+ export class State {
30
+ private readonly runners: Map<string, RunnerStats> = new Map()
31
+ private nextId = 1
32
+
33
+ registerRunner(host: string, uri: string): string {
34
+ const id = String(this.nextId++)
35
+ this.runners.set(id, {
36
+ id,
37
+ host,
38
+ uri,
39
+ connectedAt: Date.now(),
40
+ status: 'connecting',
41
+ grpcState: 'IDLE',
42
+ channels: {},
43
+ })
44
+ return id
45
+ }
46
+
47
+ setStatus(id: string, status: RunnerStatus): void {
48
+ const r = this.runners.get(id)
49
+ if (r) r.status = status
50
+ }
51
+
52
+ setGrpcState(id: string, state: string): void {
53
+ const r = this.runners.get(id)
54
+ if (r) r.grpcState = state
55
+ }
56
+
57
+ deregisterRunner(id: string): void {
58
+ const r = this.runners.get(id)
59
+ if (r && r.status !== 'error') r.status = 'done'
60
+ }
61
+
62
+ markError(id: string): void {
63
+ const r = this.runners.get(id)
64
+ if (r) r.status = 'error'
65
+ }
66
+
67
+ trackChannel(
68
+ runnerId: string,
69
+ uri: string,
70
+ role: 'reader' | 'writer',
71
+ ): ChannelTracker {
72
+ const runner = this.runners.get(runnerId)
73
+ if (!runner) return { recordMessage() {} }
74
+
75
+ if (!runner.channels[uri]) {
76
+ runner.channels[uri] = {
77
+ uri,
78
+ role,
79
+ messageCount: 0,
80
+ bytesTotal: 0,
81
+ lastMessageAt: null,
82
+ latenciesMs: [],
83
+ }
84
+ }
85
+
86
+ const channel = runner.channels[uri]
87
+ return {
88
+ recordMessage(bytes: number, latencyMs?: number): void {
89
+ channel.messageCount++
90
+ channel.bytesTotal += bytes
91
+ channel.lastMessageAt = Date.now()
92
+ if (latencyMs !== undefined) {
93
+ channel.latenciesMs.push(latencyMs)
94
+ if (channel.latenciesMs.length > MAX_LATENCY_SAMPLES) {
95
+ channel.latenciesMs.shift()
96
+ }
97
+ }
98
+ },
99
+ }
100
+ }
101
+
102
+ snapshot(): RunnerStats[] {
103
+ return [...this.runners.values()]
104
+ }
105
+ }