@sasjs/cli 6.15.0 → 6.17.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/internal/mockSite/assets/sw.js +300 -13
- package/build/commands/build/internal/mockSite/mockSite.d.ts +10 -0
- package/build/commands/build/internal/mockSite/mockSite.js +202 -36
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +118 -6
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +279 -88
- package/package.json +2 -1
|
@@ -23,10 +23,19 @@
|
|
|
23
23
|
* The drive is persisted by the page (host/persist.js) and the identity is not:
|
|
24
24
|
* an account, group, permission rule or client created through these routes
|
|
25
25
|
* lives in this worker's memory, so a worker the browser terminates starts again
|
|
26
|
-
* from the fixture. Edits that must survive a restart belong on a server.
|
|
26
|
+
* from the fixture. Edits that must survive a restart belong on a server. The
|
|
27
|
+
* activity log the Admin routes report is in this worker's memory for the same
|
|
28
|
+
* reason.
|
|
29
|
+
*
|
|
30
|
+
* Every operation in @sasjs/api's route table is answered here: the drive, the
|
|
31
|
+
* sign-in flow, the records, the runs, the sessions, the diagnostics and logs,
|
|
32
|
+
* the directory import, the app-stream delete, the linter and the home page. The
|
|
33
|
+
* services are the package's; this file supplies the transport, the worker's own
|
|
34
|
+
* activity, and the browser's answers to questions a server asks its process.
|
|
27
35
|
*/
|
|
28
36
|
importScripts('/host/runtime-core.js')
|
|
29
37
|
importScripts('/host/api-browser.js')
|
|
38
|
+
importScripts('/host/lint-browser.js')
|
|
30
39
|
|
|
31
40
|
const R = self.SASjsMockRuntime
|
|
32
41
|
|
|
@@ -36,6 +45,10 @@ const R = self.SASjsMockRuntime
|
|
|
36
45
|
// and the few capabilities a static origin does not have, which the package
|
|
37
46
|
// reports as a 501 naming the missing service.
|
|
38
47
|
const API = self.SASjsApi
|
|
48
|
+
// The linter, bundled at build time from the installed @sasjs/api/code and
|
|
49
|
+
// @sasjs/lint (see mockSite.ts). It is a separate script because the linter is
|
|
50
|
+
// not part of the root package a service worker can import.
|
|
51
|
+
const CODE = self.SASjsCode
|
|
39
52
|
|
|
40
53
|
const DRIVE_STORE = R.driveStore
|
|
41
54
|
const IDENTITY_STORE = new API.MemoryIdentityStore(API.DEFAULT_IDENTITY)
|
|
@@ -304,6 +317,175 @@ const tablesOf = (files) => {
|
|
|
304
317
|
|
|
305
318
|
const RUNTIME = new BrowserRuntime()
|
|
306
319
|
|
|
320
|
+
// ------------------------------------------------------------- host reports
|
|
321
|
+
// The Admin routes ask a host what it is doing. A browser has no process, but it
|
|
322
|
+
// does have a page, a heap and this worker's own activity, so the reports are
|
|
323
|
+
// built from those rather than refused: the request log below is the log file,
|
|
324
|
+
// and the memory reading is `performance.memory` where the browser exposes it.
|
|
325
|
+
const STARTED = Date.now()
|
|
326
|
+
const HOST_LOG_LIMIT = 2000
|
|
327
|
+
const HOST_LOG = []
|
|
328
|
+
const LOG_FILE = 'app.log'
|
|
329
|
+
// the moment the last line was written, which is the file's mtime - not the
|
|
330
|
+
// moment a report read it
|
|
331
|
+
let LOG_UPDATED = null
|
|
332
|
+
|
|
333
|
+
const logEntry = (line) => {
|
|
334
|
+
LOG_UPDATED = new Date().toISOString()
|
|
335
|
+
HOST_LOG.push(`${LOG_UPDATED} ${line}`)
|
|
336
|
+
if (HOST_LOG.length > HOST_LOG_LIMIT)
|
|
337
|
+
HOST_LOG.splice(0, HOST_LOG.length - HOST_LOG_LIMIT)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const logText = () => HOST_LOG.join('\n')
|
|
341
|
+
const bytesOf = (text) => new TextEncoder().encode(String(text)).length
|
|
342
|
+
|
|
343
|
+
const adminReports = {
|
|
344
|
+
diagnostics: async () => {
|
|
345
|
+
const perf = self.performance || {}
|
|
346
|
+
const heap = perf.memory || {}
|
|
347
|
+
const deviceBytes = Number(navigator.deviceMemory || 0) * 1024 ** 3
|
|
348
|
+
const total = Number(heap.jsHeapSizeLimit || deviceBytes || 0)
|
|
349
|
+
const used = Number(heap.usedJSHeapSize || 0)
|
|
350
|
+
return {
|
|
351
|
+
build: { version: 'dev', gitHash: 'unknown', buildDate: null },
|
|
352
|
+
server: {
|
|
353
|
+
// a page is not a process: the fields exist so a diagnostics screen
|
|
354
|
+
// renders, and their values describe this worker rather than inventing
|
|
355
|
+
// one behind it
|
|
356
|
+
mode: 'browser',
|
|
357
|
+
uptimeSeconds: (Date.now() - STARTED) / 1000,
|
|
358
|
+
startedAt: new Date(STARTED).toISOString(),
|
|
359
|
+
pid: 0,
|
|
360
|
+
nodeVersion: 'n/a',
|
|
361
|
+
platform: navigator.platform || 'browser',
|
|
362
|
+
arch: 'browser',
|
|
363
|
+
cpuCount: navigator.hardwareConcurrency || 1,
|
|
364
|
+
cpuModel: 'unknown',
|
|
365
|
+
loadAverage: [],
|
|
366
|
+
runTimes: ['js'],
|
|
367
|
+
sasjsRoot: '/'
|
|
368
|
+
},
|
|
369
|
+
memory: {
|
|
370
|
+
rssBytes: used,
|
|
371
|
+
heapTotalBytes: total,
|
|
372
|
+
heapUsedBytes: used,
|
|
373
|
+
externalBytes: 0,
|
|
374
|
+
systemTotalBytes: deviceBytes || total,
|
|
375
|
+
systemFreeBytes: Math.max(0, (deviceBytes || total) - used)
|
|
376
|
+
},
|
|
377
|
+
// a static origin cannot read the device's filesystem, which the document
|
|
378
|
+
// allows the host to state
|
|
379
|
+
filesystem: null,
|
|
380
|
+
folders: [],
|
|
381
|
+
logs: {
|
|
382
|
+
folder: 'worker memory',
|
|
383
|
+
// no file until a request has written one, and the stamp is the last
|
|
384
|
+
// write rather than the moment this report was assembled
|
|
385
|
+
fileCount: HOST_LOG.length ? 1 : 0,
|
|
386
|
+
totalBytes: bytesOf(logText()),
|
|
387
|
+
newestFile: HOST_LOG.length ? LOG_FILE : null,
|
|
388
|
+
newestModified: LOG_UPDATED
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
logs: async () =>
|
|
393
|
+
HOST_LOG.length
|
|
394
|
+
? [
|
|
395
|
+
{
|
|
396
|
+
name: LOG_FILE,
|
|
397
|
+
sizeBytes: bytesOf(logText()),
|
|
398
|
+
modified: LOG_UPDATED
|
|
399
|
+
}
|
|
400
|
+
]
|
|
401
|
+
: [],
|
|
402
|
+
logTail: async (fileName, lines) => {
|
|
403
|
+
if (fileName !== LOG_FILE || !HOST_LOG.length) return undefined
|
|
404
|
+
const tail = HOST_LOG.slice(-lines)
|
|
405
|
+
const content = tail.join('\n')
|
|
406
|
+
return {
|
|
407
|
+
fileName,
|
|
408
|
+
lines: tail.length,
|
|
409
|
+
bytesRead: bytesOf(content),
|
|
410
|
+
truncated: tail.length < HOST_LOG.length,
|
|
411
|
+
content
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The directory the LDAP import reads.
|
|
418
|
+
*
|
|
419
|
+
* A browser has no bind to make, so the source is the fixture this host already
|
|
420
|
+
* signs in with: every account and group it names is present, so the import
|
|
421
|
+
* answers the route (the package owns the idempotency) and creates nothing. A
|
|
422
|
+
* host with a real directory supplies its own source.
|
|
423
|
+
*/
|
|
424
|
+
const directorySource = {
|
|
425
|
+
users: async () =>
|
|
426
|
+
API.DEFAULT_IDENTITY.users.map((user) => ({
|
|
427
|
+
username: user.username,
|
|
428
|
+
displayName: user.displayName
|
|
429
|
+
})),
|
|
430
|
+
groups: async () =>
|
|
431
|
+
API.DEFAULT_IDENTITY.groups.map((group) => ({
|
|
432
|
+
name: group.name,
|
|
433
|
+
members: group.users || []
|
|
434
|
+
}))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The app streams this host published: the site itself.
|
|
439
|
+
*
|
|
440
|
+
* The folder is the identity, so unpublishing the app the site was built for
|
|
441
|
+
* answers `success`; any other folder is the documented 404. The static files
|
|
442
|
+
* stay where they are - a worker cannot delete the site it is served from - so
|
|
443
|
+
* this is a record of the publication, not a filesystem operation.
|
|
444
|
+
*/
|
|
445
|
+
let registryPromise
|
|
446
|
+
const registry = () => {
|
|
447
|
+
if (!registryPromise)
|
|
448
|
+
registryPromise = (async () => {
|
|
449
|
+
let appLoc
|
|
450
|
+
try {
|
|
451
|
+
const config = await (await fetch('/host/mock-config.json')).json()
|
|
452
|
+
appLoc = config && config.appLoc
|
|
453
|
+
} catch (e) {
|
|
454
|
+
appLoc = undefined
|
|
455
|
+
}
|
|
456
|
+
return new API.MemoryAppStreamRegistry(appLoc ? [appLoc] : [])
|
|
457
|
+
})()
|
|
458
|
+
return registryPromise
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The page at `/`.
|
|
463
|
+
*
|
|
464
|
+
* The site's own index.html is already in the build, so the home route reads it
|
|
465
|
+
* from the static host and hands the package the page and a token of its
|
|
466
|
+
* contents. The package decides what to do with the token; these builds do not
|
|
467
|
+
* reference `index.bundle.js`, so in practice it is returned unchanged.
|
|
468
|
+
*/
|
|
469
|
+
const contentVersion = (text) => {
|
|
470
|
+
let hash = 0x811c9dc5
|
|
471
|
+
for (let i = 0; i < text.length; i++) {
|
|
472
|
+
hash ^= text.charCodeAt(i)
|
|
473
|
+
hash = Math.imul(hash, 0x01000193)
|
|
474
|
+
}
|
|
475
|
+
return (hash >>> 0).toString(16)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const homeService = API.createHomeService(async () => {
|
|
479
|
+
try {
|
|
480
|
+
const response = await fetch('/index.html', { cache: 'no-store' })
|
|
481
|
+
if (!response.ok) return undefined
|
|
482
|
+
const html = await response.text()
|
|
483
|
+
return { html, version: contentVersion(html) }
|
|
484
|
+
} catch (e) {
|
|
485
|
+
return undefined
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
|
|
307
489
|
const SERVICES = {
|
|
308
490
|
drive: API.createDriveService(DRIVE_STORE),
|
|
309
491
|
info: API.createInfoService(API.BROWSER_INFO),
|
|
@@ -315,11 +497,17 @@ const SERVICES = {
|
|
|
315
497
|
runTimes: ['js']
|
|
316
498
|
}),
|
|
317
499
|
session: API.createSessionService(IDENTITY_STORE, RUNTIME),
|
|
318
|
-
authConfig: API.createAuthConfigService({})
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
500
|
+
authConfig: API.createAuthConfigService({}),
|
|
501
|
+
admin: API.createAdminService(adminReports),
|
|
502
|
+
directory: API.createDirectoryService(IDENTITY_STORE, directorySource),
|
|
503
|
+
registry: {
|
|
504
|
+
unpublish: async (appLoc) => (await registry()).unpublish(appLoc)
|
|
505
|
+
},
|
|
506
|
+
home: homeService,
|
|
507
|
+
// the package's own code service over the bundled linter; the host passes no
|
|
508
|
+
// lint config, so a buffer is linted against the linter's defaults - a mock
|
|
509
|
+
// has no `.sasjslint` tree to resolve one from
|
|
510
|
+
code: CODE.createCodeService({})
|
|
323
511
|
}
|
|
324
512
|
|
|
325
513
|
/** The routes this host performs itself, because the answer is not JSON. */
|
|
@@ -663,7 +851,7 @@ const contentsResponse = (answer, path) => {
|
|
|
663
851
|
})
|
|
664
852
|
}
|
|
665
853
|
|
|
666
|
-
const
|
|
854
|
+
const routeRequest = async (request, url) => {
|
|
667
855
|
const path = url.pathname.replace(/\/+$/, '') || '/'
|
|
668
856
|
const query = {}
|
|
669
857
|
for (const [k, v] of url.searchParams.entries()) query[k] = v
|
|
@@ -671,6 +859,56 @@ const handle = async (request, url) => {
|
|
|
671
859
|
try {
|
|
672
860
|
await R.ensureReady()
|
|
673
861
|
|
|
862
|
+
// The app stream is the server's own web surface rather than part of the
|
|
863
|
+
// API: the listing, the landing page and the redirect from one to the other
|
|
864
|
+
// are routes sasjs/server answers, and an interface asks for the listing on
|
|
865
|
+
// every visit. Unanswered, those requests reach the static host and the page
|
|
866
|
+
// parses this site's index.html as JSON.
|
|
867
|
+
//
|
|
868
|
+
// The answers mirror the server's, because a caller cannot tell the two
|
|
869
|
+
// hosts apart otherwise: `/AppStream` redirects to `/AppStream/`, the landing
|
|
870
|
+
// page is served, and only a name under it is missing. The listing is empty
|
|
871
|
+
// rather than read from `registry()`, which records this deployment's own
|
|
872
|
+
// folder so the delete route has something to remove: the stream really does
|
|
873
|
+
// serve nothing at `/AppStream/<name>/` here, and a listing that named it
|
|
874
|
+
// would offer a tile that 404s.
|
|
875
|
+
// `path` has its trailing slashes stripped, and these four answers differ by
|
|
876
|
+
// exactly that, so they are decided on the request's own pathname.
|
|
877
|
+
const stream = url.pathname
|
|
878
|
+
|
|
879
|
+
if (/^\/AppStream$/i.test(stream))
|
|
880
|
+
return new Response(null, {
|
|
881
|
+
status: 301,
|
|
882
|
+
headers: { location: '/AppStream/' }
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
if (
|
|
886
|
+
/^\/AppStream\/$/i.test(stream) ||
|
|
887
|
+
/^\/AppStream\/index\.html$/i.test(stream)
|
|
888
|
+
)
|
|
889
|
+
return new Response(
|
|
890
|
+
[
|
|
891
|
+
'<!doctype html><html><head><meta charset="utf-8">',
|
|
892
|
+
'<title>App Stream</title></head><body>',
|
|
893
|
+
'<h1>SASjs App Stream</h1>',
|
|
894
|
+
'<p>This browser host serves no apps: nothing is published on a static site.</p>',
|
|
895
|
+
'</body></html>'
|
|
896
|
+
].join(''),
|
|
897
|
+
{ status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } }
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
if (/^\/AppStream\/apps\.json$/i.test(stream)) return json({ apps: [] })
|
|
901
|
+
|
|
902
|
+
if (/^\/AppStream\//i.test(stream)) {
|
|
903
|
+
return json(
|
|
904
|
+
{
|
|
905
|
+
message:
|
|
906
|
+
'This browser host serves no apps: nothing is published on a static site.'
|
|
907
|
+
},
|
|
908
|
+
404
|
|
909
|
+
)
|
|
910
|
+
}
|
|
911
|
+
|
|
674
912
|
// The package's own matcher, so a route is recognised however its path is
|
|
675
913
|
// cased and its parameters are read the way the document states them.
|
|
676
914
|
const matched = API.findRoute(request.method, path)
|
|
@@ -710,6 +948,14 @@ const handle = async (request, url) => {
|
|
|
710
948
|
const answer = await API.dispatch(SERVICES, routeId, apiRequest)
|
|
711
949
|
if (request.method !== 'GET') await persistChanges()
|
|
712
950
|
|
|
951
|
+
// the shell is a page, not a file the caller named: the document states
|
|
952
|
+
// text/html for it, and the extension of `/` says nothing
|
|
953
|
+
if (routeId === 'GET /')
|
|
954
|
+
return new Response(answer.contents, {
|
|
955
|
+
status: answer.status,
|
|
956
|
+
headers: { 'content-type': 'text/html; charset=utf-8' }
|
|
957
|
+
})
|
|
958
|
+
|
|
713
959
|
if (answer.contents !== undefined)
|
|
714
960
|
return contentsResponse(
|
|
715
961
|
answer,
|
|
@@ -718,6 +964,7 @@ const handle = async (request, url) => {
|
|
|
718
964
|
return json(answer.body, answer.status)
|
|
719
965
|
} catch (e) {
|
|
720
966
|
const message = (e && e.stack) || String(e)
|
|
967
|
+
logEntry(`${request.method} ${path} ERROR ${(e && e.message) || message}`)
|
|
721
968
|
broadcast({ type: 'sasjs-host-error', path, message })
|
|
722
969
|
// A refusal the API decided on carries its status and, where the route
|
|
723
970
|
// answers an envelope, the body to send. The drive's routes send the message
|
|
@@ -736,15 +983,55 @@ const handle = async (request, url) => {
|
|
|
736
983
|
}
|
|
737
984
|
}
|
|
738
985
|
|
|
986
|
+
/**
|
|
987
|
+
* One request, answered and written to the host's activity log.
|
|
988
|
+
*
|
|
989
|
+
* The log is what the Admin routes report: a browser has no server log file, so
|
|
990
|
+
* the worker keeps its own - method, path, status and how long it took - and the
|
|
991
|
+
* three admin routes read it back.
|
|
992
|
+
*/
|
|
993
|
+
const handle = async (request, url) => {
|
|
994
|
+
const started = Date.now()
|
|
995
|
+
const response = await routeRequest(request, url)
|
|
996
|
+
logEntry(
|
|
997
|
+
`${request.method} ${url.pathname} ${response.status} ${
|
|
998
|
+
Date.now() - started
|
|
999
|
+
}ms`
|
|
1000
|
+
)
|
|
1001
|
+
return response
|
|
1002
|
+
}
|
|
1003
|
+
|
|
739
1004
|
self.addEventListener('fetch', (event) => {
|
|
740
1005
|
const url = new URL(event.request.url)
|
|
741
1006
|
if (url.origin !== self.location.origin) return
|
|
742
|
-
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
|
|
1007
|
+
|
|
1008
|
+
// The site's own page is answered by the home route, but a navigation must
|
|
1009
|
+
// never be at the API's mercy: if that route throws or answers an error, the
|
|
1010
|
+
// request falls back to the static host, which holds the same index.html. A
|
|
1011
|
+
// broken API route can cost a client its data; it must not cost the app its
|
|
1012
|
+
// page.
|
|
1013
|
+
if (url.pathname === '/') {
|
|
1014
|
+
event.respondWith(
|
|
1015
|
+
(async () => {
|
|
1016
|
+
try {
|
|
1017
|
+
const response = await handle(event.request, url)
|
|
1018
|
+
if (response && response.status < 400) return response
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
/* fall through to the static host */
|
|
1021
|
+
}
|
|
1022
|
+
return fetch(event.request)
|
|
1023
|
+
})()
|
|
1024
|
+
)
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// The host's own paths are answered here, including one the table does not
|
|
1029
|
+
// have - falling through to the static host would put the SPA's index.html
|
|
1030
|
+
// behind a typo in a caller's URL, or behind a listing an interface parses as
|
|
1031
|
+
// JSON. `/AppStream` is one of those: the package serves app deployments, but
|
|
1032
|
+
// the listing is the server's web route, and an interface that lists apps asks
|
|
1033
|
+
// for it on every visit.
|
|
1034
|
+
if (!/^\/(SASjsApi|SASLogon|AppStream)(\/|$)/i.test(url.pathname)) return
|
|
748
1035
|
event.respondWith(handle(event.request, url))
|
|
749
1036
|
})
|
|
750
1037
|
|
|
@@ -28,6 +28,16 @@ export declare const WEBOUT_SENTINELS: {
|
|
|
28
28
|
userId: string;
|
|
29
29
|
apiServerUrl: string;
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Builds `lint-browser.js` from the installed packages.
|
|
33
|
+
*
|
|
34
|
+
* The shim sources are written to the system temp folder, never beside this
|
|
35
|
+
* module: an installed package can be read-only (a global install, a pnpm store
|
|
36
|
+
* symlink, a container that builds as another user), and a build that writes
|
|
37
|
+
* inside it fails with EACCES for exactly the users least able to diagnose it.
|
|
38
|
+
* Resolution is kept by naming the CLI's `node_modules` through `nodePaths`.
|
|
39
|
+
*/
|
|
40
|
+
export declare const buildLintBundle: (destination: string) => void;
|
|
31
41
|
/**
|
|
32
42
|
* The bounds on what the browser drive seeds. A byte entry is carried as base64
|
|
33
43
|
* in vfs-seed.json and held in the worker's memory, so a large artefact - an
|