@sasjs/cli 6.24.2 → 6.26.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.
@@ -99,6 +99,7 @@ var file_1 = require("../../utils/file");
99
99
  var internal_1 = require("../compile/internal");
100
100
  var config_2 = require("./internal/config");
101
101
  var getLaunchPageCode_1 = require("./internal/getLaunchPageCode");
102
+ var appManifest_1 = require("./internal/appManifest");
102
103
  var mockSite_1 = require("./internal/mockSite/mockSite");
103
104
  function build(target) {
104
105
  return __awaiter(this, void 0, void 0, function () {
@@ -241,6 +242,11 @@ function createFinalSasFiles(target) {
241
242
  servicePackSASjs.streamLogo = streamConfig.streamLogo;
242
243
  servicePackSASjs.streamServiceName = streamConfig.streamServiceName;
243
244
  servicePackSASjs.streamWebFolder = streamConfig.streamWebFolder;
245
+ // The deployment and the statement that it is an app travel together: the
246
+ // server indexes the app from this file in the same request that writes the
247
+ // files, so a publish cannot fail on its own after a deploy succeeded. It
248
+ // also means the zip a CI job keeps is everything needed to publish.
249
+ servicePackSASjs.fileTree = (0, appManifest_1.withAppManifest)(folderContentJSON, (0, appManifest_1.appManifest)(streamConfig));
244
250
  }
245
251
  }
246
252
  finalSasFileContent = (0, utils_1.removeHeader)(finalSasFileContent);
@@ -0,0 +1,42 @@
1
+ import { FileTree, StreamConfig } from '@sasjs/utils';
2
+ /**
3
+ * The file, at the root of a deployment, that says the folder is an app.
4
+ *
5
+ * It carries what a deploy payload names for a stream today - the label, the
6
+ * folder the compiled assets are in, the logo - inside the deployment itself, so
7
+ * one upload can be both the deployment and the publish.
8
+ *
9
+ * **This ships ahead of the reader.** The manifest is read by `splitAppBundle` in
10
+ * sasjs/api (PR #65), which takes it out of the tree before the tree is written
11
+ * and indexes the app in the same request; no released `@sasjs/api`, `@sasjs/adapter`
12
+ * or `sasjs/server` knows the file yet, so until a host adopts that package the
13
+ * manifest is deployed as an ordinary file and the publish still comes from the
14
+ * payload's `streamWebFolder` / `streamServiceName`, exactly as now. The name has
15
+ * to match `APP_MANIFEST` in that package - `sasjs.app.json` is spelled here
16
+ * rather than imported because the deployment does not ship the package.
17
+ */
18
+ export declare const APP_MANIFEST = "sasjs.app.json";
19
+ export interface AppManifest {
20
+ /** The label the app is listed under. */
21
+ name?: string;
22
+ /** The folder under `services/` holding the compiled web app. */
23
+ streamWebFolder: string;
24
+ /** An asset inside the app, used as its tile image. */
25
+ logo?: string;
26
+ }
27
+ /**
28
+ * What a target's stream configuration says about the app it deploys.
29
+ *
30
+ * Only fields the deployer actually set are written: a manifest that states
31
+ * `"logo": undefined` is not JSON, and one that states a default the server also
32
+ * defaults would freeze that default into every deployment.
33
+ */
34
+ export declare const appManifest: (streamConfig: StreamConfig) => AppManifest;
35
+ /**
36
+ * The deployment tree with the manifest at its root.
37
+ *
38
+ * A tree that already carries a manifest - a build output adjusted by hand, or a
39
+ * bundle built twice - has it replaced rather than duplicated, because two files
40
+ * with one name in one folder is not a tree any host can deploy.
41
+ */
42
+ export declare const withAppManifest: (tree: FileTree, manifest: AppManifest) => FileTree;
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
13
+ var __read = (this && this.__read) || function (o, n) {
14
+ var m = typeof Symbol === "function" && o[Symbol.iterator];
15
+ if (!m) return o;
16
+ var i = m.call(o), r, ar = [], e;
17
+ try {
18
+ while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
19
+ }
20
+ catch (error) { e = { error: error }; }
21
+ finally {
22
+ try {
23
+ if (r && !r.done && (m = i["return"])) m.call(i);
24
+ }
25
+ finally { if (e) throw e.error; }
26
+ }
27
+ return ar;
28
+ };
29
+ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
30
+ if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
31
+ if (ar || !(i in from)) {
32
+ if (!ar) ar = Array.prototype.slice.call(from, 0, i);
33
+ ar[i] = from[i];
34
+ }
35
+ }
36
+ return to.concat(ar || Array.prototype.slice.call(from));
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.withAppManifest = exports.appManifest = exports.APP_MANIFEST = void 0;
40
+ var utils_1 = require("@sasjs/utils");
41
+ /**
42
+ * The file, at the root of a deployment, that says the folder is an app.
43
+ *
44
+ * It carries what a deploy payload names for a stream today - the label, the
45
+ * folder the compiled assets are in, the logo - inside the deployment itself, so
46
+ * one upload can be both the deployment and the publish.
47
+ *
48
+ * **This ships ahead of the reader.** The manifest is read by `splitAppBundle` in
49
+ * sasjs/api (PR #65), which takes it out of the tree before the tree is written
50
+ * and indexes the app in the same request; no released `@sasjs/api`, `@sasjs/adapter`
51
+ * or `sasjs/server` knows the file yet, so until a host adopts that package the
52
+ * manifest is deployed as an ordinary file and the publish still comes from the
53
+ * payload's `streamWebFolder` / `streamServiceName`, exactly as now. The name has
54
+ * to match `APP_MANIFEST` in that package - `sasjs.app.json` is spelled here
55
+ * rather than imported because the deployment does not ship the package.
56
+ */
57
+ exports.APP_MANIFEST = 'sasjs.app.json';
58
+ /**
59
+ * What a target's stream configuration says about the app it deploys.
60
+ *
61
+ * Only fields the deployer actually set are written: a manifest that states
62
+ * `"logo": undefined` is not JSON, and one that states a default the server also
63
+ * defaults would freeze that default into every deployment.
64
+ */
65
+ var appManifest = function (streamConfig) { return (__assign(__assign(__assign({}, (streamConfig.streamServiceName
66
+ ? { name: streamConfig.streamServiceName }
67
+ : {})), { streamWebFolder: streamConfig.streamWebFolder }), (streamConfig.streamLogo ? { logo: streamConfig.streamLogo } : {}))); };
68
+ exports.appManifest = appManifest;
69
+ /**
70
+ * The deployment tree with the manifest at its root.
71
+ *
72
+ * A tree that already carries a manifest - a build output adjusted by hand, or a
73
+ * bundle built twice - has it replaced rather than duplicated, because two files
74
+ * with one name in one folder is not a tree any host can deploy.
75
+ */
76
+ var withAppManifest = function (tree, manifest) { return (__assign(__assign({}, tree), { members: __spreadArray(__spreadArray([], __read(tree.members.filter(function (member) {
77
+ return !(member.type === utils_1.MemberType.file && member.name === exports.APP_MANIFEST);
78
+ })), false), [
79
+ {
80
+ name: exports.APP_MANIFEST,
81
+ type: utils_1.MemberType.file,
82
+ code: JSON.stringify(manifest, null, 2)
83
+ }
84
+ ], false) })); };
85
+ exports.withAppManifest = withAppManifest;
@@ -3,29 +3,40 @@
3
3
  *
4
4
  * The service worker hosts the runtime but may be terminated between requests,
5
5
  * so it holds no durable state. On boot this script reads the persisted drive
6
- * and hands it to the worker; after every service run the worker sends back the
7
- * files it changed and they are written here.
6
+ * and identity and hands them to the worker; after every service run the worker
7
+ * sends back the files it changed and the identity store's snapshot when a route
8
+ * changed it, and both are written here.
8
9
  */
9
10
  ;(function () {
10
11
  const DB_NAME = 'sasjs-mock-host'
11
- const STORE = 'files'
12
+ const FILES = 'files'
13
+ /**
14
+ * The identity the visitor last had: the fixture the worker's store reported
15
+ * and the account the visitor chose to view the app as. One row, because the
16
+ * two are written together - a subject without the fixture it names would be a
17
+ * boot signed in as an account the store no longer holds.
18
+ */
19
+ const IDENTITY = 'identity'
20
+ const IDENTITY_KEY = 'current'
12
21
 
13
22
  const openDb = () =>
14
23
  new Promise((resolve, reject) => {
15
- const req = indexedDB.open(DB_NAME, 1)
24
+ const req = indexedDB.open(DB_NAME, 2)
16
25
  req.onupgradeneeded = () => {
17
26
  const db = req.result
18
- if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE)
27
+ if (!db.objectStoreNames.contains(FILES)) db.createObjectStore(FILES)
28
+ if (!db.objectStoreNames.contains(IDENTITY))
29
+ db.createObjectStore(IDENTITY)
19
30
  }
20
31
  req.onsuccess = () => resolve(req.result)
21
32
  req.onerror = () => reject(req.error)
22
33
  })
23
34
 
24
- const withStore = async (mode, run) => {
35
+ const withStore = async (mode, run, name = FILES) => {
25
36
  const db = await openDb()
26
37
  return new Promise((resolve, reject) => {
27
- const tx = db.transaction(STORE, mode)
28
- const store = tx.objectStore(STORE)
38
+ const tx = db.transaction(name, mode)
39
+ const store = tx.objectStore(name)
29
40
  let result
30
41
  try {
31
42
  result = run(store)
@@ -44,15 +55,15 @@
44
55
  try {
45
56
  const db = await openDb()
46
57
  const keysReq = db
47
- .transaction(STORE, 'readonly')
48
- .objectStore(STORE)
58
+ .transaction(FILES, 'readonly')
59
+ .objectStore(FILES)
49
60
  .getAllKeys()
50
61
  keysReq.onsuccess = () => {
51
62
  const keys = keysReq.result
52
63
  if (!keys.length) return resolve({})
53
64
  const valsReq = db
54
- .transaction(STORE, 'readonly')
55
- .objectStore(STORE)
65
+ .transaction(FILES, 'readonly')
66
+ .objectStore(FILES)
56
67
  .getAll()
57
68
  valsReq.onsuccess = () => {
58
69
  const files = {}
@@ -89,6 +100,36 @@
89
100
 
90
101
  const clearAll = () => withStore('readwrite', (store) => store.clear())
91
102
 
103
+ /**
104
+ * The stored identity, `null` when there is none. A row whose shape is not the
105
+ * one this script writes is ignored rather than handed on: the worker refuses a
106
+ * fixture it cannot read by name, and re-sending a value that is not a row at
107
+ * all would blame the fixture for a corrupt database.
108
+ */
109
+ const readIdentity = () =>
110
+ new Promise(async (resolve, reject) => {
111
+ try {
112
+ const db = await openDb()
113
+ const req = db
114
+ .transaction(IDENTITY, 'readonly')
115
+ .objectStore(IDENTITY)
116
+ .get(IDENTITY_KEY)
117
+ req.onsuccess = () => {
118
+ const row = req.result
119
+ resolve(row && typeof row === 'object' && row.fixture ? row : null)
120
+ }
121
+ req.onerror = () => reject(req.error)
122
+ } catch (e) {
123
+ reject(e)
124
+ }
125
+ })
126
+
127
+ const writeIdentity = (row) =>
128
+ withStore('readwrite', (store) => store.put(row, IDENTITY_KEY), IDENTITY)
129
+
130
+ const clearIdentity = () =>
131
+ withStore('readwrite', (store) => store.delete(IDENTITY_KEY), IDENTITY)
132
+
92
133
  /**
93
134
  * The build this site is, as the page can see it: mock-config.json carries the
94
135
  * stamp the build wrote over its host code and the seed it lays out. Read here
@@ -191,13 +232,29 @@
191
232
  })
192
233
  })
193
234
 
194
- // Persist whatever the worker reports as changed.
235
+ // Persist whatever the worker reports as changed: drive files, and the
236
+ // identity store's snapshot when a route created, changed or removed anything.
195
237
  navigator.serviceWorker.addEventListener('message', (ev) => {
196
238
  const data = ev.data || {}
197
- if (data.type === 'sasjs-persist')
239
+ if (data.type === 'sasjs-persist') {
198
240
  writeFiles(data.files).catch((e) =>
199
241
  console.error('[sasjs-mock] persist failed', e)
200
242
  )
243
+ if (data.identity)
244
+ readIdentity()
245
+ .then((row) =>
246
+ // `undefined`, not null: no stored subject means the fixture's own
247
+ // administrator, which is not the same as the anonymous caller a
248
+ // null would name
249
+ writeIdentity({
250
+ fixture: data.identity,
251
+ subject: row ? row.subject : undefined
252
+ })
253
+ )
254
+ .catch((e) =>
255
+ console.error('[sasjs-mock] identity persist failed', e)
256
+ )
257
+ }
201
258
  if (data.type === 'sasjs-host-error')
202
259
  console.error('[sasjs-mock] ' + data.path + ': ' + data.message)
203
260
  })
@@ -206,7 +263,52 @@
206
263
  readAll,
207
264
  writeFiles,
208
265
  clear: clearAll,
266
+ identity: readIdentity,
209
267
  snapshot: () => askWorker({ type: 'sasjs-snapshot' }, 'sasjs-snapshot'),
268
+ /**
269
+ * Views the app as another account: `as('sasjsuser')` for a restricted user,
270
+ * `as()` or `as('admin')` to return to the fixture's administrator.
271
+ *
272
+ * The worker issues a token pair for the account and answers as it from then
273
+ * on; the subject and the fixture are stored here so a reload stays that
274
+ * account, and the app is restarted so every screen redraws its menus and
275
+ * refusals. The pair is returned for a caller who wants to send it as a
276
+ * bearer token - nothing signs it, and no password is checked.
277
+ */
278
+ as: async (username) => {
279
+ const answer = await askWorker(
280
+ { type: 'sasjs-as', username },
281
+ 'sasjs-as-ack'
282
+ )
283
+ if (answer.error) throw new Error(answer.error)
284
+
285
+ try {
286
+ await writeIdentity({
287
+ fixture: answer.identity,
288
+ subject: answer.username
289
+ })
290
+ } catch (e) {
291
+ // the subject still applies to this run; it just will not outlive it
292
+ console.error('[sasjs-mock] could not store the subject: ' + e)
293
+ }
294
+
295
+ const pair = {
296
+ username: answer.username,
297
+ accessToken: answer.accessToken,
298
+ refreshToken: answer.refreshToken
299
+ }
300
+
301
+ // a task, so the caller's promise resolves with the pair first
302
+ setTimeout(() => {
303
+ try {
304
+ window.location.reload()
305
+ } catch (e) {
306
+ /* a page that cannot reload keeps the subject it already has */
307
+ }
308
+ }, 0)
309
+
310
+ return pair
311
+ },
210
312
  /**
211
313
  * The drive total in bytes, measured by the worker: it owns the stored
212
314
  * representation, so the figure stays the one `statSync` reports rather than
@@ -249,20 +351,62 @@
249
351
  return Object.keys(files).length
250
352
  },
251
353
  reset: async () => {
252
- await askWorker({ type: 'sasjs-reset' }, 'sasjs-reset-ack')
354
+ const answer = await askWorker({ type: 'sasjs-reset' }, 'sasjs-reset-ack')
355
+ if (answer.error) throw new Error(answer.error)
253
356
  await clearAll()
357
+ await clearIdentity()
254
358
  return true
255
359
  }
256
360
  }
257
361
 
362
+ /**
363
+ * Boots the worker with what is stored, and recovers when the stored identity
364
+ * is one this build cannot read.
365
+ *
366
+ * A fixture outlives the build that wrote it, exactly as the drive does, and
367
+ * the worker refuses one whose format stamp it does not speak - by name, and
368
+ * with `errorKind: 'identity'`. Only that answer drops the stored row: the
369
+ * row holds the accounts the visitor created as well as the fixture, so a
370
+ * host-side failure (a drive that could not be read) leaves it alone.
371
+ */
372
+ const boot = async (files, stored) => {
373
+ const loading = Object.keys(files).length > 0
374
+ const type = loading ? 'sasjs-load' : 'sasjs-seed'
375
+ const ack = loading ? 'sasjs-load-ack' : 'sasjs-seed-ack'
376
+
377
+ const answer = await askWorker(
378
+ {
379
+ type,
380
+ files,
381
+ identity: stored ? stored.fixture : undefined,
382
+ subject: stored ? stored.subject : undefined
383
+ },
384
+ ack
385
+ )
386
+
387
+ if (answer.error && stored && answer.errorKind === 'identity') {
388
+ console.info('[sasjs-mock] ' + answer.error)
389
+ try {
390
+ await clearIdentity()
391
+ } catch (e) {
392
+ console.error('[sasjs-mock] could not clear the stored identity: ' + e)
393
+ }
394
+ return await askWorker({ type, files }, ack)
395
+ }
396
+
397
+ return answer
398
+ }
399
+
258
400
  window.__sasjsMockReady = (async () => {
259
401
  await navigator.serviceWorker.ready
260
402
  const controller = await waitForController()
261
403
  if (!controller) return { error: 'no service worker controller' }
262
404
 
263
405
  let files = {}
406
+ let stored = null
264
407
  try {
265
408
  files = await readAll()
409
+ stored = await readIdentity()
266
410
  } catch (e) {
267
411
  console.error('[sasjs-mock] could not read storage: ' + e)
268
412
  }
@@ -287,23 +431,18 @@
287
431
  )
288
432
  try {
289
433
  await clearAll()
434
+ // the identity is part of what this build ships, so a visitor who kept
435
+ // the previous build's fixture would never see a changed
436
+ // `mock-identity.json`: it goes with the drive, as it does on reset
437
+ await clearIdentity()
290
438
  } catch (e) {
291
439
  console.error('[sasjs-mock] could not clear storage: ' + e)
292
440
  }
293
441
  files = {}
442
+ stored = null
294
443
  }
295
444
 
296
- if (Object.keys(files).length) {
297
- window.__sasjsMockBoot = await askWorker(
298
- { type: 'sasjs-load', files },
299
- 'sasjs-load-ack'
300
- )
301
- } else {
302
- window.__sasjsMockBoot = await askWorker(
303
- { type: 'sasjs-seed' },
304
- 'sasjs-seed-ack'
305
- )
306
- }
445
+ window.__sasjsMockBoot = await boot(files, stored)
307
446
  if (window.__sasjsMockBoot && !window.__sasjsMockBoot.error)
308
447
  keepBuildStamp(stamp)
309
448
  return window.__sasjsMockBoot