@sasjs/cli 6.1.0 → 6.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/build/internal/mockSite/assets/persist.js +13 -6
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +636 -22
- package/build/commands/build/internal/mockSite/assets/sw.js +100 -11
- package/build/commands/build/internal/mockSite/mockSite.d.ts +11 -0
- package/build/commands/build/internal/mockSite/mockSite.js +87 -20
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +103 -11
- package/build/commands/build/internal/mockSite/spec/persist.spec.js +61 -3
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +506 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +356 -1
- package/package.json +3 -3
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
604
|
-
|
|
605
|
-
|
|
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) =>
|
|
1224
|
+
read: (file) => readDriveFile(file),
|
|
611
1225
|
tree: () => buildTree()
|
|
612
1226
|
}
|
|
613
1227
|
|