@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.
- package/build/commands/build/build.js +20 -10
- package/build/commands/build/internal/mockSite/assets/intercept.js +172 -0
- package/build/commands/build/internal/mockSite/assets/md5.js +75 -0
- package/build/commands/build/internal/mockSite/assets/persist.js +222 -0
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +1230 -0
- package/build/commands/build/internal/mockSite/assets/sw.js +317 -0
- package/build/commands/build/internal/mockSite/mockSite.d.ts +38 -0
- package/build/commands/build/internal/mockSite/mockSite.js +475 -0
- package/build/commands/build/internal/mockSite/spec/intercept.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/intercept.spec.js +267 -0
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +497 -0
- package/build/commands/build/internal/mockSite/spec/persist.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/persist.spec.js +476 -0
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +802 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +716 -0
- package/build/commands/index.d.ts +1 -0
- package/build/commands/index.js +3 -1
- package/package.json +8 -7
|
@@ -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
|