@jseeio/jsee 0.2.8 → 0.3.0

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/src/main.js ADDED
@@ -0,0 +1,678 @@
1
+ import { createVueApp } from './app'
2
+ import Worker from './worker.js'
3
+
4
+ const utils = require('./utils')
5
+ const isObject = utils.isObject
6
+
7
+ const { Notyf } = require('notyf')
8
+ const notyf = new Notyf({
9
+ types: [
10
+ {
11
+ type: 'success',
12
+ background: '#00d1b2',
13
+ },
14
+ {
15
+ type: 'error',
16
+ background: '#f14668',
17
+ duration: 2000,
18
+ dismissible: true
19
+ }
20
+ ]
21
+ })
22
+
23
+ const Overlay = require('./overlay')
24
+
25
+ require('notyf/notyf.min.css')
26
+
27
+ const fetch = window['fetch']
28
+ const Blob = window['Blob']
29
+
30
+ let verbose = true
31
+ function log () {
32
+ if (verbose) {
33
+ console.log(`[JSEE v${VERSION}]`, ...arguments)
34
+ const logElement = document.querySelector('#log')
35
+ if (logElement) {
36
+ logElement.innerHTML += `\n${[...arguments].join(' ')}`
37
+ logElement.scrollTop = logElement.scrollHeight // auto scroll to bottom
38
+ if (logElement.innerHTML.length > 10000) {
39
+ logElement.innerHTML = logElement.innerHTML.slice(-10000)
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ // const Worker = window['Worker']
46
+
47
+ // Deep clone a simple object
48
+ function clone (obj) {
49
+ // return JSON.parse(JSON.stringify(obj))
50
+ return Object.assign({}, obj)
51
+ }
52
+
53
+
54
+
55
+ function getName (code) {
56
+ switch (typeof code) {
57
+ case 'function':
58
+ return code.name
59
+ case 'string':
60
+ const words = code.split(' ')
61
+ const functionIndex = words.findIndex((word) => word == 'function')
62
+ const name = words[functionIndex + 1]
63
+ return name.includes('(') ? undefined : name
64
+ default:
65
+ return undefined
66
+ }
67
+ }
68
+
69
+ // Return input value
70
+ function getValue (input) {
71
+ if (input.type === 'group') {
72
+ const value = {}
73
+ input.elements.forEach(el => {
74
+ value[el.name] = getValue(el)
75
+ })
76
+ return value
77
+ } else {
78
+ return input.value
79
+ }
80
+ }
81
+
82
+ function getModelType (model) {
83
+ if (model.code && typeof model.code === 'string' && model.code.split(' ').map(v => v.trim()).includes('def')) {
84
+ return 'py'
85
+ } else if (model.url) {
86
+ return 'post'
87
+ }
88
+ return 'function'
89
+ }
90
+
91
+ // Nice trick to get a function parameters by Jack Allan
92
+ // From: https://stackoverflow.com/a/9924463/2998960
93
+ const STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg
94
+ const ARGUMENT_NAMES = /([^\s,]+)/g
95
+ function getParamNames (func) {
96
+ const fnStr = func.toString().replace(STRIP_COMMENTS, '')
97
+ let result = fnStr.slice(fnStr.indexOf('(')+1, fnStr.indexOf(')')).match(ARGUMENT_NAMES)
98
+ if (result === null)
99
+ result = []
100
+ return result
101
+ }
102
+
103
+ function getInputs (model) {
104
+ if (model.code) {
105
+ const params = getParamNames(model.code).filter(p => !['(', ')', '#', '{', '}'].some(c => p.includes(c)))
106
+ log('Trying to infer inputs from params:', params)
107
+ return params.map(p => ({
108
+ 'name': p,
109
+ 'type': 'string'
110
+ }))
111
+ }
112
+ return []
113
+ }
114
+
115
+ function getFunctionContainer (target) {
116
+ // Check if the number of parameters is > 1, then 'args'
117
+ }
118
+
119
+ export default class JSEE {
120
+ constructor (params, alt1, alt2) {
121
+ // Check if JSEE was initialized with args rather than with a params object
122
+ // So two ways to init JSEE:
123
+ // 1. new JSEE({schema: ..., container: ..., verbose: ...}) <- params object
124
+ // 2. new JSEE(schema, container, verbose) <- args
125
+ // This check converts args to params object (2 -> 1)
126
+ if (('model' in params) || (typeof params === 'string') || (typeof params === 'function') || !(typeof alt1 === 'undefined')) {
127
+ params = {
128
+ 'schema': params,
129
+ 'container': alt1,
130
+ 'verbose': alt2
131
+ }
132
+ }
133
+
134
+ // Set global verbose flag
135
+ // This check sets verbose to true in all cases except when params.verbose is explicitly set to false
136
+ verbose = !(params.verbose === false)
137
+ this.container = params.container
138
+ this.schema = params.schema || params.config // Previous naming
139
+ this.__version__ = VERSION
140
+
141
+ // Check if schema is provided
142
+ if (typeof this.schema === 'undefined') {
143
+ notyf.error('No schema provided')
144
+ throw new Error('No schema provided')
145
+ }
146
+
147
+ // Check if container is provided
148
+ if (typeof this.container === 'undefined') {
149
+ // Check if 'jsee-container' exists
150
+ if (document.querySelector('#jsee-container')) {
151
+ this.container = '#jsee-container'
152
+ log(`Using default container: ${this.container}`)
153
+ } else {
154
+ notyf.error('No container provided')
155
+ throw new Error('No container provided')
156
+ }
157
+ }
158
+
159
+ this.init()
160
+ }
161
+
162
+ log (...args) {
163
+ log(...args)
164
+ }
165
+
166
+ notify (txt) {
167
+ notyf.success(txt)
168
+ }
169
+
170
+ progress (i) {
171
+ // Check if progress div is defined
172
+ let progress = document.querySelector('#progress')
173
+ if (!progress) {
174
+ progress = document.createElement('div')
175
+ progress.setAttribute('id', 'progress')
176
+ progress.style = 'position: fixed; top: 0; left: 0; width: 0; height: 3px; background: #00d1b2; z-index: 1000;'
177
+ document.body.appendChild(progress)
178
+ }
179
+ progress.style.width = `${i}%`
180
+ }
181
+
182
+ async init () {
183
+ // At this point this.schema is defined but can be in different forms (e.g. string, object, function)
184
+ await this.initSchema() // -> this.schema (object)
185
+ await this.initModel()
186
+ await this.initInputs()
187
+ await this.initVue() // -> this.app, this.data
188
+ await this.initPipeline()
189
+ if (this.schema.autorun) {
190
+ log('Autorun is enabled. Running the model')
191
+ this.run('init')
192
+ }
193
+ }
194
+
195
+ async initSchema () {
196
+ // Check if schema is a string (url to json)
197
+ if (typeof this.schema === 'string') {
198
+ this.schemaUrl = this.schema.indexOf('json') ? this.schema : this.schema + '.json'
199
+ this.schema = await fetch(this.schemaUrl)
200
+ this.schema = await this.schema.json()
201
+ }
202
+
203
+ // Check if schema is a function (model)
204
+ if (typeof this.schema === 'function') {
205
+ this.schema = {
206
+ model: {
207
+ code: this.schema,
208
+ }
209
+ }
210
+ }
211
+
212
+ // At this point schema should be an object
213
+ if (!isObject(this.schema)) {
214
+ notyf.error('Schema is in a wrong format')
215
+ throw new Error(`Schema is in a wrong format: ${this.schema}`)
216
+ }
217
+ }
218
+
219
+ async initModel () {
220
+ // Model is the main part of the schema that defines all computations
221
+ // At the end it should be an array of objects that define a sequence of tasks
222
+ this.model = []
223
+
224
+ // Check if model is a function (model)
225
+ ;[this.schema.model, this.schema.render].forEach(m => {
226
+ // Function -> {code: Function}
227
+ if (typeof m === 'function') {
228
+ this.model.push({
229
+ code: m
230
+ })
231
+ } else if (Array.isArray(m)) {
232
+ // concatenate
233
+ this.model = this.model.concat(m)
234
+ } else if (isObject(m)) {
235
+ this.model.push(m)
236
+ }
237
+ })
238
+
239
+ // Check if model is empty
240
+ if (this.model.length === 0) {
241
+ notyf.error('Model is in a wrong format')
242
+ throw new Error(`Model is in a wrong format: ${this.schema.model}`)
243
+ }
244
+
245
+ // Put worker and imports inside model blocks
246
+ ;['worker', 'imports'].forEach(key => {
247
+ if (typeof this.schema[key] !== 'undefined') {
248
+ this.model[0][key] = this.schema[key]
249
+ delete this.schema[key]
250
+ }
251
+ })
252
+
253
+ // Check if autorun is defined
254
+ if (typeof this.model[0]['autorun'] !== 'undefined') {
255
+ this.schema.autorun = this.model[0]['autorun']
256
+ delete this.model[0]['autorun']
257
+ }
258
+
259
+ // Async for-loop over this.model
260
+ for (const [i, m] of this.model.entries()) {
261
+ if (typeof m.worker === 'undefined') {
262
+ m.worker = i === 0 // Run first model in a web worker
263
+ }
264
+
265
+ // Load code if url is provided
266
+ if (m.url && (m.url.includes('.js') || m.url.includes('.py'))) {
267
+ // Update model URL if needed
268
+ if (!m.url.includes('/') && this.schemaUrl && this.schemaUrl.includes('/')) {
269
+ m.url = window.location.protocol + '//' + window.location.host + this.schemaUrl.split('/').slice(0, -1).join('/') + '/' + m.url
270
+ log(`Changed the old model URL to ${m.url} (based on the schema URL)`)
271
+ }
272
+ log('Loaded code from:', m.url)
273
+ m.code = await fetch(m.url)
274
+ m.code = await m.code.text()
275
+ }
276
+
277
+ // Update model name if absent
278
+ if (typeof m.name === 'undefined'){
279
+ if ((m.url) && (m.url.includes('.js'))) {
280
+ m.name = m.url.split('/').pop().split('.')[0]
281
+ log('Use model name from url:', m.name)
282
+ } else if (m.code) {
283
+ m.name = getName(m.code)
284
+ log('Use model name from code:', m.name)
285
+ }
286
+ }
287
+
288
+ // Check if imports are string -> convert to array
289
+ if (typeof m.imports === 'string') {
290
+ m.imports = [m.imports]
291
+ }
292
+
293
+ // Infer model type
294
+ if (typeof m.type === 'undefined') {
295
+ m.type = getModelType(m)
296
+ }
297
+
298
+
299
+ } // end of model-loop
300
+
301
+ log('Model initialized, size:', this.model.length)
302
+ }
303
+
304
+ async initInputs () {
305
+ // Check inputs
306
+ // Relies on model.code
307
+ // So run after possible fetching
308
+ if (typeof this.schema.inputs === 'undefined') {
309
+ this.model[0].container = 'args'
310
+ this.schema.inputs = getInputs(this.model[0])
311
+ }
312
+
313
+ // Relies on input check
314
+ // Set default input type
315
+ this.schema.inputs.forEach(input => {
316
+ if (typeof input.type === 'undefined') {
317
+ input.type = 'string'
318
+ }
319
+ })
320
+ log('Inputs are:', this.schema.inputs)
321
+ }
322
+
323
+ initVue () {
324
+ return new Promise((resolve, reject) => {
325
+ try {
326
+ log('Initializing VUE')
327
+ this.app = createVueApp(this, (container) => {
328
+ // Called when the app is mounted
329
+ // FYI "this" here refers to port object
330
+ this.outputsContainer = container.querySelector('#outputs')
331
+ this.inputsContainer = container.querySelector('#inputs')
332
+ this.modelContainer = container.querySelector('#model')
333
+ // Init overlay
334
+ this.overlay = new Overlay(this.inputsContainer ? this.inputsContainer : this.outputsContainer)
335
+ // Add stop button to the overlay if interval is defined
336
+ if (this.schema.interval) {
337
+ this.stopElement = document.createElement('button')
338
+ this.stopElement.innerHTML = 'Stop'
339
+ this.stopElement.style = 'background: white; color: #333; border: 1px solid #DDD; padding: 10px; border-radius: 5px; cursor: pointer;'
340
+ this.stopElement.addEventListener('click', () => {
341
+ this.running = false
342
+ })
343
+ this.overlay.element.innerHTML = ''
344
+ this.overlay.element.appendChild(this.stopElement)
345
+ }
346
+ resolve()
347
+ }, log)
348
+ this.data = this.app.$data
349
+ } catch (err) {
350
+ reject(err)
351
+ }
352
+ })
353
+ }
354
+
355
+ async initPipeline () {
356
+ // Initial identity operation (just pass the input to output)
357
+ this.pipeline = (inputs) => inputs
358
+ // Async for-loop over this.model (again)
359
+ for (const [i, m] of this.model.entries()) {
360
+ log('Initilizing the pipeline with model:', i)
361
+ let modelFunc
362
+ if (m.worker) {
363
+ // Init worker model
364
+ modelFunc = await this.initWorker(m)
365
+ } else {
366
+ // Init specific model types
367
+ switch (m.type) {
368
+ case 'py':
369
+ modelFunc = await this.initPython(m)
370
+ break
371
+ case 'tf':
372
+ modelFunc = await this.initTF(m)
373
+ break
374
+ case 'function':
375
+ case 'class':
376
+ case 'async-init':
377
+ case 'async-function':
378
+ modelFunc = await this.initJS(m)
379
+ break
380
+ case 'get':
381
+ case 'post':
382
+ modelFunc = await this.initAPI(m)
383
+ break
384
+ default:
385
+ notyf.error('No type information')
386
+ throw new Error(`No type information: ${m.type}`)
387
+ }
388
+ }
389
+
390
+ this.pipeline = (p => {
391
+ return async (inputs) => {
392
+ const resPrev = await p(inputs)
393
+ const resNext = await modelFunc(resPrev)
394
+ if (isObject(resNext) && isObject(resPrev)) {
395
+ // If both results are objects, merge them
396
+ return Object.assign({}, resPrev, resNext)
397
+ } else if (typeof resNext !== 'undefined') {
398
+ // If next result is defined, return it
399
+ return resNext
400
+ } else {
401
+ // Otherwise return previous result (pass through)
402
+ return resPrev
403
+ }
404
+ }
405
+ })(this.pipeline)
406
+
407
+ notyf.success('Pipeline initialized')
408
+ this.overlay.hide()
409
+ }
410
+ }
411
+
412
+ async initWorker (model) {
413
+ // Init worker
414
+ const worker = new Worker()
415
+
416
+ // Init worker with the model
417
+ if (typeof model.code === 'function') {
418
+ log('Convert code in schema to string for WebWorker')
419
+ model.code = model.code.toString()
420
+ }
421
+
422
+ // Wrap anonymous functions
423
+ if (!model.name) {
424
+ model.code = `function anon () { return (${model.code})(...arguments) }`
425
+ model.name = 'anon'
426
+ }
427
+
428
+ const modelFunc = (inputs) => new Promise((resolve, reject) => {
429
+ worker.onmessage = (e) => {
430
+ const res = e.data
431
+ if ((typeof res === 'object') && (res._status)) {
432
+ switch (res._status) {
433
+ case 'loaded':
434
+ notyf.success('Loaded model (in worker)')
435
+ log('Loaded model (in worker):', res)
436
+ resolve(res)
437
+ break
438
+ case 'log':
439
+ log(...res._log)
440
+ break
441
+ case 'progress':
442
+ this.progress(res._progress)
443
+ break
444
+ case 'error':
445
+ notyf.error(res._error)
446
+ log('Error from worker:', res._error)
447
+ reject(res._error)
448
+ break
449
+ }
450
+ } else {
451
+ log('Response from worker:', res)
452
+ resolve(res)
453
+ }
454
+ }
455
+ worker.onerror = (e) => {
456
+ notyf.error(e.message)
457
+ log('Error from worker:', e)
458
+ reject(e)
459
+ }
460
+ worker.postMessage(inputs)
461
+ })
462
+
463
+ // Initial worker call with model definition
464
+ await modelFunc(model)
465
+
466
+ // Worker will be in the context of each modelFunc
467
+ return modelFunc
468
+ }
469
+
470
+ async initPython (model) {
471
+ // Add loading indicator
472
+ this.overlay.show()
473
+ await utils.importScripts(['https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js'])
474
+ const pyodide = await loadPyodide()
475
+ if (model.imports && Array.isArray(model.imports) && model.imports.length) {
476
+ await pyodide.loadPackage(model.imports)
477
+ } else {
478
+ await pyodide.loadPackagesFromImports(model.code)
479
+ }
480
+ return async (data) => {
481
+ for (let key in data) {
482
+ window[key] = data[key]
483
+ }
484
+ return await pyodide.runPythonAsync(model.code);
485
+ }
486
+ }
487
+
488
+ async initJS (model) {
489
+ // 1. String input <- loaded from url or code(string)
490
+ // 2. Target object (can be function, class or a function with async init <- code(object)
491
+ // 3. Model function
492
+
493
+ // We always start from 1 or 2
494
+ // For window execution we go: [1 ->] 2 -> 3
495
+ // For worker: [2 ->] 1 -> Worker
496
+
497
+ // Main: Initialize model in main window
498
+
499
+ // Load imports if defined (before calling the model)
500
+ if (model.imports && model.imports.length) {
501
+ log('Loading imports from schema')
502
+ await utils.importScripts(...model.imports)
503
+ notyf.success('Loaded: JS imports')
504
+ }
505
+
506
+ // Target here represents raw JS object (e.g. class), not the final callable function
507
+ let target
508
+ if (typeof model.code === 'string') {
509
+ // 1 -> 2
510
+ // Danger zone
511
+ if (model.name) {
512
+ log('Evaluating code from string (has name)')
513
+ target = Function(
514
+ `${model.code} ;return ${model.name}`
515
+ )()
516
+ } else {
517
+ log('Evaluating code from string (no name)')
518
+ target = eval(`(${model.code})`) // ( ͡° ͜ʖ ͡°) YEAHVAL
519
+ }
520
+ } else {
521
+ target = model.code
522
+ }
523
+
524
+ const modelFunc = await utils.getModelFuncJS(model, target, this)
525
+ return modelFunc
526
+ }
527
+
528
+ initAPI () {
529
+ this.overlay.hide()
530
+ if (this.schema.model.worker) {
531
+ // Worker:
532
+ this.worker.postMessage(this.schema.model)
533
+ } else {
534
+ // Main:
535
+ this.modelFunc = utils.getModelFuncAPI(model, log)
536
+ }
537
+ }
538
+
539
+ initTF () {
540
+ let script = document.createElement('script')
541
+ script.src = 'dist/tf.min.js'
542
+ script.onload = () => {
543
+ log('Loaded TF.js')
544
+ this.overlay.hide()
545
+ window['tf'].loadLayersModel(this.schema.model.url).then(res => {
546
+ log('Loaded Tensorflow model')
547
+ })
548
+ }
549
+ document.head.appendChild(script)
550
+ }
551
+
552
+ async run (caller='run') {
553
+ // caller can be:
554
+ // 1. custom input button name
555
+ // 2. `run`
556
+ // 3. `autorun`
557
+ const schema = this.schema
558
+ const data = this.data
559
+ this.running = true
560
+
561
+ log('Running the pipeline...')
562
+ // Collect input values
563
+ let inputValues = {}
564
+ data.inputs.forEach(input => {
565
+ // Skip buttons
566
+ if (input.name && !(input.type == 'action' || input.type == 'button')) {
567
+ inputValues[input.name] = getValue(input)
568
+ }
569
+ })
570
+ // Add caller to input values so we can change model behavior based on it
571
+ inputValues.caller = caller
572
+
573
+ log('Input values:', inputValues)
574
+ // We have all input values here, pass them to worker, window.modelFunc or tf
575
+ if (!schema.model.autorun) {
576
+ this.overlay.show()
577
+ }
578
+
579
+ // Run pipeline
580
+ const results = await this.pipeline(inputValues)
581
+
582
+ // Output results
583
+ this.output(results)
584
+
585
+ // Check if interval is defined
586
+ if (schema.interval && this.running && (caller === 'run')) {
587
+ log('Interval is defined:', schema.interval)
588
+ await utils.delay(schema.interval)
589
+ await this.run(caller)
590
+ }
591
+
592
+ // Hide overlay
593
+ this.overlay.hide()
594
+ return
595
+ }
596
+
597
+ async outputAsync (res) {
598
+ this.output(res)
599
+ await delay(1)
600
+ }
601
+
602
+ output (res) {
603
+ // TODO: Think about all edge cases
604
+ // * No output field, but reactivity
605
+
606
+ if (typeof res === 'undefined') {
607
+ return
608
+ }
609
+
610
+ log('[Output] Got output results of type:', typeof res)
611
+
612
+ // Process results (res)
613
+ const inputNames = this.schema.inputs.map(i => i.name)
614
+ if (isObject(res) && Object.keys(res).every(key => inputNames.includes(key))) {
615
+ // Update inputs from results
616
+ log('Updating inputs:', Object.keys(res))
617
+ this.data.inputs.forEach((input, i) => {
618
+ if (input.name && (typeof res[input.name] !== 'undefined')) {
619
+ log('Updating input: ', input.name, 'with data:', res[input.name])
620
+ const r = res[input.name]
621
+ if (typeof r === 'object') {
622
+ Object.keys(r).forEach(k => {
623
+ input[k] = r[k]
624
+ })
625
+ } else {
626
+ input.value = r
627
+ }
628
+ }
629
+ })
630
+ } else if (this.renderFunc) {
631
+ // Pass results to a custom render function
632
+ log('Calling a render function...')
633
+ this.renderFunc(res, this)
634
+ } else if (Array.isArray(res) && res.length) {
635
+ // Result is array
636
+ if (this.data.outputs && this.data.outputs.length) {
637
+ // We have outputs defined
638
+ if (this.data.outputs.length === res.length) {
639
+ // Same length
640
+ this.data.outputs.forEach((output, i) => {
641
+ output.value = res[i]
642
+ })
643
+ } else {
644
+ // Different length
645
+ this.data.outputs[0].value = res
646
+ }
647
+ } else {
648
+ // Outputs are not defined
649
+ this.data.outputs = [{
650
+ 'type': 'array',
651
+ 'value': res
652
+ }]
653
+ }
654
+ } else if (typeof res === 'object') {
655
+ if (this.data.outputs && this.data.outputs.length) {
656
+ this.data.outputs.forEach((output, i) => {
657
+ if (output.name && (typeof res[output.name] !== 'undefined')) {
658
+ log('Updating output: ', output.name)
659
+ output.value = res[output.name]
660
+ }
661
+ })
662
+ } else {
663
+ this.data.outputs = [{
664
+ 'type': 'object',
665
+ 'value': res
666
+ }]
667
+ }
668
+ } else if (this.schema.outputs && this.schema.outputs.length === 1) {
669
+ // One output value passed as raw js object
670
+ this.data.outputs[0].value = res
671
+ } else {
672
+ this.data.outputs = [{
673
+ 'type': typeof res,
674
+ 'value': res
675
+ }]
676
+ }
677
+ }
678
+ }