@rdfc/js-runner 2.0.0 → 3.0.1

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.
@@ -0,0 +1,112 @@
1
+ import { Duplex } from 'stream'
2
+ import type {
3
+ ClientDuplexStream,
4
+ InterceptingCallInterface,
5
+ } from '@grpc/grpc-js'
6
+ import { AuthContext } from '@grpc/grpc-js/build/src/auth-context'
7
+
8
+ type Matcher<Req, T> = (req: Req) => T | undefined
9
+ type Handler<Req, Res> = (req: Req, send: (res: Res) => void) => void
10
+
11
+ type MatchObject<Req, Res, T> = {
12
+ matcher: Matcher<Req, T>
13
+ handler: Handler<T, Res>
14
+ }
15
+
16
+ let count = 0
17
+ export class MockClientDuplexStream<Req, Res>
18
+ extends Duplex
19
+ implements ClientDuplexStream<Req, Res>
20
+ {
21
+ private capabilities: Array<MatchObject<Req, Res, unknown>> = []
22
+ private onceCapabilities: Array<MatchObject<Req, Res, unknown>> = []
23
+
24
+ public readonly id: number
25
+
26
+ call?: InterceptingCallInterface | undefined
27
+ constructor() {
28
+ super({ objectMode: true })
29
+ this.id = count++
30
+ }
31
+
32
+ getAuthContext(): AuthContext | null {
33
+ return null
34
+ }
35
+
36
+ // ---- SurfaceCall stubs ----
37
+ cancel(): void {}
38
+ getPeer(): string {
39
+ return 'mock-peer'
40
+ }
41
+
42
+ // ---- ObjectWritable<Req> ----
43
+ _write(
44
+ chunk: Req,
45
+ _encoding: BufferEncoding,
46
+ callback: (error?: Error | null) => void,
47
+ ): void {
48
+ let handled = false
49
+ // check registered capabilities
50
+ for (const { matcher, handler } of this.capabilities) {
51
+ const o = matcher(chunk)
52
+ if (o !== undefined) {
53
+ handled = true
54
+ handler(o, (res) => this.send(res))
55
+ }
56
+ }
57
+
58
+ const newOnce: typeof this.onceCapabilities = []
59
+ for (const { matcher, handler } of this.onceCapabilities) {
60
+ const o = matcher(chunk)
61
+ if (o !== undefined) {
62
+ handled = true
63
+ handler(o, (res) => this.send(res))
64
+ } else {
65
+ newOnce.push({ matcher, handler })
66
+ }
67
+ }
68
+
69
+ this.onceCapabilities = newOnce
70
+ if (!handled) {
71
+ console.error('Unhandled!', Object.keys(chunk as object))
72
+ }
73
+
74
+ callback()
75
+ }
76
+
77
+ // ---- ObjectReadable<Res> ----
78
+ _read(): void {
79
+ // no-op: we push manually with send()
80
+ }
81
+
82
+ // ---- gRPC-style helpers ----
83
+ serialize(value: Req): Buffer {
84
+ return Buffer.from(JSON.stringify(value))
85
+ }
86
+
87
+ deserialize(chunk: Buffer): Res {
88
+ return JSON.parse(chunk.toString())
89
+ }
90
+
91
+ send(response: Res): void {
92
+ this.push(response)
93
+ }
94
+
95
+ end(): this {
96
+ this.push(null)
97
+ return this
98
+ }
99
+
100
+ // ---- Capability registration ----
101
+ register<T>(matcher: Matcher<Req, T>, handler: Handler<T, Res>): void {
102
+ this.capabilities.push({ matcher, handler })
103
+ }
104
+
105
+ registerOnce<T>(matcher: Matcher<Req, T>, handler: Handler<T, Res>): void {
106
+ this.onceCapabilities.push({ matcher, handler })
107
+ }
108
+
109
+ awaitMsg<T>(matcher: Matcher<Req, T>): Promise<T> {
110
+ return new Promise((res) => this.registerOnce(matcher, res))
111
+ }
112
+ }
@@ -0,0 +1,430 @@
1
+ import {
2
+ DataChunk,
3
+ FromRunner,
4
+ LogMessage,
5
+ Processor as ProcConfig,
6
+ ToRunner,
7
+ } from '@rdfc/proto'
8
+ import {} from '../reexports'
9
+ import { extractShapes } from 'rdf-lens'
10
+ import { NamedNode, Parser, Writer as N3Writer } from 'n3'
11
+ import { readFile } from 'fs/promises'
12
+ import winston, { createLogger } from 'winston'
13
+ import { Processor } from '../processor'
14
+ import { FullProc, Runner, Writable } from '../runner'
15
+ import { Quad } from '@rdfjs/types'
16
+ import { createTermNamespace } from '@treecg/types'
17
+ import {
18
+ ReceivingStreamControl,
19
+ SendingStreamControl,
20
+ StreamChunk,
21
+ StreamIdentify,
22
+ } from '@rdfc/proto/lib/generated/common'
23
+ import { MockClientDuplexStream } from './duplex'
24
+ import { promisify } from 'util'
25
+ import { Reader } from '../reader'
26
+ import { Writer } from '../writer'
27
+
28
+ export function channel(runner: Runner, name: string): [Writer, Reader] {
29
+ const n = new NamedNode(name)
30
+ const reader = runner.createReader(n)
31
+ const writer = runner.createWriter(n)
32
+
33
+ return [writer, reader]
34
+ }
35
+
36
+ export class StreamMsgMock {
37
+ data: DataChunk[] = []
38
+
39
+ private readonly resolveId: (id: StreamIdentify) => number
40
+
41
+ constructor(resolveId: (id: StreamIdentify) => number) {
42
+ this.resolveId = resolveId
43
+ }
44
+
45
+ sendStreamMessage(): MockClientDuplexStream<
46
+ StreamChunk,
47
+ ReceivingStreamControl
48
+ > {
49
+ const sendingStream = new MockClientDuplexStream<
50
+ StreamChunk,
51
+ ReceivingStreamControl
52
+ >()
53
+
54
+ let at = 0
55
+ sendingStream.register(
56
+ (x) => x.data,
57
+ (d, send) => {
58
+ setTimeout(() => send({ streamSequenceNumber: ++at }), 20)
59
+ this.data.push(d)
60
+ },
61
+ )
62
+ sendingStream.register(
63
+ (x) => x.id,
64
+ (d, send) => send({ streamSequenceNumber: this.resolveId(d) }),
65
+ )
66
+ return sendingStream
67
+ }
68
+ }
69
+
70
+ export class OrchestratorMock {
71
+ connectStream: MockClientDuplexStream<FromRunner, ToRunner>
72
+
73
+ streamMsgs: {
74
+ [id: string]: {
75
+ toProducingStream: (id: ReceivingStreamControl) => void
76
+ receivingStream?: MockClientDuplexStream<SendingStreamControl, DataChunk>
77
+ }
78
+ } = {}
79
+
80
+ streamMsgCount = 0
81
+
82
+ connect(): MockClientDuplexStream<FromRunner, ToRunner> {
83
+ const connectStream = new MockClientDuplexStream<FromRunner, ToRunner>()
84
+ // Always bounce processed msgs back to the runner
85
+ connectStream.register(
86
+ (msg) => msg.processed,
87
+ ({ channel, globalSequenceNumber }, send) => {
88
+ send({
89
+ processed: { channel, localSequenceNumber: globalSequenceNumber },
90
+ })
91
+ },
92
+ )
93
+
94
+ // Always bounce data msgs back to the runner
95
+ connectStream.register(
96
+ (msg) => msg.msg,
97
+ ({ localSequenceNumber, data, channel }, send) => {
98
+ send({
99
+ msg: { globalSequenceNumber: localSequenceNumber, channel, data },
100
+ })
101
+ },
102
+ )
103
+
104
+ // Always bounce close msgs back to the runner
105
+ connectStream.register(
106
+ (msg) => msg.close,
107
+ (close, send) => {
108
+ send({ close })
109
+ },
110
+ )
111
+
112
+ this.connectStream = connectStream
113
+ return connectStream
114
+ }
115
+
116
+ sendStreamMessage(): MockClientDuplexStream<
117
+ StreamChunk,
118
+ ReceivingStreamControl
119
+ > {
120
+ const out = new MockClientDuplexStream<
121
+ StreamChunk,
122
+ ReceivingStreamControl
123
+ >()
124
+ const id = this.streamMsgCount
125
+ this.streamMsgCount++
126
+
127
+ out.registerOnce(
128
+ (x) => x.id,
129
+ ({ channel }, toProducingStream) => {
130
+ this.streamMsgs[id] = { toProducingStream }
131
+
132
+ // Notify stream message
133
+ this.connectStream.send({
134
+ streamMsg: { channel, globalSequenceNumber: id },
135
+ })
136
+ },
137
+ )
138
+
139
+ out.register(
140
+ (x) => x.data,
141
+ (data) => {
142
+ // bounce data to receiving stream
143
+ this.streamMsgs[id].receivingStream!.send(data)
144
+ },
145
+ )
146
+
147
+ out.on('end', () => {
148
+ // end receiving stream
149
+ this.streamMsgs[id].receivingStream!.end()
150
+ })
151
+
152
+ return out
153
+ }
154
+
155
+ receiveStreamMessage(): MockClientDuplexStream<
156
+ SendingStreamControl,
157
+ DataChunk
158
+ > {
159
+ const receivingStream = new MockClientDuplexStream<
160
+ SendingStreamControl,
161
+ DataChunk
162
+ >()
163
+ let streamId = 0
164
+
165
+ receivingStream.registerOnce(
166
+ (x) => x.globalSequenceNumber,
167
+ (id) => {
168
+ streamId = id
169
+ this.streamMsgs[id].receivingStream = receivingStream
170
+ this.streamMsgs[id].toProducingStream({ streamSequenceNumber: 0 })
171
+ },
172
+ )
173
+
174
+ receivingStream.register(
175
+ (x) => x.streamSequenceNumber,
176
+ (streamSequenceNumber) => {
177
+ // Bounce processed message to producing stream
178
+ this.streamMsgs[streamId].toProducingStream({ streamSequenceNumber })
179
+ },
180
+ )
181
+
182
+ return receivingStream
183
+ }
184
+
185
+ logStream(): MockClientDuplexStream<LogMessage, null> {
186
+ const logStream = new MockClientDuplexStream<LogMessage, null>()
187
+ logStream.register((x) => x, console.log)
188
+ return logStream
189
+ }
190
+ }
191
+
192
+ export function createRunner(uri = 'http://example.com/ns#') {
193
+ const logger = createLogger({
194
+ transports: new winston.transports.Console({
195
+ level: process.env['DEBUG'] || 'info',
196
+ }),
197
+ })
198
+
199
+ // Mock the GRPC
200
+ const client = new OrchestratorMock()
201
+
202
+ // Connect just like client.ts:start()
203
+ const stream = client.connect()
204
+ const writable = promisify(stream.write.bind(stream))
205
+
206
+ const runner = new Runner(
207
+ /* eslint-disable @typescript-eslint/no-explicit-any */
208
+ client as any, // Type assertion for testing
209
+ writable as Writable,
210
+ uri,
211
+ logger,
212
+ )
213
+
214
+ stream.on('data', (msg: ToRunner) => runner.handleOrchMessage(msg))
215
+
216
+ return runner
217
+ }
218
+
219
+ export async function one<T>(iter: AsyncIterable<T>): Promise<T | undefined> {
220
+ for await (const item of iter) {
221
+ return item
222
+ }
223
+ }
224
+
225
+ const shapeQuads = `
226
+ @prefix rdfc: <https://w3id.org/rdf-connect#>.
227
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
228
+ @prefix sh: <http://www.w3.org/ns/shacl#>.
229
+ [ ] a sh:NodeShape;
230
+ sh:targetClass <JsProcessorShape>;
231
+ sh:property [
232
+ sh:path rdfc:entrypoint;
233
+ sh:name "location";
234
+ sh:minCount 1;
235
+ sh:maxCount 1;
236
+ sh:datatype xsd:string;
237
+ ], [
238
+ sh:path rdfc:file;
239
+ sh:name "file";
240
+ sh:minCount 1;
241
+ sh:maxCount 1;
242
+ sh:datatype xsd:string;
243
+ ], [
244
+ sh:path rdfc:class;
245
+ sh:name "clazz";
246
+ sh:maxCount 1;
247
+ sh:datatype xsd:string;
248
+ ].
249
+ `
250
+ export type ConfigType = {
251
+ location: string
252
+ file: string
253
+ clazz: string
254
+ }
255
+
256
+ const OWL = createTermNamespace('http://www.w3.org/2002/07/owl#', 'imports')
257
+ const processorShapes = extractShapes(new Parser().parse(shapeQuads))
258
+ const base = 'https://w3id.org/rdf-connect#'
259
+
260
+ export async function importFile(
261
+ file: string,
262
+ content?: string,
263
+ ): Promise<Quad[]> {
264
+ const done = new Set<string>()
265
+
266
+ const quads: Quad[] = []
267
+ const todo: URL[] = []
268
+
269
+ const parse = (content: string, baseIRI: URL) => {
270
+ done.add(baseIRI.toString())
271
+
272
+ const extras = new Parser({ baseIRI: baseIRI.toString() }).parse(content)
273
+
274
+ for (const o of extras
275
+ .filter(
276
+ (x) =>
277
+ x.subject.value === baseIRI?.toString() &&
278
+ x.predicate.equals(OWL.imports),
279
+ )
280
+ .map((x) => x.object.value)) {
281
+ todo.push(new URL(o))
282
+ }
283
+
284
+ quads.push(...extras)
285
+ }
286
+
287
+ if (content) {
288
+ parse(content, new URL('file://' + file))
289
+ } else {
290
+ todo.push(new URL('file://' + file))
291
+ }
292
+
293
+ let item = todo.pop()
294
+ while (item !== undefined) {
295
+ if (done.has(item.toString())) {
296
+ item = todo.pop()
297
+ continue
298
+ }
299
+
300
+ if (item.protocol !== 'file:') {
301
+ throw 'No supported protocol ' + item.protocol
302
+ }
303
+ const txt = await readFile(item.pathname, { encoding: 'utf8' })
304
+ parse(txt, item)
305
+
306
+ item = todo.pop()
307
+ }
308
+
309
+ return quads
310
+ }
311
+
312
+ /**
313
+ * Helper class to gradually test your processors.
314
+ * Possible flow:
315
+ * - import the JsRunner index file
316
+ * - import your processor config file
317
+ * - test if the config is as you would expect (from getConfig())
318
+ * - import your processor definition (inline)
319
+ * - build your processor
320
+ * - test your processor
321
+ */
322
+ export class ProcHelper<T extends Processor<unknown>> {
323
+ runner: Runner
324
+ quads: Quad[] = []
325
+ config: ConfigType
326
+ proc: FullProc<T>
327
+
328
+ constructor(uri?: string) {
329
+ this.runner = createRunner(uri)
330
+ }
331
+
332
+ async importInline(baseIRI: string, config: string) {
333
+ const configQuads = await importFile(baseIRI, config)
334
+ this.quads.push(...configQuads)
335
+ }
336
+
337
+ async importFile(file: string) {
338
+ const configQuads = await importFile(file)
339
+ this.quads.push(...configQuads)
340
+ }
341
+
342
+ getConfig(ty: string | NamedNode): ConfigType {
343
+ const id = typeof ty === 'string' ? new NamedNode(base + ty) : ty
344
+ const procConfig = <ConfigType>processorShapes.lenses[
345
+ 'JsProcessorShape'
346
+ ].execute({
347
+ id,
348
+ quads: this.quads,
349
+ })
350
+
351
+ this.config = procConfig
352
+ return procConfig
353
+ }
354
+
355
+ async getProcessor(
356
+ uri: string = 'http://example.com/ns#processor',
357
+ ): Promise<FullProc<T>> {
358
+ await this.runner.handleOrchMessage({
359
+ pipeline: new N3Writer().quadsToString(this.quads),
360
+ })
361
+
362
+ const proc = await this.runner.addProcessor<T>({
363
+ config: JSON.stringify(this.config),
364
+ arguments: '',
365
+ uri,
366
+ })
367
+ return proc
368
+ }
369
+ }
370
+
371
+ /**
372
+ * @deprecated use {@link ProcHelper}
373
+ */
374
+ export async function getProcInline<T extends Processor<unknown>>(
375
+ config: string,
376
+ ty: string,
377
+ runner: Runner,
378
+ baseIRI: string,
379
+ uri = 'http://example.com/ns#processor',
380
+ ): Promise<FullProc<T>> {
381
+ const configQuads = await importFile(baseIRI, config)
382
+ const procConfig = <ProcConfig>processorShapes.lenses[
383
+ 'JsProcessorShape'
384
+ ].execute({
385
+ id: new NamedNode(base + ty),
386
+ quads: configQuads,
387
+ })
388
+
389
+ await runner.handleOrchMessage({
390
+ pipeline: new N3Writer().quadsToString(configQuads),
391
+ })
392
+
393
+ const proc = await runner.addProcessor<T>({
394
+ config: JSON.stringify(procConfig),
395
+ arguments: '',
396
+ uri,
397
+ })
398
+
399
+ return proc
400
+ }
401
+
402
+ /**
403
+ * @deprecated use {@link ProcHelper}
404
+ */
405
+ export async function getProc<T extends Processor<unknown>>(
406
+ config: string,
407
+ ty: string,
408
+ runner: Runner,
409
+ configLocation: string,
410
+ uri = 'http://example.com/ns#processor',
411
+ ): Promise<FullProc<T>> {
412
+ const configQuads = await importFile(configLocation)
413
+ const procConfig = processorShapes.lenses['JsProcessorShape'].execute({
414
+ id: new NamedNode(base + ty),
415
+ quads: configQuads,
416
+ })
417
+
418
+ configQuads.push(...new Parser().parse(config))
419
+ await runner.handleOrchMessage({
420
+ pipeline: new N3Writer().quadsToString(configQuads),
421
+ })
422
+
423
+ const proc = await runner.addProcessor<T>({
424
+ config: JSON.stringify(procConfig),
425
+ arguments: '',
426
+ uri,
427
+ })
428
+
429
+ return proc
430
+ }