@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.
- package/.claude/settings.local.json +12 -0
- package/README.md +37 -1
- package/bin/runner.js +7 -1
- package/bin/server.js +13 -0
- package/examples/echo/.idea/echo.iml +9 -0
- package/examples/echo/.idea/misc.xml +6 -0
- package/examples/echo/.idea/modules.xml +8 -0
- package/examples/echo/.idea/vcs.xml +7 -0
- package/examples/echo/.swls/config.json +1 -0
- package/examples/echo/package-lock.json +27 -29
- package/examples/echo/pipeline.ttl +0 -1
- package/examples/echo/processors.ttl +1 -1
- package/examples/echo/remote_pipeline.ttl +18 -0
- package/examples/echo/server.ttl +5 -0
- package/examples/echo/untitled:/types/MyType.ttl +0 -0
- package/file:/home/silvius/Projects/mumo-pipeline/ldes/http_3A_2F_2Fdata.mumo.be_2Fstreams_2Fnodes_2Fdefault/root/index.trig +3 -0
- package/ldes/http_3A_2F_2Fdata.mumo.be_2Fstreams_2Fnodes_2Fdefault/root/index.trig +3 -0
- package/lib/client.d.ts +2 -1
- package/lib/client.js +70 -22
- package/lib/index.d.ts +2 -0
- package/lib/index.js +3 -1
- package/lib/jsonld.d.ts +17 -0
- package/lib/jsonld.js +135 -0
- package/lib/reader.d.ts +4 -1
- package/lib/reader.js +11 -3
- package/lib/runner.d.ts +6 -1
- package/lib/runner.js +43 -15
- package/lib/server.d.ts +9 -0
- package/lib/server.js +459 -0
- package/lib/state.d.ts +32 -0
- package/lib/state.js +71 -0
- package/lib/testUtils.d.ts +24 -0
- package/lib/testUtils.js +150 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/writer.d.ts +5 -1
- package/lib/writer.js +26 -10
- package/minimal.ttl +99 -0
- package/package.json +12 -11
- package/src/client.ts +99 -24
- package/src/index.ts +2 -0
- package/src/reader.ts +11 -1
- package/src/runner.ts +58 -11
- package/src/server.ts +545 -0
- package/src/state.ts +105 -0
- 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
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
|
+
}
|