@sasjs/cli 6.16.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.
@@ -859,6 +859,56 @@ const routeRequest = async (request, url) => {
859
859
  try {
860
860
  await R.ensureReady()
861
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
+
862
912
  // The package's own matcher, so a route is recognised however its path is
863
913
  // cased and its parameters are read the way the document states them.
864
914
  const matched = API.findRoute(request.method, path)
@@ -975,10 +1025,13 @@ self.addEventListener('fetch', (event) => {
975
1025
  return
976
1026
  }
977
1027
 
978
- // The API's own paths are answered here, including one the table does not
1028
+ // The host's own paths are answered here, including one the table does not
979
1029
  // have - falling through to the static host would put the SPA's index.html
980
- // behind a typo in a caller's URL.
981
- if (!/^\/(SASjsApi|SASLogon)(\/|$)/i.test(url.pathname)) return
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
982
1035
  event.respondWith(handle(event.request, url))
983
1036
  })
984
1037
 
@@ -472,12 +472,12 @@ var assertSafeOutputFolder = function (outputFolder, projectDir, buildFolder, we
472
472
  !isSameOrChild(buildFolder, outputFolder))
473
473
  throw refusal('that folder is outside the project');
474
474
  };
475
- var countJsPrograms = function (files) {
475
+ var countPrograms = function (files, extension) {
476
476
  return Object.keys(files).filter(function (key) {
477
477
  var inProgramFolder = PROGRAM_FOLDERS.some(function (folder) {
478
478
  return key.includes("/".concat(folder, "/"));
479
479
  });
480
- return inProgramFolder && key.toLowerCase().endsWith('.js');
480
+ return inProgramFolder && key.toLowerCase().endsWith(extension);
481
481
  }).length;
482
482
  };
483
483
  /** The script-tag attributes that change how an app's script runs. */
@@ -646,11 +646,11 @@ var patchIndexHtml = function (html, version) {
646
646
  */
647
647
  function buildMockSite(target) {
648
648
  return __awaiter(this, void 0, void 0, function () {
649
- var projectDir, appLocRaw, appLoc, buildFolder, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, driveEntries, webFileCount, hostFolder, _b, _c, asset, hostVersion, swPath, snapshotSource, hasSnapshot, indexPath, _d, patched, warnings;
649
+ var projectDir, appLocRaw, appLoc, buildFolder, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, sasPrograms, driveEntries, webFileCount, hostFolder, _b, _c, asset, hostVersion, swPath, snapshotSource, hasSnapshot, indexPath, _d, patched, warnings;
650
650
  var e_8, _e;
651
- var _f, _g, _h;
652
- return __generator(this, function (_j) {
653
- switch (_j.label) {
651
+ var _f, _g, _h, _j;
652
+ return __generator(this, function (_k) {
653
+ switch (_k.label) {
654
654
  case 0:
655
655
  projectDir = process.projectDir || process.cwd();
656
656
  appLocRaw = target.appLoc;
@@ -662,7 +662,7 @@ function buildMockSite(target) {
662
662
  path_1.default.join(projectDir, 'sasjsbuild');
663
663
  return [4 /*yield*/, (0, utils_1.folderExists)(buildFolder)];
664
664
  case 1:
665
- if (!(_j.sent())) {
665
+ if (!(_k.sent())) {
666
666
  throw new Error("Build folder not found: ".concat(buildFolder, ". Run `sasjs compile` first."));
667
667
  }
668
668
  webSourceRaw = (_f = target.streamConfig) === null || _f === void 0 ? void 0 : _f.webSourcePath;
@@ -672,7 +672,7 @@ function buildMockSite(target) {
672
672
  webFolder = path_1.default.resolve(projectDir, webSourceRaw);
673
673
  return [4 /*yield*/, (0, utils_1.folderExists)(webFolder)];
674
674
  case 2:
675
- if (!(_j.sent())) {
675
+ if (!(_k.sent())) {
676
676
  throw new Error("Web source folder not found: ".concat(webFolder, ". Check streamConfig.webSourcePath - build the frontend first."));
677
677
  }
678
678
  if (!fs_1.default.existsSync(path_1.default.join(webFolder, 'index.html'))) {
@@ -690,12 +690,26 @@ function buildMockSite(target) {
690
690
  excluded.push("/sasjs/drive/files".concat(appLoc, "/services/").concat(streamConfig.streamWebFolder, "/"));
691
691
  }
692
692
  _a = seedFromBuild(buildFolder, appLoc, excluded, [outputFolder]), seed = _a.files, skipped = _a.skipped;
693
- jsPrograms = countJsPrograms(seed);
693
+ jsPrograms = countPrograms(seed, '.js');
694
+ sasPrograms = countPrograms(seed, '.sas');
694
695
  driveEntries = Object.keys(seed).length;
695
- if (jsPrograms === 0) {
696
- throw new Error("No .js programs in ".concat(buildFolder, "/services, /jobs or /tests. This target builds SAS code, which cannot run in a browser. ") +
696
+ // A build holding SAS and no JavaScript is a SAS target: its services cannot
697
+ // run here, and seeding them would produce a site whose buttons answer 400.
698
+ // A build holding NEITHER is a frontend host - the app is the whole thing and
699
+ // the worker answers its API - which is what an interface with no services
700
+ // compiles to, and the case this site is built for.
701
+ if (jsPrograms === 0 && sasPrograms > 0) {
702
+ throw new Error("No .js programs in ".concat(buildFolder, "/services, /jobs or /tests, but ").concat(sasPrograms, " SAS program(s). This target builds SAS code, which cannot run in a browser. ") +
697
703
  'Build a target whose services are JavaScript (typically a mocks project).');
698
704
  }
705
+ // No PROGRAMS is a different thing again: a compile that produced nothing, or
706
+ // a stale sasjsbuild from an unrelated command - `sasjs doc` leaves a folder
707
+ // behind that is not a program folder but does reach the drive, so the count
708
+ // that decides this is of programs, not of files.
709
+ if (jsPrograms + sasPrograms === 0) {
710
+ (_h = process.logger) === null || _h === void 0 ? void 0 : _h.warn("No compiled programs in ".concat(buildFolder, ": this site ships with an empty drive. ") +
711
+ 'That is expected for an interface with no services; for anything else, check that compile wrote the target.');
712
+ }
699
713
  // The package supplies the drive rules the worker answers with, so an install
700
714
  // without its browser build is a build that cannot run. Checked here, with the
701
715
  // other refusals, so a missing dependency leaves the previous site in place -
@@ -706,7 +720,7 @@ function buildMockSite(target) {
706
720
  'or check that @sasjs/api still publishes build/browser.js.');
707
721
  }
708
722
  if (skipped.length) {
709
- (_h = process.logger) === null || _h === void 0 ? void 0 : _h.warn("Left ".concat(skipped.length, " file(s) off the mock drive, over the ").concat(megabytes(exports.MAX_BINARY_SEED_BYTES), " per-file or ").concat(megabytes(exports.MAX_SEED_BYTES), " total the browser build seeds: ") +
723
+ (_j = process.logger) === null || _j === void 0 ? void 0 : _j.warn("Left ".concat(skipped.length, " file(s) off the mock drive, over the ").concat(megabytes(exports.MAX_BINARY_SEED_BYTES), " per-file or ").concat(megabytes(exports.MAX_SEED_BYTES), " total the browser build seeds: ") +
710
724
  "".concat(skipped
711
725
  .slice(0, 3)
712
726
  .map(function (file) {
@@ -753,7 +767,7 @@ function buildMockSite(target) {
753
767
  case 3:
754
768
  // 2. the linter, bundled from the installed packages so the code routes answer
755
769
  // with the rules this CLI resolved rather than a second implementation
756
- _j.sent();
770
+ _k.sent();
757
771
  // sw.js sits at the site root: a worker can only control the folder it is
758
772
  // served from, and the app has to be controlled from /
759
773
  fs_1.default.copyFileSync(path_1.default.join(ASSETS_FOLDER, 'sw.js'), path_1.default.join(outputFolder, 'sw.js'));
@@ -690,6 +690,95 @@ describe('browser-hosted build', function () {
690
690
  }
691
691
  });
692
692
  }); });
693
+ /**
694
+ * The case this build exists for: an interface with no services at all.
695
+ * sasjs/server's own frontend compiles to nothing, and the worker answers its
696
+ * API - so the target must build, ship the web app, and say out loud that the
697
+ * drive it ships is empty.
698
+ */
699
+ it('builds an interface that has no services, and says the drive is empty', function () { return __awaiter(void 0, void 0, void 0, function () {
700
+ var _a, _b, folder, warn, result;
701
+ var e_4, _c;
702
+ var _d;
703
+ return __generator(this, function (_e) {
704
+ switch (_e.label) {
705
+ case 0:
706
+ try {
707
+ // nothing a drive could be seeded from: no services, no sync folder
708
+ for (_a = __values(['services', 'mock-storage']), _b = _a.next(); !_b.done; _b = _a.next()) {
709
+ folder = _b.value;
710
+ fs_1.default.rmSync(path_1.default.join(root, 'sasjsbuild', folder), {
711
+ recursive: true,
712
+ force: true
713
+ });
714
+ }
715
+ }
716
+ catch (e_4_1) { e_4 = { error: e_4_1 }; }
717
+ finally {
718
+ try {
719
+ if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
720
+ }
721
+ finally { if (e_4) throw e_4.error; }
722
+ }
723
+ warn = jest.spyOn((_d = process.logger) !== null && _d !== void 0 ? _d : console, 'warn');
724
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
725
+ case 1:
726
+ result = _e.sent();
727
+ expect(result.jsPrograms).toBe(0);
728
+ expect(result.driveEntries).toBe(0);
729
+ expect(fs_1.default.existsSync(path_1.default.join(out(), 'index.html'))).toBe(true);
730
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('ships with an empty drive'));
731
+ warn.mockRestore();
732
+ return [2 /*return*/];
733
+ }
734
+ });
735
+ }); });
736
+ /**
737
+ * A stale build folder is the case the warning exists for: `sasjs doc` and
738
+ * other commands leave folders behind that are not program folders, so a count
739
+ * of files on the drive says nothing about whether anything was compiled.
740
+ */
741
+ it('warns when the build folder holds only folders that are not programs', function () { return __awaiter(void 0, void 0, void 0, function () {
742
+ var _a, _b, folder, warn, result;
743
+ var e_5, _c;
744
+ var _d;
745
+ return __generator(this, function (_e) {
746
+ switch (_e.label) {
747
+ case 0:
748
+ try {
749
+ for (_a = __values(['services', 'mock-storage']), _b = _a.next(); !_b.done; _b = _a.next()) {
750
+ folder = _b.value;
751
+ fs_1.default.rmSync(path_1.default.join(root, 'sasjsbuild', folder), {
752
+ recursive: true,
753
+ force: true
754
+ });
755
+ }
756
+ }
757
+ catch (e_5_1) { e_5 = { error: e_5_1 }; }
758
+ finally {
759
+ try {
760
+ if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
761
+ }
762
+ finally { if (e_5) throw e_5.error; }
763
+ }
764
+ write(path_1.default.join(root, 'sasjsbuild', 'docs', 'index.html'), '<html></html>');
765
+ warn = jest.spyOn((_d = process.logger) !== null && _d !== void 0 ? _d : console, 'warn');
766
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())
767
+ // the drive is NOT empty - the docs folder reaches it - so the file count
768
+ // alone would have kept quiet
769
+ ];
770
+ case 1:
771
+ result = _e.sent();
772
+ // the drive is NOT empty - the docs folder reaches it - so the file count
773
+ // alone would have kept quiet
774
+ expect(result.driveEntries).toBeGreaterThan(0);
775
+ expect(result.jsPrograms).toBe(0);
776
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('No compiled programs'));
777
+ warn.mockRestore();
778
+ return [2 /*return*/];
779
+ }
780
+ });
781
+ }); });
693
782
  it('refuses to build without a web app', function () { return __awaiter(void 0, void 0, void 0, function () {
694
783
  return __generator(this, function (_a) {
695
784
  switch (_a.label) {
@@ -773,8 +862,8 @@ describe('browser-hosted build', function () {
773
862
  });
774
863
  }); });
775
864
  it('refuses a mockWebFolder that would delete the project or its inputs', function () { return __awaiter(void 0, void 0, void 0, function () {
776
- var _a, _b, mockWebFolder, e_4_1;
777
- var e_4, _c;
865
+ var _a, _b, mockWebFolder, e_6_1;
866
+ var e_6, _c;
778
867
  return __generator(this, function (_d) {
779
868
  switch (_d.label) {
780
869
  case 0:
@@ -805,14 +894,14 @@ describe('browser-hosted build', function () {
805
894
  return [3 /*break*/, 1];
806
895
  case 4: return [3 /*break*/, 7];
807
896
  case 5:
808
- e_4_1 = _d.sent();
809
- e_4 = { error: e_4_1 };
897
+ e_6_1 = _d.sent();
898
+ e_6 = { error: e_6_1 };
810
899
  return [3 /*break*/, 7];
811
900
  case 6:
812
901
  try {
813
902
  if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
814
903
  }
815
- finally { if (e_4) throw e_4.error; }
904
+ finally { if (e_6) throw e_6.error; }
816
905
  return [7 /*endfinally*/];
817
906
  case 7:
818
907
  expect(fs_1.default.existsSync(path_1.default.join(root, 'client', 'dist', 'index.html'))).toBe(true);
@@ -1157,6 +1157,46 @@ describe('service worker host', function () {
1157
1157
  }
1158
1158
  });
1159
1159
  }); });
1160
+ it('answers the app stream as the server does, with nothing in it', function () { return __awaiter(void 0, void 0, void 0, function () {
1161
+ var dispatch, listing, _a, root, landing, _b, app, _c;
1162
+ return __generator(this, function (_d) {
1163
+ switch (_d.label) {
1164
+ case 0:
1165
+ dispatch = seeded().dispatch;
1166
+ return [4 /*yield*/, dispatch('/AppStream/apps.json')];
1167
+ case 1:
1168
+ listing = _d.sent();
1169
+ expect(listing.status).toBe(200);
1170
+ _a = expect;
1171
+ return [4 /*yield*/, listing.json()];
1172
+ case 2:
1173
+ _a.apply(void 0, [_d.sent()]).toEqual({ apps: [] });
1174
+ return [4 /*yield*/, dispatch('/AppStream')];
1175
+ case 3:
1176
+ root = _d.sent();
1177
+ expect(root.status).toBe(301);
1178
+ expect(root.headers.get('location')).toBe('/AppStream/');
1179
+ return [4 /*yield*/, dispatch('/AppStream/')];
1180
+ case 4:
1181
+ landing = _d.sent();
1182
+ expect(landing.status).toBe(200);
1183
+ expect(landing.headers.get('content-type')).toContain('text/html');
1184
+ _b = expect;
1185
+ return [4 /*yield*/, landing.text()];
1186
+ case 5:
1187
+ _b.apply(void 0, [_d.sent()]).toContain('serves no apps');
1188
+ return [4 /*yield*/, dispatch('/AppStream/Demo/')];
1189
+ case 6:
1190
+ app = _d.sent();
1191
+ expect(app.status).toBe(404);
1192
+ _c = expect;
1193
+ return [4 /*yield*/, app.json()];
1194
+ case 7:
1195
+ _c.apply(void 0, [(_d.sent()).message]).toContain('serves no apps');
1196
+ return [2 /*return*/];
1197
+ }
1198
+ });
1199
+ }); });
1160
1200
  it('leaves a request to another origin alone', function () {
1161
1201
  var dispatch = seeded().dispatch;
1162
1202
  expect(dispatch('https://other.example.com/SASjsApi/info')).toBeUndefined();
package/package.json CHANGED
@@ -125,5 +125,5 @@
125
125
  "ws": "8.21.0",
126
126
  "nanoid": "3.3.18"
127
127
  },
128
- "version": "6.16.0"
128
+ "version": "6.17.0"
129
129
  }