@sasjs/cli 6.27.2 → 6.27.3

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.
@@ -653,6 +653,11 @@ var versionScripts = function (scripts, outputFolder) {
653
653
  var src = script.src;
654
654
  if (!src)
655
655
  return script;
656
+ // A `?v=<contents>` URL is safe for an asset that pins a subresource-integrity
657
+ // hash: SRI digests the fetched BYTES, not the URL, so the hash still matches.
658
+ // The version is what stops a stale copy being served at all - a changed file
659
+ // gets a new URL, and an unchanged one is byte-identical to what the hash was
660
+ // computed from.
656
661
  var _a = __read(src.split('?'), 2), pathname = _a[0], query = _a[1];
657
662
  var file = path_1.default.join(outputFolder, pathname.replace(/^(\.\/|\/)/, ''));
658
663
  if (!fs_1.default.existsSync(file))
@@ -688,6 +693,24 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
688
693
  warnings.push('no <sasjs> element found - check the adapter configuration manually');
689
694
  }
690
695
  var scripts = [];
696
+ /**
697
+ * Scripts the page marks `data-early`, which stay in the page and run in the
698
+ * parser instead of through the loader.
699
+ *
700
+ * The loader defers every script so the worker is ready before the app asks it
701
+ * for anything, which is right for the app and wrong for a script that paints:
702
+ * on the demonstration site the canvas stays unpainted (white) for the seconds
703
+ * the worker takes to claim the page, so a dark interface announces itself with
704
+ * a full-screen white flash. A page that marks a script `data-early` is saying
705
+ * it runs nothing that needs the worker - the theme script reads localStorage
706
+ * and sets a background - so the parser keeps it where the page put it.
707
+ *
708
+ * Only a classic script can be kept: a module is deferred by specification
709
+ * however it is placed, and an inline one is refused by the page's own policy.
710
+ * Both are reported and replayed like any other script rather than being
711
+ * promised something the browser will not do.
712
+ */
713
+ var early = [];
691
714
  var tagPattern = /<script\b([^>]*)>([\s\S]*?)<\/script>[ \t]*\r?\n?/gi;
692
715
  patched = patched.replace(tagPattern, function (tag, source, body) {
693
716
  var e_7, _a;
@@ -697,6 +720,16 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
697
720
  // data blocks stay in the page; they are not scripts to replay
698
721
  if (!EXECUTABLE_TYPES.includes(type))
699
722
  return tag;
723
+ if (attributes['data-early'] !== undefined) {
724
+ if (inlined)
725
+ warnings.push('a data-early script carries its code inline - only a file can stay in the page, because the page is served under a policy that refuses inline script');
726
+ else if (type === 'module')
727
+ warnings.push('a data-early script is a module - a module is deferred by specification, so it cannot run before the loader; it is replayed like any other script');
728
+ else {
729
+ early.push({ src: attributes.src, tag: tag });
730
+ return tag;
731
+ }
732
+ }
700
733
  var spec = inlined
701
734
  ? {}
702
735
  : { src: attributes.src };
@@ -729,18 +762,62 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
729
762
  scripts.push(spec);
730
763
  return '';
731
764
  });
732
- if (scripts.length) {
765
+ if (scripts.length || early.length) {
733
766
  var versioned = outputFolder
734
767
  ? versionScripts(scripts, outputFolder)
735
768
  : scripts;
736
- var tag = bootTag(versioned, version, basePath);
769
+ var tag_1 = bootTag(versioned, version, basePath);
770
+ // a function, not a replacement string: the tag carries the page's own srcs
771
+ // in `data-scripts`, and a `$&` in one would expand to the matched markup
737
772
  patched = /<\/body>/i.test(patched)
738
- ? patched.replace(/<\/body>/i, "".concat(tag, "\n</body>"))
739
- : patched + tag;
773
+ ? patched.replace(/<\/body>/i, function () { return "".concat(tag_1, "\n</body>"); })
774
+ : patched + tag_1;
740
775
  }
741
776
  else {
742
777
  warnings.push('no script tags found - the mock loader was not installed');
743
778
  }
779
+ // The early scripts keep their place in the page, and their URL follows the
780
+ // file's contents for the same reason the replayed ones do: the host caches an
781
+ // asset for two hours and a theme script that never changes its URL is a theme
782
+ // script a returning reader keeps from the previous build.
783
+ if (early.length && outputFolder) {
784
+ var versioned_1 = versionScripts(early.map(function (entry) { return ({ src: entry.src }); }), outputFolder);
785
+ early.forEach(function (entry, index) {
786
+ // Function replacements throughout: a replacement STRING interprets `$&`,
787
+ // `$1` and the rest of the replacement patterns, and both the tag and the
788
+ // src are the page's own text - a `$&` in a file name would splice the
789
+ // surrounding markup into the src.
790
+ var withVersion = entry.tag.replace(entry.src, function () { return versioned_1[index].src; });
791
+ patched = patched.replace(entry.tag, function () { return withVersion; });
792
+ });
793
+ }
794
+ // A stylesheet the page links is a page asset like a script, and the one that
795
+ // paints the canvas is the reason this matters: a palette change behind an
796
+ // unchanged URL reaches a returning reader up to two hours late, which is a
797
+ // white flash the fix was meant to remove. Its URL follows the file's contents
798
+ // for the same reason a script's does. An `integrity` attribute travels with
799
+ // it untouched - SRI digests the bytes, so a versioned URL still satisfies it.
800
+ //
801
+ // A `rel=preload as=style` for the same file is versioned too: leaving it at
802
+ // the unversioned URL warms one cache entry while the stylesheet is fetched
803
+ // cold at another, which is the request the preload existed to save.
804
+ if (outputFolder) {
805
+ patched = patched.replace(/<link\b([^>]*)>/gi, function (tag, source) {
806
+ var attributes = parseAttributes(source);
807
+ var rel = (attributes.rel || '').trim().toLowerCase().split(/\s+/);
808
+ var preloadsStyle = rel.includes('preload') &&
809
+ (attributes.as || '').trim().toLowerCase() === 'style';
810
+ if (!rel.includes('stylesheet') && !preloadsStyle)
811
+ return tag;
812
+ var href = attributes.href;
813
+ if (!href)
814
+ return tag;
815
+ var _a = __read(versionScripts([{ src: href }], outputFolder), 1), versioned = _a[0];
816
+ return versioned.src === href
817
+ ? tag
818
+ : tag.replace(href, function () { return versioned.src; });
819
+ });
820
+ }
744
821
  return { patched: patched, warnings: warnings };
745
822
  };
746
823
  /**
@@ -57,6 +57,31 @@ var __values = (this && this.__values) || function(o) {
57
57
  };
58
58
  throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
59
59
  };
60
+ var __read = (this && this.__read) || function (o, n) {
61
+ var m = typeof Symbol === "function" && o[Symbol.iterator];
62
+ if (!m) return o;
63
+ var i = m.call(o), r, ar = [], e;
64
+ try {
65
+ while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
66
+ }
67
+ catch (error) { e = { error: error }; }
68
+ finally {
69
+ try {
70
+ if (r && !r.done && (m = i["return"])) m.call(i);
71
+ }
72
+ finally { if (e) throw e.error; }
73
+ }
74
+ return ar;
75
+ };
76
+ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
77
+ if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
78
+ if (ar || !(i in from)) {
79
+ if (!ar) ar = Array.prototype.slice.call(from, 0, i);
80
+ ar[i] = from[i];
81
+ }
82
+ }
83
+ return to.concat(ar || Array.prototype.slice.call(from));
84
+ };
60
85
  var __importDefault = (this && this.__importDefault) || function (mod) {
61
86
  return (mod && mod.__esModule) ? mod : { "default": mod };
62
87
  };
@@ -81,11 +106,22 @@ var createProject = function (root) {
81
106
  write(path_1.default.join(build, 'server-ci.sas'), '%put build pack;\n');
82
107
  write(path_1.default.join(build, 'server-ci.json'), '{"members":[]}\n');
83
108
  write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
84
- '<!doctype html><html><head><title>App</title></head><body>',
109
+ '<!doctype html><html><head><title>App</title>',
110
+ '<link rel="stylesheet" href="theme-boot.css" />',
111
+ // a paint-critical script: it runs nothing that needs the worker, so the
112
+ // page marks it to stay in the parser rather than being deferred
113
+ '<script src="theme-boot.js" data-early></script>',
114
+ // the same marker on a module cannot be honoured - a module is deferred by
115
+ // specification - so the build must replay it and say why
116
+ '<script src="deferred.js" type="module" data-early></script>',
117
+ '</head><body>',
85
118
  "<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\" servertype=\"SASJS\" loginmechanism=\"Redirected\"></sasjs>"),
86
119
  '<script src="main.abc123.js" type="module"></script>',
87
120
  '</body></html>'
88
121
  ].join('\n'));
122
+ write(path_1.default.join(root, 'client', 'dist', 'theme-boot.css'), 'html { background-color: #121212 }\n');
123
+ write(path_1.default.join(root, 'client', 'dist', 'deferred.js'), 'console.log("deferred")\n');
124
+ write(path_1.default.join(root, 'client', 'dist', 'theme-boot.js'), 'document.documentElement.style.backgroundColor = "#121212"\n');
89
125
  write(path_1.default.join(root, 'client', 'dist', 'main.abc123.js'), 'console.log(1)\n');
90
126
  };
91
127
  describe('browser-hosted build', function () {
@@ -467,6 +503,182 @@ describe('browser-hosted build', function () {
467
503
  }
468
504
  });
469
505
  }); });
506
+ /**
507
+ * A page's paint-critical script must run before the worker exists.
508
+ *
509
+ * The loader defers every script so the worker is ready before the app asks it
510
+ * for anything - right for the app, wrong for a script whose job is the canvas:
511
+ * the page stays unpainted (white) for the seconds the worker takes to claim
512
+ * it, which is the white flash a dark interface shows on every load. A tag the
513
+ * page marks `data-early` says it needs no worker, so the parser keeps it.
514
+ */
515
+ it('leaves a data-early script to the parser, in the page', function () { return __awaiter(void 0, void 0, void 0, function () {
516
+ var html, specs;
517
+ return __generator(this, function (_a) {
518
+ switch (_a.label) {
519
+ case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
520
+ case 1:
521
+ _a.sent();
522
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
523
+ specs = JSON.parse(html
524
+ .match(/data-scripts='([^']*)'/)[1]
525
+ .replace(/&#39;/g, "'")
526
+ .replace(/&amp;/g, '&'));
527
+ // still in the page, before the loader, and not replayed through it
528
+ expect(html).toMatch(/<script src="theme-boot\.js\?v=[0-9a-f]{10}" data-early><\/script>/);
529
+ expect(specs.map(function (spec) { return spec.src; })).not.toContain(expect.stringMatching(/theme-boot/));
530
+ expect(html.indexOf('theme-boot.js')).toBeLessThan(html.indexOf('host/boot.js'));
531
+ // the same marker on a module cannot be honoured: a module is deferred by
532
+ // specification, so it is replayed and the author is told
533
+ expect(html).not.toContain('<script src="deferred.js');
534
+ expect(specs.map(function (spec) { return spec.src; })).toContainEqual(expect.stringMatching(/^deferred\.js\?v=/));
535
+ expect(process.logger.warn).toHaveBeenCalledWith(expect.stringContaining('a data-early script is a module'));
536
+ return [2 /*return*/];
537
+ }
538
+ });
539
+ }); });
540
+ it('gives a linked stylesheet a URL that follows its contents', function () { return __awaiter(void 0, void 0, void 0, function () {
541
+ var html;
542
+ return __generator(this, function (_a) {
543
+ switch (_a.label) {
544
+ case 0:
545
+ // the host caches an asset for two hours: a palette change behind an
546
+ // unchanged URL reaches a returning reader late, which for the canvas
547
+ // stylesheet is the white flash the fix exists to remove
548
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
549
+ case 1:
550
+ // the host caches an asset for two hours: a palette change behind an
551
+ // unchanged URL reaches a returning reader late, which for the canvas
552
+ // stylesheet is the white flash the fix exists to remove
553
+ _a.sent();
554
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
555
+ expect(html).toMatch(/<link rel="stylesheet" href="theme-boot\.css\?v=[0-9a-f]{10}" \/>/);
556
+ return [2 /*return*/];
557
+ }
558
+ });
559
+ }); });
560
+ it("leaves a stylesheet alone when the file is not this build's", function () { return __awaiter(void 0, void 0, void 0, function () {
561
+ var html, specs;
562
+ return __generator(this, function (_a) {
563
+ switch (_a.label) {
564
+ case 0:
565
+ // a file this build does not own: guessing a version for it would break it.
566
+ // A URL that pins an `integrity` hash is versioned like any other - SRI
567
+ // digests the fetched bytes, so a `?v=` URL still satisfies the hash.
568
+ write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
569
+ '<!doctype html><html><head><title>App</title>',
570
+ '<link rel="stylesheet" href="https://cdn.example.com/lib.css" />',
571
+ '<link rel="stylesheet" href="theme-boot.css" integrity="sha384-abc" crossorigin="anonymous" />',
572
+ '</head><body>',
573
+ "<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\"></sasjs>"),
574
+ '<script src="main.abc123.js" type="module" integrity="sha384-def"></script>',
575
+ '</body></html>'
576
+ ].join('\n'));
577
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
578
+ case 1:
579
+ _a.sent();
580
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
581
+ specs = JSON.parse(html
582
+ .match(/data-scripts='([^']*)'/)[1]
583
+ .replace(/&#39;/g, "'")
584
+ .replace(/&amp;/g, '&'));
585
+ // not owned by this build: untouched
586
+ expect(html).toContain('href="https://cdn.example.com/lib.css"');
587
+ // owned, hashed: versioned, hash carried through
588
+ expect(html).toMatch(/href="theme-boot\.css\?v=[0-9a-f]{10}" integrity="sha384-abc" crossorigin="anonymous"/);
589
+ expect(specs[0].src).toMatch(/^main\.abc123\.js\?v=[0-9a-f]{10}$/);
590
+ expect(specs[0].integrity).toBe('sha384-def');
591
+ return [2 /*return*/];
592
+ }
593
+ });
594
+ }); });
595
+ it('does not let a $ in a file name rewrite the page', function () { return __awaiter(void 0, void 0, void 0, function () {
596
+ var html;
597
+ return __generator(this, function (_a) {
598
+ switch (_a.label) {
599
+ case 0:
600
+ // `String.prototype.replace` interprets `$&`, `$1` and the rest in a
601
+ // replacement STRING, and these tags carry the page's own file names - so a
602
+ // `$&` in one would splice the matched markup into the src
603
+ write(path_1.default.join(root, 'client', 'dist', '$&x.js'), 'console.log("dollar")\n');
604
+ write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
605
+ '<!doctype html><html><head><title>App</title>',
606
+ '<script src="$&x.js" data-early></script>',
607
+ '</head><body>',
608
+ "<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\"></sasjs>"),
609
+ '<script src="main.abc123.js"></script>',
610
+ '</body></html>'
611
+ ].join('\n'));
612
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
613
+ case 1:
614
+ _a.sent();
615
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
616
+ expect(html).toContain("<script src=\"$&x.js?v=".concat(require('crypto')
617
+ .createHash('md5')
618
+ .update(fs_1.default.readFileSync(path_1.default.join(root, 'client', 'dist', '$&x.js')))
619
+ .digest('hex')
620
+ .slice(0, 10), "\" data-early></script>"));
621
+ // one script tag, not a nested one spliced in by the expansion
622
+ expect(html.split('<script').length - 1).toBe(2); // the early tag + the loader
623
+ return [2 /*return*/];
624
+ }
625
+ });
626
+ }); });
627
+ it('versions a preload of the same stylesheet', function () { return __awaiter(void 0, void 0, void 0, function () {
628
+ var html, urls;
629
+ return __generator(this, function (_a) {
630
+ switch (_a.label) {
631
+ case 0:
632
+ // a preload left at the unversioned URL warms one cache entry while the
633
+ // stylesheet is fetched cold at another, which is the request it existed to save
634
+ write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
635
+ '<!doctype html><html><head><title>App</title>',
636
+ '<link rel="preload" as="style" href="theme-boot.css" />',
637
+ '<link rel="stylesheet" href="theme-boot.css" />',
638
+ '</head><body>',
639
+ "<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\"></sasjs>"),
640
+ '<script src="main.abc123.js"></script>',
641
+ '</body></html>'
642
+ ].join('\n'));
643
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
644
+ case 1:
645
+ _a.sent();
646
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
647
+ urls = __spreadArray([], __read(html.matchAll(/theme-boot\.css\?v=([0-9a-f]{10})/g)), false).map(function (match) { return match[1]; });
648
+ expect(urls).toHaveLength(2);
649
+ expect(urls[0]).toBe(urls[1]);
650
+ return [2 /*return*/];
651
+ }
652
+ });
653
+ }); });
654
+ it('installs the loader for a page whose only script runs early', function () { return __awaiter(void 0, void 0, void 0, function () {
655
+ var html, specs;
656
+ return __generator(this, function (_a) {
657
+ switch (_a.label) {
658
+ case 0:
659
+ // the loader is what installs the worker, the bridge and the drive: a page
660
+ // whose one script is kept in the parser still needs it, and must not be told
661
+ // it has no scripts
662
+ write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
663
+ '<!doctype html><html><head><title>App</title>',
664
+ '<script src="theme-boot.js" data-early></script>',
665
+ '</head><body>',
666
+ "<sasjs serverurl=\"https://sas.example.com\" apploc=\"".concat(appLoc, "\"></sasjs>"),
667
+ '</body></html>'
668
+ ].join('\n'));
669
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
670
+ case 1:
671
+ _a.sent();
672
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
673
+ specs = html.match(/data-scripts='([^']*)'/)[1];
674
+ expect(html).toContain('<script src="host/boot.js?v=');
675
+ expect(html).toContain('data-early');
676
+ expect(specs).toBe('[]');
677
+ expect(process.logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('no script tags found'));
678
+ return [2 /*return*/];
679
+ }
680
+ });
681
+ }); });
470
682
  it('points the app at itself and installs the boot loader', function () { return __awaiter(void 0, void 0, void 0, function () {
471
683
  var html, specs;
472
684
  return __generator(this, function (_a) {
@@ -490,9 +702,12 @@ describe('browser-hosted build', function () {
490
702
  .match(/data-scripts='([^']*)'/)[1]
491
703
  .replace(/&#39;/g, "'")
492
704
  .replace(/&amp;/g, '&'));
493
- expect(specs).toHaveLength(1);
494
- expect(specs[0].type).toBe('module');
495
- expect(specs[0].src).toMatch(/^main\.abc123\.js\?v=[0-9a-f]{10}$/);
705
+ // two: the module the page marked data-early (replayed, with a warning),
706
+ // then the bundle - both in the order the page had them
707
+ expect(specs).toHaveLength(2);
708
+ expect(specs[0].src).toMatch(/^deferred\.js\?v=[0-9a-f]{10}$/);
709
+ expect(specs[1].type).toBe('module');
710
+ expect(specs[1].src).toMatch(/^main\.abc123\.js\?v=[0-9a-f]{10}$/);
496
711
  return [2 /*return*/];
497
712
  }
498
713
  });
@@ -503,7 +718,7 @@ describe('browser-hosted build', function () {
503
718
  * bundle's name never changes, so the URL is what has to change.
504
719
  */
505
720
  it('gives each app script a URL that follows its contents', function () { return __awaiter(void 0, void 0, void 0, function () {
506
- var specs, first;
721
+ var specs, bundleSrc, first;
507
722
  return __generator(this, function (_a) {
508
723
  switch (_a.label) {
509
724
  case 0:
@@ -512,23 +727,26 @@ describe('browser-hosted build', function () {
512
727
  var tag = html.match(/data-scripts='([^']*)'/);
513
728
  return JSON.parse(tag[1].replace(/&#39;/g, "'").replace(/&amp;/g, '&'));
514
729
  };
730
+ bundleSrc = function () {
731
+ return specs().find(function (spec) { var _a; return (_a = spec.src) === null || _a === void 0 ? void 0 : _a.startsWith('main.'); });
732
+ };
515
733
  return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
516
734
  case 1:
517
735
  _a.sent();
518
- first = specs()[0].src;
736
+ first = bundleSrc().src;
519
737
  expect(first).toMatch(/^main\.abc123\.js\?v=[0-9a-f]{10}$/);
520
738
  // the same file twice: nothing about the URL changes
521
739
  return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
522
740
  case 2:
523
741
  // the same file twice: nothing about the URL changes
524
742
  _a.sent();
525
- expect(specs()[0].src).toBe(first);
743
+ expect(bundleSrc().src).toBe(first);
526
744
  // a different bundle: a different URL, which is the caching bust
527
745
  write(path_1.default.join(root, 'client', 'dist', 'main.abc123.js'), 'console.log(2)\n');
528
746
  return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
529
747
  case 3:
530
748
  _a.sent();
531
- expect(specs()[0].src).not.toBe(first);
749
+ expect(bundleSrc().src).not.toBe(first);
532
750
  // and a script this build does not own is left exactly as the page asked
533
751
  write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
534
752
  '<!doctype html><html><head><title>App</title></head><body>',
package/package.json CHANGED
@@ -73,7 +73,7 @@
73
73
  "@sasjs/adapter": "6.0.2",
74
74
  "@sasjs/api": "0.28.0",
75
75
  "@sasjs/core": "7.1.12",
76
- "@sasjs/lint": "5.1.1",
76
+ "@sasjs/lint": "5.3.0",
77
77
  "@sasjs/utils": "4.11.0",
78
78
  "axios": "1.20.0",
79
79
  "chalk": "4.1.2",
@@ -125,5 +125,5 @@
125
125
  "ws": "8.21.0",
126
126
  "nanoid": "3.3.18"
127
127
  },
128
- "version": "6.27.2"
128
+ "version": "6.27.3"
129
129
  }