@sasjs/cli 5.1.1 → 6.1.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.
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Browser runtime host for a project's JS stored programs.
3
+ *
4
+ * Runs the sasjs/server JS "stored program" contract inside a Web Worker:
5
+ * a generated wrapper declares the request parameters as module-scope consts,
6
+ * predeclares `fs` / `weboutPath` / `_SASJS_*` exactly as
7
+ * controllers/internal/createJSProgram.ts does, executes the program text, and
8
+ * the program's `_webout` assignment is read back from the virtual drive.
9
+ *
10
+ * The virtual drive seeds from vfs-seed.json, which holds the project's own
11
+ * JS services unchanged, so the services themselves are not adapted in any
12
+ * way.
13
+ */
14
+
15
+ if (typeof importScripts === 'function') importScripts('/host/md5.js')
16
+
17
+ // ---------------------------------------------------------------- posix path
18
+ const P = {
19
+ sep: '/',
20
+ normalise(p) {
21
+ const isAbs = p.charAt(0) === '/'
22
+ const out = []
23
+ for (const part of p.split('/')) {
24
+ if (!part || part === '.') continue
25
+ if (part === '..') {
26
+ if (out.length && out[out.length - 1] !== '..') out.pop()
27
+ else if (!isAbs) out.push('..')
28
+ } else out.push(part)
29
+ }
30
+ const joined = out.join('/')
31
+ return isAbs ? '/' + joined : joined || '.'
32
+ },
33
+ join(...args) {
34
+ return (
35
+ this.normalise(
36
+ args
37
+ .filter((a) => a !== undefined && a !== null && String(a) !== '')
38
+ .join('/')
39
+ ) || '.'
40
+ )
41
+ },
42
+ resolve(...args) {
43
+ let resolved = ''
44
+ for (let i = args.length - 1; i >= 0; i--) {
45
+ const a = String(args[i] === undefined || args[i] === null ? '' : args[i])
46
+ if (!a) continue
47
+ resolved = a + '/' + resolved
48
+ if (a.charAt(0) === '/') break
49
+ }
50
+ if (resolved.charAt(0) !== '/') resolved = '/' + resolved
51
+ return this.normalise(resolved)
52
+ },
53
+ dirname(p) {
54
+ const n = this.normalise(p)
55
+ const i = n.lastIndexOf('/')
56
+ return i <= 0 ? '/' : n.slice(0, i)
57
+ },
58
+ basename(p) {
59
+ const n = this.normalise(p)
60
+ return n.slice(n.lastIndexOf('/') + 1)
61
+ },
62
+ extname(p) {
63
+ const b = this.basename(p)
64
+ const i = b.lastIndexOf('.')
65
+ return i > 0 ? b.slice(i) : ''
66
+ }
67
+ }
68
+
69
+ // ------------------------------------------------------------- virtual drive
70
+ const FILES = new Map() // absolute path -> string contents
71
+ const DIRS = new Set(['/'])
72
+
73
+ // ------------------------------------------------------------ persistence
74
+ // The drive lives in this worker's memory. IndexedDB is owned by the PAGE
75
+ // (a service worker can be terminated between requests, and in some browsers
76
+ // worker and page storage are not shared), so the page hands the drive in at
77
+ // boot and receives the changed files back after each run.
78
+ const dirty = new Map() // abs path -> contents, or null when deleted
79
+
80
+ const markDirty = (path) => {
81
+ const contents = FILES.has(path) ? FILES.get(path) : null
82
+ dirty.set(path, contents)
83
+ }
84
+
85
+ const takeDirty = () => {
86
+ const out = {}
87
+ for (const [path, contents] of dirty) out[path] = contents
88
+ dirty.clear()
89
+ return out
90
+ }
91
+
92
+ const dumpAll = () => {
93
+ const out = {}
94
+ for (const [path, contents] of FILES) out[path] = contents
95
+ return out
96
+ }
97
+
98
+ const loadSnapshot = (files) => {
99
+ FILES.clear()
100
+ DIRS.clear()
101
+ DIRS.add('/')
102
+ mkdirp(DRIVE_APP)
103
+ mkdirp(`${DRIVE_ROOT}/files`)
104
+ for (const [path, contents] of Object.entries(files || {})) {
105
+ if (typeof contents !== 'string') continue
106
+ FILES.set(P.resolve(path), contents)
107
+ mkdirp(P.dirname(path))
108
+ }
109
+ lastBoot = {
110
+ booted: true,
111
+ restored: true,
112
+ files: FILES.size,
113
+ at: new Date().toISOString()
114
+ }
115
+ return lastBoot
116
+ }
117
+
118
+ const mkdirp = (dir) => {
119
+ const parts = P.normalise(dir).split('/').filter(Boolean)
120
+ let acc = ''
121
+ for (const part of parts) {
122
+ acc += '/' + part
123
+ DIRS.add(acc)
124
+ }
125
+ }
126
+
127
+ const FS = {
128
+ readFileSync(p, opts) {
129
+ const abs = P.resolve(String(p))
130
+ if (!FILES.has(abs)) {
131
+ const err = new Error(
132
+ `ENOENT: no such file or directory, open '${String(p)}'`
133
+ )
134
+ err.code = 'ENOENT'
135
+ throw err
136
+ }
137
+ return FILES.get(abs)
138
+ },
139
+ existsSync(p) {
140
+ const abs = P.resolve(String(p))
141
+ return FILES.has(abs) || DIRS.has(abs)
142
+ },
143
+ writeFileSync(p, data) {
144
+ const abs = P.resolve(String(p))
145
+ mkdirp(P.dirname(abs))
146
+ FILES.set(abs, String(data))
147
+ markDirty(abs)
148
+ },
149
+ writeFile(p, data, cb) {
150
+ try {
151
+ FS.writeFileSync(p, data)
152
+ if (cb) cb(null)
153
+ } catch (e) {
154
+ if (cb) cb(e)
155
+ else throw e
156
+ }
157
+ },
158
+ mkdirSync(p) {
159
+ mkdirp(P.resolve(String(p)))
160
+ },
161
+ readdirSync(p) {
162
+ const abs = P.resolve(String(p))
163
+ const pre = abs === '/' ? '/' : abs + '/'
164
+ const out = new Set()
165
+ for (const k of FILES.keys()) {
166
+ if (k.startsWith(pre)) {
167
+ const rest = k.slice(pre.length)
168
+ if (rest && !rest.includes('/')) out.add(rest)
169
+ }
170
+ }
171
+ for (const d of DIRS) {
172
+ if (d.startsWith(pre)) {
173
+ const rest = d.slice(pre.length)
174
+ if (rest && !rest.includes('/')) out.add(rest)
175
+ }
176
+ }
177
+ return [...out].sort()
178
+ },
179
+ statSync(p) {
180
+ const abs = P.resolve(String(p))
181
+ if (DIRS.has(abs))
182
+ return { isDirectory: () => true, isFile: () => false, size: 0 }
183
+ if (FILES.has(abs)) {
184
+ const size = FILES.get(abs).length
185
+ return { isDirectory: () => false, isFile: () => true, size }
186
+ }
187
+ const err = new Error(
188
+ `ENOENT: no such file or directory, stat '${String(p)}'`
189
+ )
190
+ err.code = 'ENOENT'
191
+ throw err
192
+ },
193
+ unlinkSync(p) {
194
+ const abs = P.resolve(String(p))
195
+ FILES.delete(abs)
196
+ markDirty(abs)
197
+ },
198
+ copyFileSync(from, to) {
199
+ const src = P.resolve(String(from))
200
+ if (!FILES.has(src))
201
+ throw new Error(`ENOENT: no such file, copy '${String(from)}'`)
202
+ FS.writeFileSync(to, FILES.get(src))
203
+ }
204
+ }
205
+
206
+ const CRYPTO = {
207
+ createHash(alg) {
208
+ if (String(alg).toLowerCase() !== 'md5')
209
+ throw new Error(`Unsupported hash in browser host: ${alg}`)
210
+ let buf = ''
211
+ return {
212
+ update(s) {
213
+ buf += String(s)
214
+ return this
215
+ },
216
+ digest(enc) {
217
+ const hex = MD5(buf)
218
+ return enc === 'hex' ? hex : hex
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ const requireShim = (name) => {
225
+ switch (name) {
226
+ case 'path':
227
+ return P
228
+ case 'fs':
229
+ return FS
230
+ case 'crypto':
231
+ return CRYPTO
232
+ default:
233
+ throw new Error(
234
+ `Cannot find module '${name}' in the browser runtime host`
235
+ )
236
+ }
237
+ }
238
+
239
+ // ------------------------------------------------------------------ commands
240
+ const DRIVE_ROOT = '/sasjs/drive'
241
+ // Rewritten at boot from host/mock-config.json, which the mock build writes.
242
+ let APP_LOC = '/Public/app'
243
+ let DRIVE_APP = `${DRIVE_ROOT}/files${APP_LOC}`
244
+ const SEED_PATH = '/host/vfs-seed.json'
245
+ const SNAPSHOT_PATH = '/host/vfs-snapshot.json'
246
+
247
+ /**
248
+ * Reads the generated mock-config.json (appLoc and provenance). Absent or
249
+ * malformed, the defaults above apply - a demo can then only work when the
250
+ * services sit under the default appLoc.
251
+ */
252
+ const loadMockConfig = async () => {
253
+ try {
254
+ const res = await fetch('/host/mock-config.json', { cache: 'no-store' })
255
+ if (!res.ok) return
256
+ const cfg = await res.json()
257
+ if (cfg && typeof cfg.appLoc === 'string' && cfg.appLoc.trim()) {
258
+ APP_LOC = '/' + cfg.appLoc.replace(/^\/+/, '').replace(/\/+$/, '')
259
+ DRIVE_APP = `${DRIVE_ROOT}/files${APP_LOC}`
260
+ }
261
+ } catch (e) {
262
+ /* keep the defaults */
263
+ }
264
+ }
265
+
266
+ let sessionSeq = 0
267
+
268
+ const formatArg = (a) => {
269
+ if (typeof a === 'string') return a
270
+ try {
271
+ return JSON.stringify(a)
272
+ } catch (e) {
273
+ return String(a)
274
+ }
275
+ }
276
+
277
+ const PROGRAM_FOLDERS = ['services', 'jobs', 'tests']
278
+
279
+ /**
280
+ * Resolves a program reference the way the server does: an exact drive path,
281
+ * with the runtime extension tried. The adapter sends a full path, and prepends
282
+ * the appLoc when the app passes one without a leading slash, so a reference
283
+ * naming no program folder (e.g. "getdata", or "<appLoc>/getdata") is also
284
+ * tried under services, jobs and tests below the appLoc.
285
+ */
286
+ const resolveProgram = (program) => {
287
+ const relative = String(program).replace(/^\/+/, '')
288
+ const roots = [P.resolve(DRIVE_ROOT, 'files', relative)]
289
+
290
+ const namesAFolder = PROGRAM_FOLDERS.some(
291
+ (folder) => relative === folder || relative.startsWith(folder + '/')
292
+ )
293
+ if (!namesAFolder) {
294
+ // DRIVE_APP already carries the appLoc, and it is absolute: joining it with
295
+ // P.resolve keeps the drive prefix that the literal appLoc would replace.
296
+ const underAppLoc = relative.startsWith(APP_LOC.slice(1) + '/')
297
+ ? relative.slice(APP_LOC.length)
298
+ : relative
299
+ for (const folder of PROGRAM_FOLDERS) {
300
+ roots.push(P.join(DRIVE_APP, folder, underAppLoc))
301
+ }
302
+ }
303
+
304
+ for (const root of roots) {
305
+ for (const candidate of [root, root + '.js', root + '.sas']) {
306
+ if (FILES.has(candidate)) return candidate
307
+ }
308
+ }
309
+
310
+ return null
311
+ }
312
+
313
+ const runProgram = ({ program, code, params = {}, tables = [] }) => {
314
+ const payloadCode = code
315
+ const codePath = program ? resolveProgram(program) : null
316
+ if (!codePath && !payloadCode)
317
+ throw new Error(`Program not found in drive: ${program}`)
318
+
319
+ const logLines = []
320
+ const sandboxConsole = {}
321
+ for (const level of ['log', 'info', 'warn', 'error', 'debug']) {
322
+ sandboxConsole[level] = (...args) => {
323
+ const line = args.map(formatArg).join(' ')
324
+ logLines.push(line)
325
+ // eslint-disable-next-line no-console
326
+ console[level === 'debug' ? 'log' : level](`[service] ${line}`)
327
+ }
328
+ }
329
+
330
+ const sessionDir = `/sasjs/sessions/s${++sessionSeq}`
331
+ mkdirp(sessionDir)
332
+ const weboutPath = `${sessionDir}/webout.txt`
333
+ const headersPath = `${sessionDir}/stpsrv_header.txt`
334
+ const tokenFile = `${sessionDir}/reqHeaders.txt`
335
+ FILES.set(weboutPath, '')
336
+ FILES.set(headersPath, '')
337
+ FILES.set(tokenFile, '')
338
+
339
+ const varStatements = Object.entries(params)
340
+ .filter(([name]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
341
+ .map(([name, value]) => `const ${name} = ${JSON.stringify(String(value))};`)
342
+ .join('\n')
343
+
344
+ const webinStatements = []
345
+ tables.forEach((table, idx) => {
346
+ const n = idx + 1
347
+ webinStatements.push(
348
+ `const _WEBIN_NAME${n} = ${JSON.stringify(table.name)};`
349
+ )
350
+ webinStatements.push(
351
+ `const _WEBIN_FILENAME${n} = ${JSON.stringify(table.filename)};`
352
+ )
353
+ webinStatements.push(
354
+ `const _WEBIN_FILEREF${n} = ${JSON.stringify(table.csv)};`
355
+ )
356
+ })
357
+ webinStatements.push(`const _WEBIN_FILE_COUNT = ${tables.length};`)
358
+
359
+ const preProgram = `
360
+ let _webout = '';
361
+ const weboutPath = ${JSON.stringify(weboutPath)};
362
+ const _SASJS_TOKENFILE = ${JSON.stringify(tokenFile)};
363
+ const _SASJS_WEBOUT_HEADERS = ${JSON.stringify(headersPath)};
364
+ const _SASJS_USERNAME = "DEMO";
365
+ const _SASJS_USERID = "0";
366
+ const _SASJS_DISPLAYNAME = "Demo User";
367
+ const _METAPERSON = _SASJS_DISPLAYNAME;
368
+ const _METAUSER = _SASJS_USERNAME;
369
+ const SASJSPROCESSMODE = 'Stored Program';
370
+ `
371
+
372
+ const source = `
373
+ const fs = require('fs');
374
+ ${varStatements}
375
+ ${webinStatements.join('\n')}
376
+ ${preProgram}
377
+ ${payloadCode || FILES.get(codePath)}
378
+ if (_webout) { fs.writeFile(weboutPath, _webout, function () {}); }
379
+ return _webout;
380
+ `
381
+
382
+ const fn = new Function('require', 'console', 'process', source)
383
+
384
+ let thrown = null
385
+ let returned = ''
386
+ try {
387
+ returned = fn(requireShim, sandboxConsole, {
388
+ pid: 4242,
389
+ env: { NODE_ENV: 'browser' }
390
+ })
391
+ } catch (e) {
392
+ thrown = e && e.stack ? e.stack : String(e)
393
+ logLines.push(String(thrown))
394
+ }
395
+
396
+ const fromFile = FILES.get(weboutPath) || ''
397
+ const webout = fromFile || (typeof returned === 'string' ? returned : '')
398
+
399
+ return {
400
+ webout,
401
+ log: logLines.join('\n'),
402
+ headers: FILES.get(headersPath) || '',
403
+ error: thrown
404
+ }
405
+ }
406
+
407
+ const listFolder = (folderPath) => {
408
+ const abs = folderPath
409
+ ? P.resolve(
410
+ DRIVE_APP,
411
+ String(folderPath)
412
+ .replace(/^\/+/, '')
413
+ .replace(new RegExp('^' + APP_LOC + '/'), '')
414
+ )
415
+ : DRIVE_APP
416
+ const alt = folderPath
417
+ ? P.resolve(DRIVE_ROOT, 'files', String(folderPath).replace(/^\/+/, ''))
418
+ : DRIVE_APP
419
+ const target = DIRS.has(abs) ? abs : alt
420
+ if (!DIRS.has(target) && !FILES.has(target)) {
421
+ const err = new Error(`Folder doesn't exist.`)
422
+ err.status = 404
423
+ throw err
424
+ }
425
+ const pre = target === '/' ? '/' : target + '/'
426
+ const files = []
427
+ const folders = []
428
+ for (const k of FILES.keys()) {
429
+ if (k.startsWith(pre)) {
430
+ const rest = k.slice(pre.length)
431
+ if (!rest.includes('/')) files.push(rest)
432
+ }
433
+ }
434
+ for (const d of DIRS) {
435
+ if (d.startsWith(pre)) {
436
+ const rest = d.slice(pre.length)
437
+ if (rest && !rest.includes('/')) folders.push(rest)
438
+ }
439
+ }
440
+ return { files: files.sort(), folders: folders.sort() }
441
+ }
442
+
443
+ const readDriveFile = (filePath) => {
444
+ const abs = P.resolve(
445
+ DRIVE_ROOT,
446
+ 'files',
447
+ String(filePath).replace(/^\/+/, '')
448
+ )
449
+ if (!FILES.has(abs)) {
450
+ const err = new Error('File not found')
451
+ err.status = 404
452
+ throw err
453
+ }
454
+ return FILES.get(abs)
455
+ }
456
+
457
+ const buildTree = () => {
458
+ const walk = (dir) => {
459
+ const node = {
460
+ name: P.basename(dir) || 'files',
461
+ relativePath: dir.slice(DRIVE_ROOT.length + '/files'.length),
462
+ absolutePath: dir,
463
+ isFolder: true,
464
+ children: []
465
+ }
466
+ node.relativePath = node.relativePath === '' ? '' : node.relativePath
467
+ for (const child of FS.readdirSync(dir)) {
468
+ const childAbs = `${dir}/${child}`
469
+ if (DIRS.has(childAbs)) node.children.push(walk(childAbs))
470
+ else {
471
+ node.children.push({
472
+ name: child,
473
+ relativePath: childAbs.slice(DRIVE_ROOT.length + '/files'.length),
474
+ absolutePath: childAbs,
475
+ isFolder: false,
476
+ children: []
477
+ })
478
+ }
479
+ }
480
+ return node
481
+ }
482
+ return { status: 'success', tree: walk(`${DRIVE_ROOT}/files`) }
483
+ }
484
+
485
+ // -------------------------------------------------------------------- boot
486
+ let ready = null
487
+ let lastBoot = null
488
+
489
+ /**
490
+ * Loads the drive the site ships with. A snapshot wins over the plain seed: it
491
+ * is a complete drive captured after a project's own services had run (a
492
+ * seeded demo site, for instance), so a visitor starts from data instead of
493
+ * from an empty drive.
494
+ */
495
+ const fetchDrive = async () => {
496
+ for (const [source, url] of [
497
+ ['snapshot', SNAPSHOT_PATH],
498
+ ['seed', SEED_PATH]
499
+ ]) {
500
+ try {
501
+ const res = await fetch(url, { cache: 'no-store' })
502
+ if (!res.ok) continue
503
+ const body = await res.json()
504
+ const files = body && body.files ? body.files : body
505
+ if (files && typeof files === 'object') return { source, files }
506
+ } catch (e) {
507
+ /* fall through to the next source */
508
+ }
509
+ }
510
+
511
+ throw new Error(
512
+ 'No drive to load: neither ' +
513
+ SNAPSHOT_PATH +
514
+ ' nor ' +
515
+ SEED_PATH +
516
+ ' could be read'
517
+ )
518
+ }
519
+
520
+ const boot = async () => {
521
+ await loadMockConfig()
522
+ const { source, files: seed } = await fetchDrive()
523
+ mkdirp(DRIVE_APP)
524
+ mkdirp(`${DRIVE_ROOT}/files`)
525
+ for (const [path, contents] of Object.entries(seed)) {
526
+ FILES.set(P.resolve(path), contents)
527
+ mkdirp(P.dirname(path))
528
+ }
529
+ dirty.clear()
530
+
531
+ // No bootstrap: seeding a drive is an ordinary service call, made by the app
532
+ // (or an operator) the same way it would be against a server. The host knows
533
+ // nothing about what a project's services do.
534
+ // Mark the whole drive dirty once, so the host persists a COMPLETE snapshot:
535
+ // later restores then need no merging with the seed, and a deleted file (a
536
+ // seeder service that removes itself) stays deleted.
537
+ for (const path of FILES.keys()) markDirty(path)
538
+
539
+ lastBoot = {
540
+ booted: true,
541
+ restored: false,
542
+ source,
543
+ files: FILES.size,
544
+ at: new Date().toISOString()
545
+ }
546
+ return lastBoot
547
+ }
548
+
549
+ const reset = async () => {
550
+ FILES.clear()
551
+ DIRS.clear()
552
+ DIRS.add('/')
553
+ dirty.clear()
554
+ started = true
555
+ const p = boot()
556
+ ready = p
557
+ if (resolveBoot) resolveBoot(p)
558
+ await p
559
+ return true
560
+ }
561
+
562
+ // Nothing runs until the host says whether it has a persisted drive or wants
563
+ // a fresh seed; ensureReady() seeds after a short grace if it hears neither.
564
+ let started = false
565
+ let resolveBoot
566
+ ready = new Promise((resolve) => {
567
+ resolveBoot = resolve
568
+ })
569
+
570
+ const api = {
571
+ ensureReady: async () => {
572
+ if (!started) {
573
+ await new Promise((r) => setTimeout(r, 600))
574
+ }
575
+ if (!started) await api.seed()
576
+ await ready
577
+ return true
578
+ },
579
+ // Called by the host with the persisted drive; skips seeding entirely.
580
+ load: (files) => {
581
+ started = true
582
+ const info = loadSnapshot(files)
583
+ ready = Promise.resolve(info)
584
+ if (resolveBoot) resolveBoot(info)
585
+ return ready
586
+ },
587
+ seed: () => {
588
+ started = true
589
+ const p = boot().catch((e) => ({
590
+ booted: false,
591
+ error: (e && e.message) || String(e)
592
+ }))
593
+ ready = p
594
+ if (resolveBoot) resolveBoot(p)
595
+ return p
596
+ },
597
+ reset: () => reset(),
598
+ takeDirty: () => takeDirty(),
599
+ dumpAll: () => dumpAll(),
600
+ bootInfo: () => lastBoot,
601
+ stats: () => ({
602
+ files: FILES.size,
603
+ bytes: [...FILES.values()].reduce(
604
+ (total, contents) => total + contents.length,
605
+ 0
606
+ )
607
+ }),
608
+ run: (payload) => runProgram(payload),
609
+ list: (folder) => listFolder(folder),
610
+ read: (file) => ({ contents: readDriveFile(file) }),
611
+ tree: () => buildTree()
612
+ }
613
+
614
+ self.SASjsMockRuntime = api
615
+
616
+ if (typeof module !== 'undefined') module.exports = api