@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.
@@ -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
- // `admin`, `directory`, `code` and `home` are deliberately absent: this host
320
- // has no process to report on, no LDAP connection to import from, and the
321
- // linter is a node-only dependency. The package answers a 501 naming the
322
- // capability rather than a 404 that reads like a typo in the caller.
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 handle = async (request, url) => {
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
- // The site's own page is the home route, and the static host serves it: a
743
- // worker that answered `/` with the page it copied would be serving the app
744
- // from two places. The API's own paths are answered here, including one the
745
- // table does not have - falling through to the static host would put the SPA's
746
- // index.html behind a typo in a caller's URL.
747
- if (!/^\/(SASjsApi|SASLogon)(\/|$)/i.test(url.pathname)) return
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