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