@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.
- package/.claude/settings.local.json +12 -0
- package/README.md +41 -1
- package/__tests__/echoProcessor.test.ts +11 -12
- package/bin/runner.js +7 -1
- package/bin/server.js +13 -0
- package/bun.lock +820 -0
- package/examples/echo/.idea/echo.iml +9 -0
- package/examples/echo/.idea/misc.xml +6 -0
- package/{.idea → examples/echo/.idea}/modules.xml +1 -1
- 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/logger.d.ts +1 -0
- package/lib/logger.js +19 -9
- 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/duplex.js +1 -1
- package/lib/testUtils/index.d.ts +9 -9
- package/lib/testUtils/index.js +9 -9
- 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 +21 -19
- package/src/client.ts +99 -24
- package/src/index.ts +2 -0
- package/src/logger.ts +15 -7
- package/src/reader.ts +19 -9
- package/src/runner.ts +61 -12
- package/src/server.ts +545 -0
- package/src/state.ts +105 -0
- package/src/testUtils/duplex.ts +1 -4
- package/src/testUtils/index.ts +25 -21
- package/src/writer.ts +36 -12
- package/.idea/LNKD.tech Editor.xml +0 -194
- package/.idea/codeStyles/Project.xml +0 -52
- package/.idea/codeStyles/codeStyleConfig.xml +0 -5
- package/.idea/copilot.data.migration.agent.xml +0 -6
- package/.idea/copilot.data.migration.ask.xml +0 -6
- package/.idea/copilot.data.migration.ask2agent.xml +0 -6
- package/.idea/copilot.data.migration.edit.xml +0 -6
- package/.idea/inspectionProfiles/Project_Default.xml +0 -6
- package/.idea/js-runner.iml +0 -12
- package/.idea/vcs.xml +0 -6
- package/dist/args.d.ts +0 -4
- package/dist/args.js +0 -58
- package/dist/connectors/file.d.ts +0 -15
- package/dist/connectors/file.js +0 -89
- package/dist/connectors/http.d.ts +0 -14
- package/dist/connectors/http.js +0 -82
- package/dist/connectors/kafka.d.ts +0 -48
- package/dist/connectors/kafka.js +0 -68
- package/dist/connectors/ws.d.ts +0 -10
- package/dist/connectors/ws.js +0 -72
- package/dist/connectors.d.ts +0 -73
- package/dist/connectors.js +0 -168
- package/dist/index.cjs +0 -732
- package/dist/index.d.ts +0 -42
- package/dist/index.js +0 -83
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/dist/util.d.ts +0 -71
- package/dist/util.js +0 -92
- 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 {
|
|
15
|
+
import { Logger } from 'winston'
|
|
15
16
|
|
|
16
|
-
import {
|
|
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 =
|
|
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(
|
|
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,'&').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
|
+
}
|