@sasjs/cli 6.30.0 → 6.31.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.
Files changed (23) hide show
  1. package/build/commands/build/internal/mockSite/assets/api-console.css +365 -0
  2. package/build/commands/build/internal/mockSite/assets/api-console.js +762 -0
  3. package/build/commands/build/internal/mockSite/assets/api-index.html +83 -0
  4. package/build/commands/build/internal/mockSite/assets/boot.js +23 -1
  5. package/build/commands/build/internal/mockSite/assets/persist.js +76 -5
  6. package/build/commands/build/internal/mockSite/assets/runtime-core.js +413 -50
  7. package/build/commands/build/internal/mockSite/assets/sw.js +156 -15
  8. package/build/commands/build/internal/mockSite/mockSite.d.ts +25 -0
  9. package/build/commands/build/internal/mockSite/mockSite.js +311 -24
  10. package/build/commands/build/internal/mockSite/spec/apiConsole.spec.d.ts +1 -0
  11. package/build/commands/build/internal/mockSite/spec/apiConsole.spec.js +1371 -0
  12. package/build/commands/build/internal/mockSite/spec/apiConsoleBasePath.spec.d.ts +1 -0
  13. package/build/commands/build/internal/mockSite/spec/apiConsoleBasePath.spec.js +303 -0
  14. package/build/commands/build/internal/mockSite/spec/boot.spec.js +50 -3
  15. package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +217 -3
  16. package/build/commands/build/internal/mockSite/spec/persist.spec.js +179 -1
  17. package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +546 -0
  18. package/build/commands/build/internal/mockSite/spec/sw.spec.js +407 -53
  19. package/build/commands/version/spec/version.spec.js +11 -1
  20. package/build/commands/version/version.d.ts +5 -3
  21. package/build/commands/version/version.js +17 -7
  22. package/build/types/mockWebBasePath.d.ts +9 -0
  23. package/package.json +13 -32
@@ -37,7 +37,6 @@
37
37
  */
38
38
  importScripts('host/runtime-core.js')
39
39
  importScripts('host/api-browser.js')
40
- importScripts('host/lint-browser.js')
41
40
 
42
41
  const R = self.SASjsMockRuntime
43
42
 
@@ -47,10 +46,6 @@ const R = self.SASjsMockRuntime
47
46
  // and the few capabilities a static origin does not have, which the package
48
47
  // reports as a 501 naming the missing service.
49
48
  const API = self.SASjsApi
50
- // The linter, bundled at build time from the installed @sasjs/api/code and
51
- // @sasjs/lint (see mockSite.ts). It is a separate script because the linter is
52
- // not part of the root package a service worker can import.
53
- const CODE = self.SASjsCode
54
49
 
55
50
  const DRIVE_STORE = R.driveStore
56
51
 
@@ -968,6 +963,53 @@ const homeService = API.createHomeService(async () => {
968
963
  */
969
964
  const LINT_CONFIG_FILE = '.sasjslint'
970
965
 
966
+ /**
967
+ * The linter, bundled at build time from the installed @sasjs/api/code and
968
+ * @sasjs/lint (see mockSite.ts).
969
+ *
970
+ * Imported on the first code route rather than by the worker at boot: it is
971
+ * around 40KB of the worker's payload for two routes (`code/lint` and
972
+ * `code/format`), and the install has to finish before the page may make its
973
+ * first request - so every visit pays it, including the ones that never open the
974
+ * editor. The build stamps the version into the URL below, so the copy fetched
975
+ * here is cache-busted with the rest of the host.
976
+ */
977
+ let codeService
978
+ let linter
979
+
980
+ const code = () => {
981
+ if (codeService) return codeService
982
+ importScripts('host/lint-browser.js')
983
+ linter = self.SASjsCode
984
+ codeService = linter.createCodeService({ lintConfig })
985
+ return codeService
986
+ }
987
+
988
+ /**
989
+ * The package's code service, resolved on first use.
990
+ *
991
+ * A proxy rather than an object listing `lint` and `format`: the package decides
992
+ * which methods a service has, and a facade would answer `undefined` for one a
993
+ * later version adds. Dispatch reads `services.code` once per request and only
994
+ * touches a method on a code route, so nothing below the first method access
995
+ * loads the linter.
996
+ */
997
+ const lazyCode = () =>
998
+ new Proxy(
999
+ {},
1000
+ {
1001
+ get: (target, property) => {
1002
+ // probing for a promise is not a method lookup: answering it must not
1003
+ // pull 40KB
1004
+ if (typeof property === 'symbol' || property === 'then')
1005
+ return undefined
1006
+ const service = code()
1007
+ const value = service[property]
1008
+ return typeof value === 'function' ? value.bind(service) : value
1009
+ }
1010
+ }
1011
+ )
1012
+
971
1013
  const lintConfig = async (filePath) => {
972
1014
  // A path that escapes the drive has no folder to walk from, and a buffer that
973
1015
  // names no file has none either: both take the drive root's rules, which is
@@ -977,9 +1019,14 @@ const lintConfig = async (filePath) => {
977
1019
  for (;;) {
978
1020
  const configPath =
979
1021
  folder === '/' ? `/${LINT_CONFIG_FILE}` : `${folder}/${LINT_CONFIG_FILE}`
980
- const contents = await DRIVE_STORE.read(configPath).catch(() => null)
1022
+ const contents = await DRIVE_STORE.read(configPath).catch((e) => {
1023
+ // a piece that is still on the site is not a folder with no rules file:
1024
+ // swallowing it here would lint against the defaults and never fetch it
1025
+ if (e && e.code === R.PENDING_CODE) throw e
1026
+ return null
1027
+ })
981
1028
  const parsed = parseLintConfig(contents)
982
- if (parsed) return new CODE.LintConfig(parsed)
1029
+ if (parsed) return new linter.LintConfig(parsed)
983
1030
  if (folder === '/') return undefined
984
1031
  folder = API.dirname(folder)
985
1032
  }
@@ -1026,7 +1073,7 @@ const SERVICES = {
1026
1073
  // own rules: a buffer is linted and formatted against the nearest
1027
1074
  // `.sasjslint` at or above its folder, so editing that file changes the
1028
1075
  // editor here exactly as it does on a server
1029
- code: CODE.createCodeService({ lintConfig })
1076
+ code: lazyCode()
1030
1077
  }
1031
1078
 
1032
1079
  /** The routes this host performs itself, because the answer is not JSON. */
@@ -1355,6 +1402,15 @@ const subjectOf = async (request, query) => {
1355
1402
  * `_debug` means - is still the package's decision.
1356
1403
  */
1357
1404
  const runRoute = async (routeId, payload, subject) => {
1405
+ // A program may read any file it likes - the drive is its filesystem - so
1406
+ // every piece of a sharded drive is fetched here rather than guessed from the
1407
+ // request. That is deliberately the whole drive and not just the app being
1408
+ // run: replaying a program because a read failed would run its writes twice,
1409
+ // so the safe choice costs the download of every other app's bundle the first
1410
+ // time anything runs on a cold visit. A page that only lists apps never pays
1411
+ // it; the saving this change is for is on that page.
1412
+ await R.ensureAll()
1413
+
1358
1414
  const query = payload.query
1359
1415
  const body = payload.body
1360
1416
  const tables = payload.files.map((file) => ({
@@ -1446,7 +1502,38 @@ const contentsResponse = (answer, path) => {
1446
1502
  })
1447
1503
  }
1448
1504
 
1449
- const routeRequest = async (request, url) => {
1505
+ /**
1506
+ * The shards a request names, fetched before it is answered.
1507
+ *
1508
+ * The drive of a sharded site is not in memory: the build splits a capture so a
1509
+ * first page does not download every app's bundle, and a route that reads a path
1510
+ * has to ask for the piece holding it. The two query parameters below are where
1511
+ * a request names a path outright, which is the common case and the cheap one -
1512
+ * whatever a route resolves from the drive itself is left to the retry in
1513
+ * `routeRequest`, which knows the path from the read that failed rather than
1514
+ * guessing at it from the URL.
1515
+ */
1516
+ const ensureForRequest = async (url) => {
1517
+ const paths = []
1518
+ for (const name of ['_filePath', '_program']) {
1519
+ const value = url.searchParams.get(name)
1520
+ if (value) paths.push(value)
1521
+ }
1522
+ if (paths.length) await R.ensurePaths(paths)
1523
+ }
1524
+
1525
+ /**
1526
+ * One attempt at a request, with the body source a retry can copy again.
1527
+ *
1528
+ * A Request's body can only be read once, and a retry (see the catch below) is a
1529
+ * second read of the same request - so the first attempt keeps an unread copy
1530
+ * beside it and every attempt after that is made from a copy of the copy. Only a
1531
+ * request with a body needs one, and `source` is what carries it through the
1532
+ * recursion.
1533
+ */
1534
+ const routeRequest = async (request, url, attempts = 0, source = null) => {
1535
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
1536
+ const replay = hasBody ? source || request.clone() : null
1450
1537
  const path = withoutBase(url.pathname).replace(/\/+$/, '') || '/'
1451
1538
  // `/api` is the short spelling of the API, and `/SASjsApi` the canonical one:
1452
1539
  // both are answered, so the alias is stripped once here and everything below -
@@ -1462,6 +1549,7 @@ const routeRequest = async (request, url) => {
1462
1549
  // (a static asset fetch raced by an API call) must not answer as nobody
1463
1550
  await ensureIdentity()
1464
1551
  await R.ensureReady()
1552
+ await ensureForRequest(url)
1465
1553
 
1466
1554
  // The app stream is the server's own web surface rather than part of the
1467
1555
  // API: the listing, the landing page and the redirect from one to the other
@@ -1577,6 +1665,20 @@ const routeRequest = async (request, url) => {
1577
1665
  )
1578
1666
  return json(answer.body, answer.status)
1579
1667
  } catch (e) {
1668
+ // A read of a file that is still on the site: the route reached a path the
1669
+ // request did not name - an app asset, a `.sasjslint` above the edited file,
1670
+ // a join a service resolves from the drive. The failure names the path, so
1671
+ // the first attempt fetches exactly that piece; a second one in the same
1672
+ // request means it wants more than one, so the rest of the drive comes in.
1673
+ // Bounded, because a refusal that keeps repeating is a real one.
1674
+ if (e && e.code === R.PENDING_CODE && attempts < 2) {
1675
+ if (!attempts && e.path) await R.ensurePaths([e.path])
1676
+ else await R.ensureAll()
1677
+ // a fresh copy per attempt: the one this attempt read is spent
1678
+ const next = replay ? replay.clone() : request
1679
+ return await routeRequest(next, url, attempts + 1, replay)
1680
+ }
1681
+
1580
1682
  const message = (e && e.stack) || String(e)
1581
1683
  const status = (e && e.status) || 500
1582
1684
  const refusal = status >= 400 && status < 500
@@ -1630,6 +1732,22 @@ const handle = async (request, url) => {
1630
1732
  return response
1631
1733
  }
1632
1734
 
1735
+ // The console's own files under `/api/`, replaced at build time from the list the
1736
+ // builder writes the folder from, so the two cannot drift apart.
1737
+ const API_CONSOLE_FILES = [
1738
+ 'index.html',
1739
+ 'console.js',
1740
+ 'console.css',
1741
+ 'operations.json'
1742
+ ]
1743
+
1744
+ const CONSOLE_FILE = new RegExp(
1745
+ '^/api/(' +
1746
+ API_CONSOLE_FILES.map((name) => name.replace(/\./g, '\\.')).join('|') +
1747
+ ')$',
1748
+ 'i'
1749
+ )
1750
+
1633
1751
  self.addEventListener('fetch', (event) => {
1634
1752
  const url = new URL(event.request.url)
1635
1753
  if (url.origin !== self.location.origin) return
@@ -1662,12 +1780,20 @@ self.addEventListener('fetch', (event) => {
1662
1780
  // listing is the server's web route, and an interface that lists apps asks for
1663
1781
  // it on every visit.
1664
1782
  //
1665
- // `/api` and `/api/` on their own are the API's documentation page - a file
1666
- // the build copies into the site - so the worker stays out of the way there
1667
- // and the static host answers it. Every other `/api/...` is an API call on the
1668
- // short spelling, which routeRequest canonicalises to `/SASjsApi`.
1783
+ // `/api` and `/api/` on their own are the console page - `api/index.html`,
1784
+ // which this build writes - so the worker stays out of the way there and the
1785
+ // static host answers it. Every other `/api/...` is an API call on the short
1786
+ // spelling, which routeRequest canonicalises to `/SASjsApi`.
1669
1787
  if (/^\/api\/?$/i.test(path)) return
1670
1788
 
1789
+ // And the console's own files, which live under that prefix: a path naming one
1790
+ // of them is the page's, not an API call, so the static host answers it. Named
1791
+ // one by one on purpose - an extension test would also pass through
1792
+ // `/api/admin/logs/foo.log`, which is a real call on the short spelling. The
1793
+ // list is replaced at build time with the one the builder writes the folder
1794
+ // from, so a file added there cannot arrive here as an API path.
1795
+ if (CONSOLE_FILE.test(path)) return
1796
+
1671
1797
  if (!/^\/(SASjsApi|SASLogon|AppStream|api|app)(\/|$)/i.test(path)) return
1672
1798
  event.respondWith(handle(event.request, url))
1673
1799
  })
@@ -1681,7 +1807,7 @@ self.addEventListener('message', (event) => {
1681
1807
  case 'sasjs-load':
1682
1808
  event.waitUntil(
1683
1809
  bootIdentity(data)
1684
- .then(() => R.load(data.files || {}))
1810
+ .then(() => R.load(data.files || {}, data.deleted || []))
1685
1811
  .then(async () => {
1686
1812
  await persistChanges()
1687
1813
  reply({
@@ -1844,7 +1970,22 @@ self.addEventListener('message', (event) => {
1844
1970
  }
1845
1971
 
1846
1972
  case 'sasjs-snapshot':
1847
- reply({ type: 'sasjs-snapshot', files: R.dumpAll() })
1973
+ // a sharded drive is not all in memory, and the caller is exporting it -
1974
+ // for persistence after a change, or as `mock-snapshot.json` for the next
1975
+ // build. Whole means whole: the pieces nobody read are fetched first, or
1976
+ // the export would quietly lose every app bundle it had not opened.
1977
+ event.waitUntil(
1978
+ R.ensureReady()
1979
+ .then(() => R.ensureAll())
1980
+ .then(() => reply({ type: 'sasjs-snapshot', files: R.dumpAll() }))
1981
+ .catch((e) =>
1982
+ reply({
1983
+ type: 'sasjs-snapshot',
1984
+ files: R.dumpAll(),
1985
+ error: (e && e.message) || String(e)
1986
+ })
1987
+ )
1988
+ )
1848
1989
  break
1849
1990
 
1850
1991
  case 'sasjs-stats':
@@ -9,6 +9,13 @@ export interface MockSiteResult {
9
9
  webFileCount: number;
10
10
  /** True when a captured drive snapshot was shipped with the site. */
11
11
  snapshot: boolean;
12
+ /**
13
+ * How many pieces the captured drive ships in: the boot shard plus one per
14
+ * large folder or file. Zero when the site ships no snapshot.
15
+ */
16
+ snapshotShards: number;
17
+ /** True when the site carries the API console at <base>/api/. */
18
+ apiConsole: boolean;
12
19
  /** True when a recorded SAS session was shipped, for .sas runs to answer. */
13
20
  sasTranscript: boolean;
14
21
  /**
@@ -64,6 +71,24 @@ export declare const MAX_SEED_BYTES: number;
64
71
  * marker format changes.
65
72
  */
66
73
  export declare const DRIVE_FORMAT = 1;
74
+ /**
75
+ * The size at which a captured file is fetched on its own rather than with its
76
+ * folder.
77
+ *
78
+ * A shard is a download the visitor waits for, and an app's folder holds both
79
+ * its bundles and the small files a listing shows - its logo, a config. Splitting
80
+ * on size keeps one from dragging the other: the tile asks for the folder's small
81
+ * files, the app's own page asks for the bundle.
82
+ */
83
+ export declare const SHARD_FILE_BYTES: number;
84
+ /**
85
+ * The size below which a folder's files ride in the boot shard.
86
+ *
87
+ * The boot shard is fetched before the first page renders, so it is paid by every
88
+ * visit. Folders under this size are cheaper there than as a request of their
89
+ * own; folders over it are only fetched when something reads them.
90
+ */
91
+ export declare const SHARD_GROUP_BYTES: number;
67
92
  /**
68
93
  * Builds a static site that runs the project's JS services (its mocks) in the
69
94
  * browser, so a frontend can be tested with no server and no SAS.