@sasjs/cli 6.1.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.
@@ -10,6 +10,26 @@
10
10
  * The virtual drive seeds from vfs-seed.json, which holds the project's own
11
11
  * JS services unchanged, so the services themselves are not adapted in any
12
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.
13
33
  */
14
34
 
15
35
  if (typeof importScripts === 'function') importScripts('/host/md5.js')
@@ -66,9 +86,481 @@ const P = {
66
86
  }
67
87
  }
68
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
+
69
546
  // ------------------------------------------------------------- virtual drive
70
547
  const FILES = new Map() // absolute path -> string contents
71
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
+ }
72
564
 
73
565
  // ------------------------------------------------------------ persistence
74
566
  // The drive lives in this worker's memory. IndexedDB is owned by the PAGE
@@ -97,6 +589,7 @@ const dumpAll = () => {
97
589
 
98
590
  const loadSnapshot = (files) => {
99
591
  FILES.clear()
592
+ SIZES.clear()
100
593
  DIRS.clear()
101
594
  DIRS.add('/')
102
595
  mkdirp(DRIVE_APP)
@@ -124,8 +617,26 @@ const mkdirp = (dir) => {
124
617
  }
125
618
  }
126
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
+
127
638
  const FS = {
128
- readFileSync(p, opts) {
639
+ readFileSync(p, options) {
129
640
  const abs = P.resolve(String(p))
130
641
  if (!FILES.has(abs)) {
131
642
  const err = new Error(
@@ -134,21 +645,81 @@ const FS = {
134
645
  err.code = 'ENOENT'
135
646
  throw err
136
647
  }
137
- return FILES.get(abs)
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)
138
669
  },
139
670
  existsSync(p) {
140
671
  const abs = P.resolve(String(p))
141
672
  return FILES.has(abs) || DIRS.has(abs)
142
673
  },
143
- writeFileSync(p, data) {
674
+ writeFileSync(p, data, options) {
144
675
  const abs = P.resolve(String(p))
145
676
  mkdirp(P.dirname(abs))
146
- FILES.set(abs, String(data))
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))
147
712
  markDirty(abs)
148
713
  },
149
- writeFile(p, data, cb) {
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
+ }
150
721
  try {
151
- FS.writeFileSync(p, data)
722
+ FS.writeFileSync(p, data, options)
152
723
  if (cb) cb(null)
153
724
  } catch (e) {
154
725
  if (cb) cb(e)
@@ -181,7 +752,7 @@ const FS = {
181
752
  if (DIRS.has(abs))
182
753
  return { isDirectory: () => true, isFile: () => false, size: 0 }
183
754
  if (FILES.has(abs)) {
184
- const size = FILES.get(abs).length
755
+ const size = entrySizeAt(abs)
185
756
  return { isDirectory: () => false, isFile: () => true, size }
186
757
  }
187
758
  const err = new Error(
@@ -193,13 +764,19 @@ const FS = {
193
764
  unlinkSync(p) {
194
765
  const abs = P.resolve(String(p))
195
766
  FILES.delete(abs)
767
+ SIZES.delete(abs)
196
768
  markDirty(abs)
197
769
  },
198
770
  copyFileSync(from, to) {
199
771
  const src = P.resolve(String(from))
200
772
  if (!FILES.has(src))
201
773
  throw new Error(`ENOENT: no such file, copy '${String(from)}'`)
202
- FS.writeFileSync(to, FILES.get(src))
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)
203
780
  }
204
781
  }
205
782
 
@@ -379,27 +956,54 @@ if (_webout) { fs.writeFile(weboutPath, _webout, function () {}); }
379
956
  return _webout;
380
957
  `
381
958
 
382
- const fn = new Function('require', 'console', 'process', source)
383
-
384
959
  let thrown = null
385
960
  let returned = ''
386
961
  try {
387
- returned = fn(requireShim, sandboxConsole, {
388
- pid: 4242,
389
- env: { NODE_ENV: 'browser' }
390
- })
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
+ )
391
975
  } catch (e) {
392
976
  thrown = e && e.stack ? e.stack : String(e)
393
977
  logLines.push(String(thrown))
394
978
  }
395
979
 
396
- const fromFile = FILES.get(weboutPath) || ''
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)
397
999
  const webout = fromFile || (typeof returned === 'string' ? returned : '')
398
1000
 
399
1001
  return {
400
1002
  webout,
401
1003
  log: logLines.join('\n'),
402
- headers: FILES.get(headersPath) || '',
1004
+ headers: entryText(FILES.get(headersPath) || ''),
1005
+ // a byte body travels to the host as base64, which is how it is stored
1006
+ binary,
403
1007
  error: thrown
404
1008
  }
405
1009
  }
@@ -451,9 +1055,18 @@ const readDriveFile = (filePath) => {
451
1055
  err.status = 404
452
1056
  throw err
453
1057
  }
454
- return FILES.get(abs)
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 }
455
1065
  }
456
1066
 
1067
+ /** The stored base64 of a byte entry, once it is known to decode. */
1068
+ const validateEntry = (contents) => cleanedBase64(contents)
1069
+
457
1070
  const buildTree = () => {
458
1071
  const walk = (dir) => {
459
1072
  const node = {
@@ -548,6 +1161,7 @@ const boot = async () => {
548
1161
 
549
1162
  const reset = async () => {
550
1163
  FILES.clear()
1164
+ SIZES.clear()
551
1165
  DIRS.clear()
552
1166
  DIRS.add('/')
553
1167
  dirty.clear()
@@ -600,14 +1214,14 @@ const api = {
600
1214
  bootInfo: () => lastBoot,
601
1215
  stats: () => ({
602
1216
  files: FILES.size,
603
- bytes: [...FILES.values()].reduce(
604
- (total, contents) => total + contents.length,
605
- 0
606
- )
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)
607
1221
  }),
608
1222
  run: (payload) => runProgram(payload),
609
1223
  list: (folder) => listFolder(folder),
610
- read: (file) => ({ contents: readDriveFile(file) }),
1224
+ read: (file) => readDriveFile(file),
611
1225
  tree: () => buildTree()
612
1226
  }
613
1227