@sasjs/cli 6.0.0 → 6.2.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,1230 @@
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
+ * CONTRACT. A stored program gets what the server's generated wrapper gives it:
15
+ * the request parameters as module-scope consts (`_program` included), the
16
+ * uploaded tables as `_WEBIN_*` refs, `fs`/`path`/`crypto` from require(),
17
+ * `Buffer`, and `_webout` / `weboutPath` / `_SASJS_WEBOUT_HEADERS`. Two ways to
18
+ * answer a request:
19
+ *
20
+ * 1. text - assign `_webout`, or write a string to `weboutPath`;
21
+ * 2. bytes - write an ArrayBuffer, a TypedArray or a Buffer to `weboutPath`,
22
+ * the way a service that streams a file does on the server.
23
+ *
24
+ * In both cases the response headers are whatever lines the service wrote to
25
+ * `_SASJS_WEBOUT_HEADERS` (`Content-type`, `Content-Disposition`); the host
26
+ * applies them to the response. A byte body comes back to the host as base64
27
+ * with a `binary` flag, because the drive itself holds strings.
28
+ *
29
+ * One place the drive is not node: `fs.readFileSync` without an encoding hands
30
+ * TEXT back as text, which is what a service reading a config, a CSV or a
31
+ * service file expects - a byte file still reads back as a Buffer, and an
32
+ * encoding returns a string for either, as node does.
33
+ */
34
+
35
+ if (typeof importScripts === 'function') importScripts('/host/md5.js')
36
+
37
+ // ---------------------------------------------------------------- posix path
38
+ const P = {
39
+ sep: '/',
40
+ normalise(p) {
41
+ const isAbs = p.charAt(0) === '/'
42
+ const out = []
43
+ for (const part of p.split('/')) {
44
+ if (!part || part === '.') continue
45
+ if (part === '..') {
46
+ if (out.length && out[out.length - 1] !== '..') out.pop()
47
+ else if (!isAbs) out.push('..')
48
+ } else out.push(part)
49
+ }
50
+ const joined = out.join('/')
51
+ return isAbs ? '/' + joined : joined || '.'
52
+ },
53
+ join(...args) {
54
+ return (
55
+ this.normalise(
56
+ args
57
+ .filter((a) => a !== undefined && a !== null && String(a) !== '')
58
+ .join('/')
59
+ ) || '.'
60
+ )
61
+ },
62
+ resolve(...args) {
63
+ let resolved = ''
64
+ for (let i = args.length - 1; i >= 0; i--) {
65
+ const a = String(args[i] === undefined || args[i] === null ? '' : args[i])
66
+ if (!a) continue
67
+ resolved = a + '/' + resolved
68
+ if (a.charAt(0) === '/') break
69
+ }
70
+ if (resolved.charAt(0) !== '/') resolved = '/' + resolved
71
+ return this.normalise(resolved)
72
+ },
73
+ dirname(p) {
74
+ const n = this.normalise(p)
75
+ const i = n.lastIndexOf('/')
76
+ return i <= 0 ? '/' : n.slice(0, i)
77
+ },
78
+ basename(p) {
79
+ const n = this.normalise(p)
80
+ return n.slice(n.lastIndexOf('/') + 1)
81
+ },
82
+ extname(p) {
83
+ const b = this.basename(p)
84
+ const i = b.lastIndexOf('.')
85
+ return i > 0 ? b.slice(i) : ''
86
+ }
87
+ }
88
+
89
+ // ------------------------------------------------------------ binary values
90
+ // The drive holds strings: it is persisted as JSON in IndexedDB and seeded from
91
+ // a JSON file. A file a service writes as bytes is therefore stored as base64
92
+ // behind a marker, which keeps the representation self-describing - a drive
93
+ // restored from a snapshot still knows which entries are base64 - and no text
94
+ // file starts with a NUL.
95
+ const BINARY_PREFIX = '\u0000base64:'
96
+ // a text file that would otherwise look like a marker is stored behind this, so
97
+ // a value a service wrote can never be mistaken for a byte entry
98
+ const TEXT_PREFIX = '\u0000text:'
99
+ const BUFFER_MARK = '__sasjsBuffer'
100
+ const CHUNK = 0x8000
101
+
102
+ const chunked = (bytes) => {
103
+ let out = ''
104
+ for (let i = 0; i < bytes.length; i += CHUNK)
105
+ out += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK))
106
+ return out
107
+ }
108
+
109
+ const bytesToBase64 = (bytes) => btoa(chunked(bytes))
110
+
111
+ /**
112
+ * Node's base64 decoding: the url-safe alphabet counts, anything that is not a
113
+ * base64 character is ignored, and a leftover single character (which cannot be
114
+ * decoded) is dropped rather than throwing.
115
+ */
116
+ const base64ToBytes = (base64) => {
117
+ const clean = String(base64)
118
+ .replace(/-/g, '+')
119
+ .replace(/_/g, '/')
120
+ .replace(/[^A-Za-z0-9+/]/g, '')
121
+ const usable = clean.length % 4 === 1 ? clean.slice(0, -1) : clean
122
+ try {
123
+ return Uint8Array.from(atob(usable), (char) => char.charCodeAt(0))
124
+ } catch (e) {
125
+ return new Uint8Array(0)
126
+ }
127
+ }
128
+
129
+ /** Node's hex decoding: pairs, stopping at the first pair that is not hex. */
130
+ const hexToBytes = (value) => {
131
+ const text = String(value)
132
+ const bytes = []
133
+ for (let i = 0; i + 1 < text.length; i += 2) {
134
+ const pair = text.slice(i, i + 2)
135
+ if (!/^[0-9a-fA-F]{2}$/.test(pair)) break
136
+ bytes.push(parseInt(pair, 16))
137
+ }
138
+ return Uint8Array.from(bytes)
139
+ }
140
+
141
+ /**
142
+ * What node accepts as raw bytes: a Buffer or other Uint8Array, an ArrayBuffer,
143
+ * or a view over one. Arrays and strings are not bytes here - node rejects them
144
+ * where it wants a buffer, and a mock that quietly accepts them hides a service
145
+ * that would fail on the server.
146
+ */
147
+ const asBufferInput = (value) => {
148
+ if (value instanceof Uint8Array) return value
149
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
150
+ if (ArrayBuffer.isView(value))
151
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
152
+ return null
153
+ }
154
+
155
+ /**
156
+ * The bytes behind a value Buffer.from accepts: the byte-like inputs plus a
157
+ * plain array of numbers, which node allows there and nowhere else.
158
+ */
159
+ const asBytes = (data) => {
160
+ const bytes = asBufferInput(data)
161
+ if (bytes) return bytes
162
+ if (Array.isArray(data)) return Uint8Array.from(data)
163
+ return null
164
+ }
165
+
166
+ /** How node names a value it refused, so the error names the same rule. */
167
+ const describeValue = (value) => {
168
+ if (value === null) return 'null'
169
+ if (Array.isArray(value)) return 'an instance of Array'
170
+ if (typeof value === 'string') return `type string ('${value}')`
171
+ if (typeof value !== 'object')
172
+ return `type ${typeof value} (${String(value)})`
173
+ return 'an instance of Object'
174
+ }
175
+
176
+ const isBinary = (contents) =>
177
+ typeof contents === 'string' && contents.startsWith(BINARY_PREFIX)
178
+
179
+ /** The text a stored entry holds, with the escape for a marker-like value off. */
180
+ const entryText = (contents) =>
181
+ contents.startsWith(TEXT_PREFIX)
182
+ ? contents.slice(TEXT_PREFIX.length)
183
+ : contents
184
+
185
+ /** What the drive stores for a string a service wrote. */
186
+ const encodeText = (data) =>
187
+ data.startsWith('\u0000') ? TEXT_PREFIX + data : data
188
+
189
+ const utf8Encoder = new TextEncoder()
190
+ const utf8Decoder = new TextDecoder()
191
+ const utf16Decoder = new TextDecoder('utf-16le', { ignoreBOM: true })
192
+
193
+ /**
194
+ * The stored base64 of a byte entry, stripped of whitespace and checked, so
195
+ * every read of the entry - decoding it, measuring it - starts from the same
196
+ * validated string.
197
+ *
198
+ * node's own base64 decoder ignores what it cannot use, but a drive entry is
199
+ * written by the host as base64 and nothing else, so a corrupt one is a host
200
+ * bug: it fails here with the same complaint the worker gives when it serves the
201
+ * file, rather than reading as empty bytes in one place and a 500 in another.
202
+ */
203
+ const cleanedBase64 = (contents) => {
204
+ const clean = String(contents.slice(BINARY_PREFIX.length)).replace(/\s+/g, '')
205
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.length % 4)
206
+ throw corruptEntry()
207
+ return clean
208
+ }
209
+
210
+ const corruptEntry = () => {
211
+ const error = new Error('Corrupt binary entry on the drive: not valid base64')
212
+ error.status = 500
213
+ return error
214
+ }
215
+
216
+ /**
217
+ * The byte length node reports for a stored entry, worked out from the stored
218
+ * form rather than by materialising the bytes: the drive now carries seeded
219
+ * project files, and a multi-megabyte archive must not be decoded to answer a
220
+ * stat. The arithmetic is exactly what the decoder yields for the same string
221
+ * (three bytes per four base64 characters, less the padding), so the size and
222
+ * the contents cannot disagree.
223
+ */
224
+ const entrySize = (contents) => {
225
+ if (!isBinary(contents)) return utf8Encoder.encode(entryText(contents)).length
226
+ const base64 = cleanedBase64(contents)
227
+ return (
228
+ Math.floor((base64.length * 3) / 4) -
229
+ (base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0)
230
+ )
231
+ }
232
+
233
+ /** The bytes a stored entry holds, whatever representation it is behind. */
234
+ const entryBytes = (contents) =>
235
+ isBinary(contents)
236
+ ? base64ToBytes(cleanedBase64(contents))
237
+ : utf8Encoder.encode(entryText(contents))
238
+
239
+ /** Two byte sets joined, as an appending write leaves the file. */
240
+ const joinBytes = (first, second) => {
241
+ const out = new Uint8Array(first.length + second.length)
242
+ out.set(first, 0)
243
+ out.set(second, first.length)
244
+ return out
245
+ }
246
+
247
+ /** node's names for its encodings, including the aliases it accepts. */
248
+ const ENCODINGS = [
249
+ 'utf8',
250
+ 'utf-8',
251
+ 'base64',
252
+ 'base64url',
253
+ 'hex',
254
+ 'binary',
255
+ 'latin1',
256
+ 'ascii',
257
+ 'utf16le',
258
+ 'utf-16le',
259
+ 'ucs2',
260
+ 'ucs-2'
261
+ ]
262
+
263
+ const normaliseEncoding = (encoding) => {
264
+ const how = String(encoding || 'utf8').toLowerCase()
265
+ if (how === 'utf-8') return 'utf8'
266
+ if (how === 'ucs2' || how === 'ucs-2' || how === 'utf-16le') return 'utf16le'
267
+ return how
268
+ }
269
+
270
+ /** node refuses an encoding it does not implement, rather than guessing utf8. */
271
+ const assertEncoding = (encoding) => {
272
+ const how = normaliseEncoding(encoding)
273
+ if (!ENCODINGS.includes(how))
274
+ throw new TypeError(`Unknown encoding: ${String(encoding)}`)
275
+ return how
276
+ }
277
+
278
+ const toBase64Url = (base64) =>
279
+ base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
280
+
281
+ const fromBase64Url = (base64url) =>
282
+ String(base64url).replace(/-/g, '+').replace(/_/g, '/')
283
+
284
+ const bytesToString = (bytes, encoding) => {
285
+ switch (assertEncoding(encoding)) {
286
+ case 'base64':
287
+ return bytesToBase64(bytes)
288
+ case 'base64url':
289
+ return toBase64Url(bytesToBase64(bytes))
290
+ case 'hex':
291
+ return [...bytes]
292
+ .map((byte) => byte.toString(16).padStart(2, '0'))
293
+ .join('')
294
+ case 'binary':
295
+ case 'latin1':
296
+ return chunked(bytes)
297
+ case 'ascii':
298
+ // node's ascii decode drops the high bit
299
+ return chunked(Uint8Array.from(bytes, (byte) => byte & 0x7f))
300
+ case 'utf16le':
301
+ return utf16Decoder.decode(bytes)
302
+ default:
303
+ return utf8Decoder.decode(bytes)
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Gives a Uint8Array the decoding a Buffer has, and makes a view of it a Buffer
309
+ * too - a service that chunks a file with `subarray(i, j)` then decodes each
310
+ * chunk gets the chunk, not "80,75". The mark is non-enumerable and the value
311
+ * serialises as node's Buffer does, so a service putting one in a JSON body
312
+ * gets the same response from the mock as from the server.
313
+ */
314
+ const decorate = (view) => {
315
+ // the mark and the methods stay out of Object.keys and for..in, as they are
316
+ // on node's Buffer, where they live on the prototype
317
+ const hide = (name, value) =>
318
+ Object.defineProperty(view, name, {
319
+ value,
320
+ writable: true,
321
+ configurable: true
322
+ })
323
+
324
+ hide(BUFFER_MARK, true)
325
+ hide('toString', (encoding, start, end) => {
326
+ // node clamps a negative start to 0 and a negative end to 0, so an end
327
+ // below the start yields nothing rather than counting back from the end
328
+ const from = Math.max(0, start === undefined ? 0 : start)
329
+ const to = Math.max(0, end === undefined ? view.length : end)
330
+ if (to <= from) return ''
331
+ return bytesToString(
332
+ Uint8Array.prototype.subarray.call(view, from, to),
333
+ encoding
334
+ )
335
+ })
336
+ hide('subarray', (start, end) =>
337
+ decorate(Uint8Array.prototype.subarray.call(view, start, end))
338
+ )
339
+ // node's Buffer#slice is a view over the same memory, not a copy - the same
340
+ // operation as subarray, and the reason a service can edit a slice and see the
341
+ // edit in the original
342
+ hide('slice', (start, end) =>
343
+ decorate(Uint8Array.prototype.subarray.call(view, start, end))
344
+ )
345
+ hide('toJSON', () => ({ type: 'Buffer', data: Array.from(view) }))
346
+ Object.defineProperties(view, UNIMPLEMENTED_BUFFER_METHODS)
347
+ return view
348
+ }
349
+
350
+ /**
351
+ * node's Buffer carries many methods; the shim implements the ones a service
352
+ * needs to build a body. A call to another one names the gap rather than
353
+ * failing as "x.write is not a function", which reads like a bug in the service.
354
+ */
355
+ const UNIMPLEMENTED_BUFFER_METHODS = [
356
+ 'write',
357
+ 'equals',
358
+ 'compare',
359
+ 'indexOf',
360
+ 'includes',
361
+ 'copy',
362
+ 'fill',
363
+ 'readUInt8',
364
+ 'readUInt16LE',
365
+ 'readUInt16BE',
366
+ 'readUInt32LE',
367
+ 'readUInt32BE',
368
+ 'readInt8',
369
+ 'readInt16LE',
370
+ 'readInt16BE',
371
+ 'readInt32LE',
372
+ 'readInt32BE',
373
+ 'readFloatLE',
374
+ 'readFloatBE',
375
+ 'readDoubleLE',
376
+ 'readDoubleBE',
377
+ 'writeUInt8',
378
+ 'writeUInt16LE',
379
+ 'writeUInt16BE',
380
+ 'writeUInt32LE',
381
+ 'writeUInt32BE',
382
+ 'writeInt8',
383
+ 'writeInt16LE',
384
+ 'writeInt16BE',
385
+ 'writeInt32LE',
386
+ 'writeInt32BE',
387
+ 'swap16',
388
+ 'swap32',
389
+ 'swap64'
390
+ ].reduce((methods, name) => {
391
+ methods[name] = {
392
+ value: () => {
393
+ throw new TypeError(
394
+ `Buffer.${name} is not implemented in the browser host`
395
+ )
396
+ },
397
+ writable: true,
398
+ configurable: true
399
+ }
400
+ return methods
401
+ }, {})
402
+
403
+ /**
404
+ * A minimal Buffer, for the writes a streaming service makes:
405
+ * `Buffer.from(bytes)` and `Buffer.from(base64, 'base64')`. What it returns is
406
+ * a Uint8Array that also decodes itself, so a service writes it straight to the
407
+ * webout and reads it back the way node returns one.
408
+ */
409
+ const asBuffer = (bytes) => decorate(new Uint8Array(bytes))
410
+
411
+ /**
412
+ * node's Buffer.alloc: zero-filled, or filled with the value repeated - a byte
413
+ * for a number, the bytes of a string in its encoding, or the bytes of a
414
+ * buffer. A service building a padded payload depends on the repeat.
415
+ */
416
+ const allocBytes = (size, fill, encoding) => {
417
+ // node refuses a size that is not a number, and one it cannot make an array
418
+ // of - a silent empty buffer would hide a service that fails on the server
419
+ if (typeof size !== 'number')
420
+ throw new TypeError(
421
+ `The "size" argument must be of type number. Received ${describeValue(size)}`
422
+ )
423
+ const length = Number.isFinite(size) ? Math.trunc(size) : size
424
+ if (!(length >= 0) || !Number.isFinite(length))
425
+ throw new RangeError(
426
+ `The value of "size" is out of range. It must be >= 0 && <= 9007199254740991. Received ${size}`
427
+ )
428
+ const out = new Uint8Array(length)
429
+ if (fill === undefined || !out.length) return out
430
+ const bytes =
431
+ typeof fill === 'number'
432
+ ? Uint8Array.of(fill & 0xff)
433
+ : typeof fill === 'string'
434
+ ? BUFFERS.from(fill, encoding)
435
+ : asBytes(fill)
436
+ if (!bytes || !bytes.length) return out
437
+ for (let i = 0; i < out.length; i++) out[i] = bytes[i % bytes.length]
438
+ return out
439
+ }
440
+
441
+ const BUFFERS = {
442
+ /**
443
+ * node's Buffer.from overloads: (string, encoding), (arrayBuffer, byteOffset,
444
+ * length), and a single argument for anything else. The offset and length are
445
+ * read only for an ArrayBuffer - node ignores them for the contents of a view,
446
+ * so a chunk comes from subarray, not from a second argument.
447
+ */
448
+ from(value, offsetOrEncoding, length) {
449
+ if (typeof value === 'string') {
450
+ switch (assertEncoding(offsetOrEncoding)) {
451
+ case 'base64':
452
+ return asBuffer(base64ToBytes(value))
453
+ case 'base64url':
454
+ return asBuffer(base64ToBytes(fromBase64Url(value)))
455
+ case 'hex':
456
+ return asBuffer(hexToBytes(value))
457
+ case 'binary':
458
+ case 'latin1':
459
+ // node encodes 'ascii' as latin1 - the low byte of the code unit - and
460
+ // clears the high bit only when it decodes
461
+ case 'ascii':
462
+ return asBuffer(
463
+ Uint8Array.from(value, (char) => char.charCodeAt(0) & 0xff)
464
+ )
465
+ case 'utf16le': {
466
+ const bytes = new Uint8Array(value.length * 2)
467
+ for (let i = 0; i < value.length; i++) {
468
+ const code = value.charCodeAt(i)
469
+ bytes[i * 2] = code & 0xff
470
+ bytes[i * 2 + 1] = code >> 8
471
+ }
472
+ return asBuffer(bytes)
473
+ }
474
+ default:
475
+ return asBuffer(utf8Encoder.encode(value))
476
+ }
477
+ }
478
+ if (value instanceof ArrayBuffer) {
479
+ // a non-numeric second argument is not an offset, it is node's encoding
480
+ // slot, and node ignores it here rather than rejecting it
481
+ const start =
482
+ typeof offsetOrEncoding === 'number' ? Math.trunc(offsetOrEncoding) : 0
483
+ const end =
484
+ typeof length === 'number'
485
+ ? start + Math.trunc(length)
486
+ : value.byteLength
487
+ if (start < 0 || start > value.byteLength)
488
+ throw new RangeError(
489
+ `Start offset ${String(offsetOrEncoding)} is outside the bounds of the buffer`
490
+ )
491
+ if (end < start || end > value.byteLength)
492
+ throw new RangeError('"length" is outside of buffer bounds')
493
+ return asBuffer(new Uint8Array(value, start, end - start))
494
+ }
495
+ const bytes = asBytes(value)
496
+ if (!bytes)
497
+ throw new TypeError('Buffer.from: unsupported input in the browser host')
498
+ return asBuffer(bytes)
499
+ },
500
+ alloc: (size, fill, encoding) => asBuffer(allocBytes(size, fill, encoding)),
501
+ concat(list, totalLength) {
502
+ const items = list || []
503
+ const parts = items.map((item, index) => {
504
+ const bytes = asBufferInput(item)
505
+ if (!bytes)
506
+ throw new TypeError(
507
+ `The "list[${index}]" argument must be an instance of Buffer or Uint8Array. Received ${describeValue(item)}`
508
+ )
509
+ return bytes
510
+ })
511
+ const written = parts.reduce((sum, part) => sum + part.length, 0)
512
+ // node pads with zeros when totalLength is longer than the parts and cuts
513
+ // them short when it is shorter, rather than ignoring the argument
514
+ const total = totalLength === undefined ? written : totalLength
515
+ if (typeof total !== 'number')
516
+ throw new TypeError(
517
+ `The "length" argument must be of type number. Received ${describeValue(totalLength)}`
518
+ )
519
+ if (!Number.isInteger(total) || total < 0)
520
+ throw new RangeError(
521
+ `The value of "length" is out of range. It must be ${total < 0 ? '>= 0' : 'an integer'}. Received ${total}`
522
+ )
523
+ const out = new Uint8Array(total)
524
+ let offset = 0
525
+ for (const part of parts) {
526
+ if (offset >= total) break
527
+ const room = Math.min(part.length, total - offset)
528
+ out.set(part.subarray(0, room), offset)
529
+ offset += room
530
+ }
531
+ return asBuffer(out)
532
+ },
533
+ isBuffer: (value) => !!(value && value[BUFFER_MARK]),
534
+ byteLength(value, encoding) {
535
+ if (typeof value === 'string') return BUFFERS.from(value, encoding).length
536
+ const bytes = asBufferInput(value)
537
+ if (bytes) return bytes.length
538
+ throw new TypeError(
539
+ `The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received ${describeValue(value)}`
540
+ )
541
+ }
542
+ }
543
+
544
+ BUFFERS.allocUnsafe = BUFFERS.alloc
545
+
546
+ // ------------------------------------------------------------- virtual drive
547
+ const FILES = new Map() // absolute path -> string contents
548
+ const DIRS = new Set(['/'])
549
+ // abs path -> { contents, size }: the measure of the entry as stored. A cached
550
+ // entry is trusted only while it is the same string, so a write that replaces
551
+ // the contents recomputes - the cache cannot go stale, and a stat of a
552
+ // multi-megabyte seeded file does not scan it again.
553
+ const SIZES = new Map()
554
+
555
+ /** The byte length of an entry on the drive, measured once per stored value. */
556
+ const entrySizeAt = (abs) => {
557
+ const contents = FILES.get(abs)
558
+ const cached = SIZES.get(abs)
559
+ if (cached && cached.contents === contents) return cached.size
560
+ const size = entrySize(contents)
561
+ SIZES.set(abs, { contents, size })
562
+ return size
563
+ }
564
+
565
+ // ------------------------------------------------------------ persistence
566
+ // The drive lives in this worker's memory. IndexedDB is owned by the PAGE
567
+ // (a service worker can be terminated between requests, and in some browsers
568
+ // worker and page storage are not shared), so the page hands the drive in at
569
+ // boot and receives the changed files back after each run.
570
+ const dirty = new Map() // abs path -> contents, or null when deleted
571
+
572
+ const markDirty = (path) => {
573
+ const contents = FILES.has(path) ? FILES.get(path) : null
574
+ dirty.set(path, contents)
575
+ }
576
+
577
+ const takeDirty = () => {
578
+ const out = {}
579
+ for (const [path, contents] of dirty) out[path] = contents
580
+ dirty.clear()
581
+ return out
582
+ }
583
+
584
+ const dumpAll = () => {
585
+ const out = {}
586
+ for (const [path, contents] of FILES) out[path] = contents
587
+ return out
588
+ }
589
+
590
+ const loadSnapshot = (files) => {
591
+ FILES.clear()
592
+ SIZES.clear()
593
+ DIRS.clear()
594
+ DIRS.add('/')
595
+ mkdirp(DRIVE_APP)
596
+ mkdirp(`${DRIVE_ROOT}/files`)
597
+ for (const [path, contents] of Object.entries(files || {})) {
598
+ if (typeof contents !== 'string') continue
599
+ FILES.set(P.resolve(path), contents)
600
+ mkdirp(P.dirname(path))
601
+ }
602
+ lastBoot = {
603
+ booted: true,
604
+ restored: true,
605
+ files: FILES.size,
606
+ at: new Date().toISOString()
607
+ }
608
+ return lastBoot
609
+ }
610
+
611
+ const mkdirp = (dir) => {
612
+ const parts = P.normalise(dir).split('/').filter(Boolean)
613
+ let acc = ''
614
+ for (const part of parts) {
615
+ acc += '/' + part
616
+ DIRS.add(acc)
617
+ }
618
+ }
619
+
620
+ /**
621
+ * node's writeFileSync third argument: an encoding, or an options object that
622
+ * carries an encoding and a flag. 'w' replaces and 'a' appends, which are the
623
+ * two that mean something on a drive with no file descriptors; the rest are
624
+ * refused rather than quietly treated as a replace.
625
+ */
626
+ const writeOptions = (options) => {
627
+ if (options === undefined || options === null)
628
+ return { encoding: 'utf8', flag: 'w' }
629
+ const opts = typeof options === 'string' ? { encoding: options } : options
630
+ const flag = opts.flag || 'w'
631
+ if (flag !== 'w' && flag !== 'a')
632
+ throw new Error(
633
+ `Unsupported flag '${flag}' in the browser host - 'w' and 'a' only`
634
+ )
635
+ return { encoding: opts.encoding || 'utf8', flag }
636
+ }
637
+
638
+ const FS = {
639
+ readFileSync(p, options) {
640
+ const abs = P.resolve(String(p))
641
+ if (!FILES.has(abs)) {
642
+ const err = new Error(
643
+ `ENOENT: no such file or directory, open '${String(p)}'`
644
+ )
645
+ err.code = 'ENOENT'
646
+ throw err
647
+ }
648
+ // node takes the encoding either bare or on an options object. An explicit
649
+ // `{ encoding: null }` is node's ask for a Buffer, so it is not the same as
650
+ // no encoding at all - the key has to be told apart from its value.
651
+ const onOptions = options && typeof options === 'object'
652
+ const encoding = onOptions ? options.encoding : options
653
+ const contents = FILES.get(abs)
654
+ const bytes = isBinary(contents) ? asBuffer(entryBytes(contents)) : null
655
+ // an encoding asks for a string, as it does in node; without one the drive
656
+ // hands text back as text (a service reading a config or a CSV expects
657
+ // that) and a byte file back as bytes
658
+ if (encoding) {
659
+ const how = assertEncoding(encoding)
660
+ return bytes
661
+ ? bytes.toString(how)
662
+ : BUFFERS.from(entryText(contents)).toString(how)
663
+ }
664
+ // an encoding key present but null: node hands back the Buffer, so a text
665
+ // entry is its utf8 bytes
666
+ if (onOptions && 'encoding' in options)
667
+ return bytes || asBuffer(utf8Encoder.encode(entryText(contents)))
668
+ return bytes || entryText(contents)
669
+ },
670
+ existsSync(p) {
671
+ const abs = P.resolve(String(p))
672
+ return FILES.has(abs) || DIRS.has(abs)
673
+ },
674
+ writeFileSync(p, data, options) {
675
+ const abs = P.resolve(String(p))
676
+ mkdirp(P.dirname(abs))
677
+ // node's third argument is an encoding, or an object carrying one plus a
678
+ // flag. An appending write is honoured, because a service that streams a
679
+ // file in chunks produces a whole file on the server and would otherwise
680
+ // produce its last chunk here.
681
+ const { encoding, flag } = writeOptions(options)
682
+ const appending = flag === 'a' && FILES.has(abs)
683
+ const before = appending ? entryBytes(FILES.get(abs)) : null
684
+ const stored = appending && isBinary(FILES.get(abs))
685
+ // text, or the bytes node lets a service write: an array, a number or null
686
+ // is an error there, so it is one here too rather than a silent "1,2,3" in
687
+ // the file that fails on the server
688
+ if (typeof data !== 'string') {
689
+ const bytes = asBufferInput(data)
690
+ if (!bytes)
691
+ throw new TypeError(
692
+ `The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received ${describeValue(data)}`
693
+ )
694
+ const written = appending ? joinBytes(before, bytes) : bytes
695
+ FILES.set(abs, BINARY_PREFIX + bytesToBase64(written))
696
+ markDirty(abs)
697
+ return
698
+ }
699
+ const how = assertEncoding(encoding)
700
+ // a utf8 string lands as text, so a service that reads it back as text gets
701
+ // the text; anything that carries an encoding, or appends onto bytes, lands
702
+ // as the bytes it produces
703
+ if (how === 'utf8' && !stored) {
704
+ const text = appending ? entryText(FILES.get(abs)) + data : data
705
+ FILES.set(abs, encodeText(text))
706
+ markDirty(abs)
707
+ return
708
+ }
709
+ const bytes = BUFFERS.from(data, how)
710
+ const written = appending ? joinBytes(before, bytes) : bytes
711
+ FILES.set(abs, BINARY_PREFIX + bytesToBase64(written))
712
+ markDirty(abs)
713
+ },
714
+ writeFile(p, data, options, cb) {
715
+ // node's writeFile takes the options as its third argument, with the
716
+ // callback either after it or in its place
717
+ if (typeof options === 'function') {
718
+ cb = options
719
+ options = undefined
720
+ }
721
+ try {
722
+ FS.writeFileSync(p, data, options)
723
+ if (cb) cb(null)
724
+ } catch (e) {
725
+ if (cb) cb(e)
726
+ else throw e
727
+ }
728
+ },
729
+ mkdirSync(p) {
730
+ mkdirp(P.resolve(String(p)))
731
+ },
732
+ readdirSync(p) {
733
+ const abs = P.resolve(String(p))
734
+ const pre = abs === '/' ? '/' : abs + '/'
735
+ const out = new Set()
736
+ for (const k of FILES.keys()) {
737
+ if (k.startsWith(pre)) {
738
+ const rest = k.slice(pre.length)
739
+ if (rest && !rest.includes('/')) out.add(rest)
740
+ }
741
+ }
742
+ for (const d of DIRS) {
743
+ if (d.startsWith(pre)) {
744
+ const rest = d.slice(pre.length)
745
+ if (rest && !rest.includes('/')) out.add(rest)
746
+ }
747
+ }
748
+ return [...out].sort()
749
+ },
750
+ statSync(p) {
751
+ const abs = P.resolve(String(p))
752
+ if (DIRS.has(abs))
753
+ return { isDirectory: () => true, isFile: () => false, size: 0 }
754
+ if (FILES.has(abs)) {
755
+ const size = entrySizeAt(abs)
756
+ return { isDirectory: () => false, isFile: () => true, size }
757
+ }
758
+ const err = new Error(
759
+ `ENOENT: no such file or directory, stat '${String(p)}'`
760
+ )
761
+ err.code = 'ENOENT'
762
+ throw err
763
+ },
764
+ unlinkSync(p) {
765
+ const abs = P.resolve(String(p))
766
+ FILES.delete(abs)
767
+ SIZES.delete(abs)
768
+ markDirty(abs)
769
+ },
770
+ copyFileSync(from, to) {
771
+ const src = P.resolve(String(from))
772
+ if (!FILES.has(src))
773
+ throw new Error(`ENOENT: no such file, copy '${String(from)}'`)
774
+ // copy the stored representation verbatim, so a byte file stays bytes and a
775
+ // text file is not re-escaped
776
+ const abs = P.resolve(String(to))
777
+ mkdirp(P.dirname(abs))
778
+ FILES.set(abs, FILES.get(src))
779
+ markDirty(abs)
780
+ }
781
+ }
782
+
783
+ const CRYPTO = {
784
+ createHash(alg) {
785
+ if (String(alg).toLowerCase() !== 'md5')
786
+ throw new Error(`Unsupported hash in browser host: ${alg}`)
787
+ let buf = ''
788
+ return {
789
+ update(s) {
790
+ buf += String(s)
791
+ return this
792
+ },
793
+ digest(enc) {
794
+ const hex = MD5(buf)
795
+ return enc === 'hex' ? hex : hex
796
+ }
797
+ }
798
+ }
799
+ }
800
+
801
+ const requireShim = (name) => {
802
+ switch (name) {
803
+ case 'path':
804
+ return P
805
+ case 'fs':
806
+ return FS
807
+ case 'crypto':
808
+ return CRYPTO
809
+ default:
810
+ throw new Error(
811
+ `Cannot find module '${name}' in the browser runtime host`
812
+ )
813
+ }
814
+ }
815
+
816
+ // ------------------------------------------------------------------ commands
817
+ const DRIVE_ROOT = '/sasjs/drive'
818
+ // Rewritten at boot from host/mock-config.json, which the mock build writes.
819
+ let APP_LOC = '/Public/app'
820
+ let DRIVE_APP = `${DRIVE_ROOT}/files${APP_LOC}`
821
+ const SEED_PATH = '/host/vfs-seed.json'
822
+ const SNAPSHOT_PATH = '/host/vfs-snapshot.json'
823
+
824
+ /**
825
+ * Reads the generated mock-config.json (appLoc and provenance). Absent or
826
+ * malformed, the defaults above apply - a demo can then only work when the
827
+ * services sit under the default appLoc.
828
+ */
829
+ const loadMockConfig = async () => {
830
+ try {
831
+ const res = await fetch('/host/mock-config.json', { cache: 'no-store' })
832
+ if (!res.ok) return
833
+ const cfg = await res.json()
834
+ if (cfg && typeof cfg.appLoc === 'string' && cfg.appLoc.trim()) {
835
+ APP_LOC = '/' + cfg.appLoc.replace(/^\/+/, '').replace(/\/+$/, '')
836
+ DRIVE_APP = `${DRIVE_ROOT}/files${APP_LOC}`
837
+ }
838
+ } catch (e) {
839
+ /* keep the defaults */
840
+ }
841
+ }
842
+
843
+ let sessionSeq = 0
844
+
845
+ const formatArg = (a) => {
846
+ if (typeof a === 'string') return a
847
+ try {
848
+ return JSON.stringify(a)
849
+ } catch (e) {
850
+ return String(a)
851
+ }
852
+ }
853
+
854
+ const PROGRAM_FOLDERS = ['services', 'jobs', 'tests']
855
+
856
+ /**
857
+ * Resolves a program reference the way the server does: an exact drive path,
858
+ * with the runtime extension tried. The adapter sends a full path, and prepends
859
+ * the appLoc when the app passes one without a leading slash, so a reference
860
+ * naming no program folder (e.g. "getdata", or "<appLoc>/getdata") is also
861
+ * tried under services, jobs and tests below the appLoc.
862
+ */
863
+ const resolveProgram = (program) => {
864
+ const relative = String(program).replace(/^\/+/, '')
865
+ const roots = [P.resolve(DRIVE_ROOT, 'files', relative)]
866
+
867
+ const namesAFolder = PROGRAM_FOLDERS.some(
868
+ (folder) => relative === folder || relative.startsWith(folder + '/')
869
+ )
870
+ if (!namesAFolder) {
871
+ // DRIVE_APP already carries the appLoc, and it is absolute: joining it with
872
+ // P.resolve keeps the drive prefix that the literal appLoc would replace.
873
+ const underAppLoc = relative.startsWith(APP_LOC.slice(1) + '/')
874
+ ? relative.slice(APP_LOC.length)
875
+ : relative
876
+ for (const folder of PROGRAM_FOLDERS) {
877
+ roots.push(P.join(DRIVE_APP, folder, underAppLoc))
878
+ }
879
+ }
880
+
881
+ for (const root of roots) {
882
+ for (const candidate of [root, root + '.js', root + '.sas']) {
883
+ if (FILES.has(candidate)) return candidate
884
+ }
885
+ }
886
+
887
+ return null
888
+ }
889
+
890
+ const runProgram = ({ program, code, params = {}, tables = [] }) => {
891
+ const payloadCode = code
892
+ const codePath = program ? resolveProgram(program) : null
893
+ if (!codePath && !payloadCode)
894
+ throw new Error(`Program not found in drive: ${program}`)
895
+
896
+ const logLines = []
897
+ const sandboxConsole = {}
898
+ for (const level of ['log', 'info', 'warn', 'error', 'debug']) {
899
+ sandboxConsole[level] = (...args) => {
900
+ const line = args.map(formatArg).join(' ')
901
+ logLines.push(line)
902
+ // eslint-disable-next-line no-console
903
+ console[level === 'debug' ? 'log' : level](`[service] ${line}`)
904
+ }
905
+ }
906
+
907
+ const sessionDir = `/sasjs/sessions/s${++sessionSeq}`
908
+ mkdirp(sessionDir)
909
+ const weboutPath = `${sessionDir}/webout.txt`
910
+ const headersPath = `${sessionDir}/stpsrv_header.txt`
911
+ const tokenFile = `${sessionDir}/reqHeaders.txt`
912
+ FILES.set(weboutPath, '')
913
+ FILES.set(headersPath, '')
914
+ FILES.set(tokenFile, '')
915
+
916
+ const varStatements = Object.entries(params)
917
+ .filter(([name]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
918
+ .map(([name, value]) => `const ${name} = ${JSON.stringify(String(value))};`)
919
+ .join('\n')
920
+
921
+ const webinStatements = []
922
+ tables.forEach((table, idx) => {
923
+ const n = idx + 1
924
+ webinStatements.push(
925
+ `const _WEBIN_NAME${n} = ${JSON.stringify(table.name)};`
926
+ )
927
+ webinStatements.push(
928
+ `const _WEBIN_FILENAME${n} = ${JSON.stringify(table.filename)};`
929
+ )
930
+ webinStatements.push(
931
+ `const _WEBIN_FILEREF${n} = ${JSON.stringify(table.csv)};`
932
+ )
933
+ })
934
+ webinStatements.push(`const _WEBIN_FILE_COUNT = ${tables.length};`)
935
+
936
+ const preProgram = `
937
+ let _webout = '';
938
+ const weboutPath = ${JSON.stringify(weboutPath)};
939
+ const _SASJS_TOKENFILE = ${JSON.stringify(tokenFile)};
940
+ const _SASJS_WEBOUT_HEADERS = ${JSON.stringify(headersPath)};
941
+ const _SASJS_USERNAME = "DEMO";
942
+ const _SASJS_USERID = "0";
943
+ const _SASJS_DISPLAYNAME = "Demo User";
944
+ const _METAPERSON = _SASJS_DISPLAYNAME;
945
+ const _METAUSER = _SASJS_USERNAME;
946
+ const SASJSPROCESSMODE = 'Stored Program';
947
+ `
948
+
949
+ const source = `
950
+ const fs = require('fs');
951
+ ${varStatements}
952
+ ${webinStatements.join('\n')}
953
+ ${preProgram}
954
+ ${payloadCode || FILES.get(codePath)}
955
+ if (_webout) { fs.writeFile(weboutPath, _webout, function () {}); }
956
+ return _webout;
957
+ `
958
+
959
+ let thrown = null
960
+ let returned = ''
961
+ try {
962
+ // the construction sits inside the try: a service that declares a name the
963
+ // prelude already binds (`const Buffer = ...`) makes new Function throw a
964
+ // SyntaxError, which belongs in the run's error, not outside it
965
+ const fn = new Function('require', 'console', 'process', 'Buffer', source)
966
+ returned = fn(
967
+ requireShim,
968
+ sandboxConsole,
969
+ {
970
+ pid: 4242,
971
+ env: { NODE_ENV: 'browser' }
972
+ },
973
+ BUFFERS
974
+ )
975
+ } catch (e) {
976
+ thrown = e && e.stack ? e.stack : String(e)
977
+ logLines.push(String(thrown))
978
+ }
979
+
980
+ const stored = FILES.get(weboutPath) || ''
981
+ // binary only when the byte body is the body: an empty byte write falls back
982
+ // to the returned string, and flagging that as bytes would have the worker
983
+ // base64-decode text. A byte body is validated here the way a read is, so a
984
+ // corrupt one fails the run instead of reaching the worker's decoder.
985
+ let fromFile = ''
986
+ let corrupt = false
987
+ if (isBinary(stored)) {
988
+ try {
989
+ fromFile = validateEntry(stored)
990
+ } catch (e) {
991
+ corrupt = true
992
+ if (!thrown) thrown = e.message
993
+ logLines.push(e.message)
994
+ }
995
+ } else {
996
+ fromFile = entryText(stored)
997
+ }
998
+ const binary = !corrupt && !!fromFile && isBinary(stored)
999
+ const webout = fromFile || (typeof returned === 'string' ? returned : '')
1000
+
1001
+ return {
1002
+ webout,
1003
+ log: logLines.join('\n'),
1004
+ headers: entryText(FILES.get(headersPath) || ''),
1005
+ // a byte body travels to the host as base64, which is how it is stored
1006
+ binary,
1007
+ error: thrown
1008
+ }
1009
+ }
1010
+
1011
+ const listFolder = (folderPath) => {
1012
+ const abs = folderPath
1013
+ ? P.resolve(
1014
+ DRIVE_APP,
1015
+ String(folderPath)
1016
+ .replace(/^\/+/, '')
1017
+ .replace(new RegExp('^' + APP_LOC + '/'), '')
1018
+ )
1019
+ : DRIVE_APP
1020
+ const alt = folderPath
1021
+ ? P.resolve(DRIVE_ROOT, 'files', String(folderPath).replace(/^\/+/, ''))
1022
+ : DRIVE_APP
1023
+ const target = DIRS.has(abs) ? abs : alt
1024
+ if (!DIRS.has(target) && !FILES.has(target)) {
1025
+ const err = new Error(`Folder doesn't exist.`)
1026
+ err.status = 404
1027
+ throw err
1028
+ }
1029
+ const pre = target === '/' ? '/' : target + '/'
1030
+ const files = []
1031
+ const folders = []
1032
+ for (const k of FILES.keys()) {
1033
+ if (k.startsWith(pre)) {
1034
+ const rest = k.slice(pre.length)
1035
+ if (!rest.includes('/')) files.push(rest)
1036
+ }
1037
+ }
1038
+ for (const d of DIRS) {
1039
+ if (d.startsWith(pre)) {
1040
+ const rest = d.slice(pre.length)
1041
+ if (rest && !rest.includes('/')) folders.push(rest)
1042
+ }
1043
+ }
1044
+ return { files: files.sort(), folders: folders.sort() }
1045
+ }
1046
+
1047
+ const readDriveFile = (filePath) => {
1048
+ const abs = P.resolve(
1049
+ DRIVE_ROOT,
1050
+ 'files',
1051
+ String(filePath).replace(/^\/+/, '')
1052
+ )
1053
+ if (!FILES.has(abs)) {
1054
+ const err = new Error('File not found')
1055
+ err.status = 404
1056
+ throw err
1057
+ }
1058
+ const contents = FILES.get(abs)
1059
+ // the host turns a base64 entry back into bytes before it answers with it,
1060
+ // and takes the escape off a text entry. A corrupt entry is refused here, so
1061
+ // the answer is the same error the service gets when it reads the file.
1062
+ return isBinary(contents)
1063
+ ? { contents: validateEntry(contents), binary: true }
1064
+ : { contents: entryText(contents), binary: false }
1065
+ }
1066
+
1067
+ /** The stored base64 of a byte entry, once it is known to decode. */
1068
+ const validateEntry = (contents) => cleanedBase64(contents)
1069
+
1070
+ const buildTree = () => {
1071
+ const walk = (dir) => {
1072
+ const node = {
1073
+ name: P.basename(dir) || 'files',
1074
+ relativePath: dir.slice(DRIVE_ROOT.length + '/files'.length),
1075
+ absolutePath: dir,
1076
+ isFolder: true,
1077
+ children: []
1078
+ }
1079
+ node.relativePath = node.relativePath === '' ? '' : node.relativePath
1080
+ for (const child of FS.readdirSync(dir)) {
1081
+ const childAbs = `${dir}/${child}`
1082
+ if (DIRS.has(childAbs)) node.children.push(walk(childAbs))
1083
+ else {
1084
+ node.children.push({
1085
+ name: child,
1086
+ relativePath: childAbs.slice(DRIVE_ROOT.length + '/files'.length),
1087
+ absolutePath: childAbs,
1088
+ isFolder: false,
1089
+ children: []
1090
+ })
1091
+ }
1092
+ }
1093
+ return node
1094
+ }
1095
+ return { status: 'success', tree: walk(`${DRIVE_ROOT}/files`) }
1096
+ }
1097
+
1098
+ // -------------------------------------------------------------------- boot
1099
+ let ready = null
1100
+ let lastBoot = null
1101
+
1102
+ /**
1103
+ * Loads the drive the site ships with. A snapshot wins over the plain seed: it
1104
+ * is a complete drive captured after a project's own services had run (a
1105
+ * seeded demo site, for instance), so a visitor starts from data instead of
1106
+ * from an empty drive.
1107
+ */
1108
+ const fetchDrive = async () => {
1109
+ for (const [source, url] of [
1110
+ ['snapshot', SNAPSHOT_PATH],
1111
+ ['seed', SEED_PATH]
1112
+ ]) {
1113
+ try {
1114
+ const res = await fetch(url, { cache: 'no-store' })
1115
+ if (!res.ok) continue
1116
+ const body = await res.json()
1117
+ const files = body && body.files ? body.files : body
1118
+ if (files && typeof files === 'object') return { source, files }
1119
+ } catch (e) {
1120
+ /* fall through to the next source */
1121
+ }
1122
+ }
1123
+
1124
+ throw new Error(
1125
+ 'No drive to load: neither ' +
1126
+ SNAPSHOT_PATH +
1127
+ ' nor ' +
1128
+ SEED_PATH +
1129
+ ' could be read'
1130
+ )
1131
+ }
1132
+
1133
+ const boot = async () => {
1134
+ await loadMockConfig()
1135
+ const { source, files: seed } = await fetchDrive()
1136
+ mkdirp(DRIVE_APP)
1137
+ mkdirp(`${DRIVE_ROOT}/files`)
1138
+ for (const [path, contents] of Object.entries(seed)) {
1139
+ FILES.set(P.resolve(path), contents)
1140
+ mkdirp(P.dirname(path))
1141
+ }
1142
+ dirty.clear()
1143
+
1144
+ // No bootstrap: seeding a drive is an ordinary service call, made by the app
1145
+ // (or an operator) the same way it would be against a server. The host knows
1146
+ // nothing about what a project's services do.
1147
+ // Mark the whole drive dirty once, so the host persists a COMPLETE snapshot:
1148
+ // later restores then need no merging with the seed, and a deleted file (a
1149
+ // seeder service that removes itself) stays deleted.
1150
+ for (const path of FILES.keys()) markDirty(path)
1151
+
1152
+ lastBoot = {
1153
+ booted: true,
1154
+ restored: false,
1155
+ source,
1156
+ files: FILES.size,
1157
+ at: new Date().toISOString()
1158
+ }
1159
+ return lastBoot
1160
+ }
1161
+
1162
+ const reset = async () => {
1163
+ FILES.clear()
1164
+ SIZES.clear()
1165
+ DIRS.clear()
1166
+ DIRS.add('/')
1167
+ dirty.clear()
1168
+ started = true
1169
+ const p = boot()
1170
+ ready = p
1171
+ if (resolveBoot) resolveBoot(p)
1172
+ await p
1173
+ return true
1174
+ }
1175
+
1176
+ // Nothing runs until the host says whether it has a persisted drive or wants
1177
+ // a fresh seed; ensureReady() seeds after a short grace if it hears neither.
1178
+ let started = false
1179
+ let resolveBoot
1180
+ ready = new Promise((resolve) => {
1181
+ resolveBoot = resolve
1182
+ })
1183
+
1184
+ const api = {
1185
+ ensureReady: async () => {
1186
+ if (!started) {
1187
+ await new Promise((r) => setTimeout(r, 600))
1188
+ }
1189
+ if (!started) await api.seed()
1190
+ await ready
1191
+ return true
1192
+ },
1193
+ // Called by the host with the persisted drive; skips seeding entirely.
1194
+ load: (files) => {
1195
+ started = true
1196
+ const info = loadSnapshot(files)
1197
+ ready = Promise.resolve(info)
1198
+ if (resolveBoot) resolveBoot(info)
1199
+ return ready
1200
+ },
1201
+ seed: () => {
1202
+ started = true
1203
+ const p = boot().catch((e) => ({
1204
+ booted: false,
1205
+ error: (e && e.message) || String(e)
1206
+ }))
1207
+ ready = p
1208
+ if (resolveBoot) resolveBoot(p)
1209
+ return p
1210
+ },
1211
+ reset: () => reset(),
1212
+ takeDirty: () => takeDirty(),
1213
+ dumpAll: () => dumpAll(),
1214
+ bootInfo: () => lastBoot,
1215
+ stats: () => ({
1216
+ files: FILES.size,
1217
+ // the drive's total in bytes, measured per entry the way statSync does it -
1218
+ // the stored representation's own length would count the marker and the
1219
+ // base64 expansion instead - and cached, so a second call scans nothing
1220
+ bytes: [...FILES.keys()].reduce((total, abs) => total + entrySizeAt(abs), 0)
1221
+ }),
1222
+ run: (payload) => runProgram(payload),
1223
+ list: (folder) => listFolder(folder),
1224
+ read: (file) => readDriveFile(file),
1225
+ tree: () => buildTree()
1226
+ }
1227
+
1228
+ self.SASjsMockRuntime = api
1229
+
1230
+ if (typeof module !== 'undefined') module.exports = api