@rdfc/js-runner 3.0.2 → 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 (84) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/README.md +41 -1
  3. package/__tests__/echoProcessor.test.ts +11 -12
  4. package/bin/runner.js +7 -1
  5. package/bin/server.js +13 -0
  6. package/bun.lock +820 -0
  7. package/examples/echo/.idea/echo.iml +9 -0
  8. package/examples/echo/.idea/misc.xml +6 -0
  9. package/{.idea → examples/echo/.idea}/modules.xml +1 -1
  10. package/examples/echo/.idea/vcs.xml +7 -0
  11. package/examples/echo/.swls/config.json +1 -0
  12. package/examples/echo/package-lock.json +27 -29
  13. package/examples/echo/pipeline.ttl +0 -1
  14. package/examples/echo/processors.ttl +1 -1
  15. package/examples/echo/remote_pipeline.ttl +18 -0
  16. package/examples/echo/server.ttl +5 -0
  17. package/examples/echo/untitled:/types/MyType.ttl +0 -0
  18. package/file:/home/silvius/Projects/mumo-pipeline/ldes/http_3A_2F_2Fdata.mumo.be_2Fstreams_2Fnodes_2Fdefault/root/index.trig +3 -0
  19. package/ldes/http_3A_2F_2Fdata.mumo.be_2Fstreams_2Fnodes_2Fdefault/root/index.trig +3 -0
  20. package/lib/client.d.ts +2 -1
  21. package/lib/client.js +70 -22
  22. package/lib/index.d.ts +2 -0
  23. package/lib/index.js +3 -1
  24. package/lib/jsonld.d.ts +17 -0
  25. package/lib/jsonld.js +135 -0
  26. package/lib/logger.d.ts +1 -0
  27. package/lib/logger.js +19 -9
  28. package/lib/reader.d.ts +4 -1
  29. package/lib/reader.js +11 -3
  30. package/lib/runner.d.ts +6 -1
  31. package/lib/runner.js +43 -15
  32. package/lib/server.d.ts +9 -0
  33. package/lib/server.js +459 -0
  34. package/lib/state.d.ts +32 -0
  35. package/lib/state.js +71 -0
  36. package/lib/testUtils/duplex.js +1 -1
  37. package/lib/testUtils/index.d.ts +9 -9
  38. package/lib/testUtils/index.js +9 -9
  39. package/lib/testUtils.d.ts +24 -0
  40. package/lib/testUtils.js +150 -0
  41. package/lib/tsconfig.tsbuildinfo +1 -1
  42. package/lib/writer.d.ts +5 -1
  43. package/lib/writer.js +26 -10
  44. package/minimal.ttl +99 -0
  45. package/package.json +21 -19
  46. package/src/client.ts +99 -24
  47. package/src/index.ts +2 -0
  48. package/src/logger.ts +15 -7
  49. package/src/reader.ts +19 -9
  50. package/src/runner.ts +61 -12
  51. package/src/server.ts +545 -0
  52. package/src/state.ts +105 -0
  53. package/src/testUtils/duplex.ts +1 -4
  54. package/src/testUtils/index.ts +25 -21
  55. package/src/writer.ts +36 -12
  56. package/.idea/LNKD.tech Editor.xml +0 -194
  57. package/.idea/codeStyles/Project.xml +0 -52
  58. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  59. package/.idea/copilot.data.migration.agent.xml +0 -6
  60. package/.idea/copilot.data.migration.ask.xml +0 -6
  61. package/.idea/copilot.data.migration.ask2agent.xml +0 -6
  62. package/.idea/copilot.data.migration.edit.xml +0 -6
  63. package/.idea/inspectionProfiles/Project_Default.xml +0 -6
  64. package/.idea/js-runner.iml +0 -12
  65. package/.idea/vcs.xml +0 -6
  66. package/dist/args.d.ts +0 -4
  67. package/dist/args.js +0 -58
  68. package/dist/connectors/file.d.ts +0 -15
  69. package/dist/connectors/file.js +0 -89
  70. package/dist/connectors/http.d.ts +0 -14
  71. package/dist/connectors/http.js +0 -82
  72. package/dist/connectors/kafka.d.ts +0 -48
  73. package/dist/connectors/kafka.js +0 -68
  74. package/dist/connectors/ws.d.ts +0 -10
  75. package/dist/connectors/ws.js +0 -72
  76. package/dist/connectors.d.ts +0 -73
  77. package/dist/connectors.js +0 -168
  78. package/dist/index.cjs +0 -732
  79. package/dist/index.d.ts +0 -42
  80. package/dist/index.js +0 -83
  81. package/dist/tsconfig.tsbuildinfo +0 -1
  82. package/dist/util.d.ts +0 -71
  83. package/dist/util.js +0 -92
  84. package/jest.config.js +0 -2
package/src/runner.ts CHANGED
@@ -8,16 +8,18 @@ import {
8
8
  ReceivingMessage,
9
9
  ReceivingStreamMessage,
10
10
  } from '@rdfc/proto'
11
+ import { pathToFileURL } from 'node:url'
11
12
  import { Reader, ReaderInstance } from './reader'
12
13
  import { Writer, WriterInstance } from './writer'
13
14
  import { Processor as Proc } from './processor'
14
- import { createLogger, Logger } from 'winston'
15
+ import { Logger } from 'winston'
15
16
 
16
- import { RpcTransport } from './logger'
17
+ import { extendLogger } from './logger'
17
18
  import { Cont, empty, extractShapes, Shapes } from 'rdf-lens'
18
19
  import { NamedNode, Parser } from 'n3'
19
20
  import { createNamespace, createUriAndTermNamespace, RDF } from '@treecg/types'
20
21
  import { Quad, Term } from '@rdfjs/types'
22
+ import { State } from './state'
21
23
 
22
24
  const RDFL = createUriAndTermNamespace(
23
25
  'https://w3id.org/rdf-lens/ontology#',
@@ -61,30 +63,64 @@ export class Runner {
61
63
 
62
64
  private readonly processors: Proc<unknown>[] = []
63
65
  private readonly processorTransforms: Promise<unknown>[] = []
66
+ private readonly configPath?: string
67
+ private readonly state?: State
68
+ private readonly runnerId?: string
64
69
 
65
70
  constructor(
66
71
  client: RunnerClient,
67
72
  notifyOrchestrator: Writable,
68
73
  uri: string,
69
74
  logger: Logger,
75
+ configPath?: string,
76
+ state?: State,
77
+ runnerId?: string,
70
78
  ) {
71
79
  this.client = client
72
80
  this.notifyOrchestrator = notifyOrchestrator
73
81
  this.uri = uri
74
82
  this.logger = logger
83
+ this.configPath = configPath
84
+ this.state = state
85
+ this.runnerId = runnerId
86
+ }
87
+
88
+ makeRelative(target: string, base: string): string {
89
+ if (!this.configPath) return target
90
+
91
+ const targetUrl = new URL(target)
92
+ const baseUrl = new URL(base)
93
+
94
+ const targetParts = targetUrl.pathname.split('/')
95
+ const baseParts = baseUrl.pathname.split('/')
96
+
97
+ // Remove filename from base (treat as directory)
98
+ baseParts.pop()
99
+
100
+ // Find common path
101
+ let i = 0
102
+ while (
103
+ i < targetParts.length &&
104
+ i < baseParts.length &&
105
+ targetParts[i] === baseParts[i]
106
+ ) {
107
+ i++
108
+ }
109
+
110
+ const up = baseParts.slice(i).map(() => '..')
111
+ const down = targetParts.slice(i)
112
+
113
+ const thing = './' + [...up, ...down].join('/')
114
+ const configPath = this.configPath && pathToFileURL(this.configPath)
115
+ console.log({ configPath, thing, base, target })
116
+ const final = new URL(thing, configPath)
117
+ return final.href
75
118
  }
76
119
 
77
120
  async createProcessor<P extends Proc<unknown>>(
78
121
  proc: Processor,
79
122
  ): Promise<FullProc<P>> {
80
- const procLogger = createLogger({
81
- transports: [
82
- new RpcTransport({
83
- entities: [proc.uri, this.uri],
84
- stream: this.client.logStream(() => {}),
85
- }),
86
- ],
87
- })
123
+ const procLogger = extendLogger(this.logger, proc.uri)
88
124
 
89
125
  const ty = this.quads
90
126
  .filter(
@@ -93,14 +129,16 @@ export class Runner {
93
129
  )
94
130
  .map((x) => x.object.value)
95
131
 
96
- this.logger.info(`Parsing processor '${proc.uri}' of type(s) [${ty.join(', ')}]`)
132
+ this.logger.info(
133
+ `Parsing processor '${proc.uri}' of type(s) [${ty.join(', ')}]`,
134
+ )
97
135
  const args = this.shapes.lenses[RDFL.TypedExtract].execute({
98
136
  id: new NamedNode(proc.uri),
99
137
  quads: this.quads,
100
138
  })
101
139
 
102
140
  const config: ProcessorConfig = JSON.parse(proc.config)
103
- const jsProgram = await import(config.file)
141
+ const jsProgram = await import(this.makeRelative(config.file, this.uri))
104
142
  const clazz = jsProgram[config.clazz || 'default']
105
143
  const instance: Proc<unknown> = new clazz(args, procLogger)
106
144
 
@@ -110,6 +148,7 @@ export class Runner {
110
148
  async addProcessor<P extends Proc<unknown>>(
111
149
  proc: Processor,
112
150
  ): Promise<FullProc<P>> {
151
+ this.logger.info(JSON.stringify(proc))
113
152
  const instance = await this.createProcessor<P>(proc)
114
153
 
115
154
  await instance.init()
@@ -139,12 +178,17 @@ export class Runner {
139
178
  if (this.writers[id] !== undefined) {
140
179
  return this.writers[id]
141
180
  }
181
+ const tracker =
182
+ this.runnerId !== undefined
183
+ ? this.state?.trackChannel(this.runnerId, id, 'writer')
184
+ : undefined
142
185
  const writer = new WriterInstance(
143
186
  id,
144
187
  this.client,
145
188
  this.notifyOrchestrator,
146
189
  this.uri,
147
190
  this.logger,
191
+ tracker,
148
192
  )
149
193
  this.writers[id] = writer
150
194
  return writer
@@ -156,11 +200,16 @@ export class Runner {
156
200
  if (this.readers[ids] !== undefined) {
157
201
  return this.readers[ids]
158
202
  }
203
+ const tracker =
204
+ this.runnerId !== undefined
205
+ ? this.state?.trackChannel(this.runnerId, ids, 'reader')
206
+ : undefined
159
207
  const reader = new ReaderInstance(
160
208
  ids,
161
209
  this.client,
162
210
  this.notifyOrchestrator,
163
211
  this.logger,
212
+ tracker,
164
213
  )
165
214
  this.readers[ids] = reader
166
215
  return reader
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
+ }