@sasjs/cli 6.29.0 → 6.30.1

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.
@@ -79,7 +79,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
79
79
  return (mod && mod.__esModule) ? mod : { "default": mod };
80
80
  };
81
81
  Object.defineProperty(exports, "__esModule", { value: true });
82
- exports.DRIVE_FORMAT = exports.MAX_SEED_BYTES = exports.MAX_BINARY_SEED_BYTES = exports.buildLintBundle = exports.WEBOUT_SENTINELS = void 0;
82
+ exports.SHARD_GROUP_BYTES = exports.SHARD_FILE_BYTES = exports.DRIVE_FORMAT = exports.MAX_SEED_BYTES = exports.MAX_BINARY_SEED_BYTES = exports.buildLintBundle = exports.WEBOUT_SENTINELS = void 0;
83
83
  exports.buildMockSite = buildMockSite;
84
84
  var crypto_1 = require("crypto");
85
85
  var fs_1 = __importDefault(require("fs"));
@@ -130,10 +130,11 @@ var toPosix = function (value) { return value.split(path_1.default.sep).join('/'
130
130
  * configuration by walking a filesystem - but the linter's core (`lintText`,
131
131
  * `formatText`) reads only the text it is given and the configuration it is
132
132
  * passed, so the two can run anywhere the module graph can be built. This
133
- * bundle is that graph: the package's own code service, the linter it wraps, and
134
- * shims for the four node modules reached at import time (`path` and `os` for
135
- * resolution, `fs` and `@sasjs/utils/file` for the config lookup that a mock
136
- * never makes, because it hands the linter the default configuration).
133
+ * bundle is that graph: the package's own code service, the configuration class
134
+ * a host builds from its own rules file, the linter behind both, and shims for
135
+ * the four node modules reached at import time (`path` and `os` for resolution,
136
+ * `fs` and `@sasjs/utils/file` for the linter's own filesystem lookup, which
137
+ * this host never makes - it hands the routes the rules it read from the drive).
137
138
  *
138
139
  * It is built from the installed @sasjs/api and @sasjs/lint on every mock
139
140
  * build, so the rules a client sees are the version the CLI resolved - a
@@ -142,8 +143,8 @@ var toPosix = function (value) { return value.split(path_1.default.sep).join('/'
142
143
  var LINT_BUNDLE_FILES = {
143
144
  'entry.js': [
144
145
  "import './process.js'",
145
- "import { createCodeService } from '@sasjs/api/code'",
146
- 'globalThis.SASjsCode = { createCodeService }'
146
+ "import { createCodeService, LintConfig } from '@sasjs/api/code'",
147
+ 'globalThis.SASjsCode = { createCodeService, LintConfig }'
147
148
  ].join('\n'),
148
149
  'process.js': [
149
150
  '// the linter reads process.env for a default and process.cwd() for a root;',
@@ -511,6 +512,137 @@ var countPrograms = function (files, extension) {
511
512
  return inProgramFolder && key.toLowerCase().endsWith(extension);
512
513
  }).length;
513
514
  };
515
+ /**
516
+ * The size at which a captured file is fetched on its own rather than with its
517
+ * folder.
518
+ *
519
+ * A shard is a download the visitor waits for, and an app's folder holds both
520
+ * its bundles and the small files a listing shows - its logo, a config. Splitting
521
+ * on size keeps one from dragging the other: the tile asks for the folder's small
522
+ * files, the app's own page asks for the bundle.
523
+ */
524
+ exports.SHARD_FILE_BYTES = 256 * 1024;
525
+ /**
526
+ * The size below which a folder's files ride in the boot shard.
527
+ *
528
+ * The boot shard is fetched before the first page renders, so it is paid by every
529
+ * visit. Folders under this size are cheaper there than as a request of their
530
+ * own; folders over it are only fetched when something reads them.
531
+ */
532
+ exports.SHARD_GROUP_BYTES = 64 * 1024;
533
+ /** The folder a captured path is grouped under: its first three segments. */
534
+ var shardFolderFor = function (filePath) {
535
+ var prefix = '/sasjs/drive/files/';
536
+ if (!filePath.startsWith(prefix))
537
+ return path_1.default.dirname(filePath);
538
+ var parts = filePath.slice(prefix.length).split('/');
539
+ // a file at the drive root groups with the other root files, which is where
540
+ // the index, the linter rules and a project's configs live
541
+ return (prefix +
542
+ parts
543
+ .slice(0, Math.max(0, parts.length - 1))
544
+ .slice(0, 3)
545
+ .join('/'));
546
+ };
547
+ /**
548
+ * Splits a captured drive into a boot shard and the pieces fetched on demand.
549
+ *
550
+ * A site that ships its whole drive as one file makes every visitor download all
551
+ * of it before the first page renders - on a demonstration site that is every
552
+ * app's bundle, behind a list of tiles that names them. The split is by what a
553
+ * page reads, not by what the drive holds:
554
+ *
555
+ * - a file at or over `SHARD_FILE_BYTES` gets a shard of its own;
556
+ * - the rest are grouped by folder, and a folder under `SHARD_GROUP_BYTES` joins
557
+ * the boot shard (id 0), so no visit pays a round trip for a logo;
558
+ * - everything over it becomes a shard, fetched when a path in it is read.
559
+ *
560
+ * The index carries every path with its shard and byte count, so a listing, a
561
+ * stat and an existence check answer for the whole drive with nothing fetched.
562
+ */
563
+ var shardSnapshot = function (files) {
564
+ var e_7, _a, e_8, _b, _c;
565
+ var paths = Object.keys(files).sort();
566
+ var bytesOf = function (filePath) {
567
+ return Buffer.byteLength(String(files[filePath]));
568
+ };
569
+ var groups = new Map();
570
+ try {
571
+ for (var paths_1 = __values(paths), paths_1_1 = paths_1.next(); !paths_1_1.done; paths_1_1 = paths_1.next()) {
572
+ var filePath = paths_1_1.value;
573
+ var big = bytesOf(filePath) >= exports.SHARD_FILE_BYTES;
574
+ var key = big ? "file:".concat(filePath) : "dir:".concat(shardFolderFor(filePath));
575
+ groups.set(key, (groups.get(key) || []).concat(filePath));
576
+ }
577
+ }
578
+ catch (e_7_1) { e_7 = { error: e_7_1 }; }
579
+ finally {
580
+ try {
581
+ if (paths_1_1 && !paths_1_1.done && (_a = paths_1.return)) _a.call(paths_1);
582
+ }
583
+ finally { if (e_7) throw e_7.error; }
584
+ }
585
+ var shards = [{ paths: [], bytes: 0 }];
586
+ try {
587
+ for (var _d = __values(__spreadArray([], __read(groups.keys()), false).sort()), _e = _d.next(); !_e.done; _e = _d.next()) {
588
+ var key = _e.value;
589
+ var members = groups.get(key);
590
+ var bytes = members.reduce(function (total, filePath) { return total + bytesOf(filePath); }, 0);
591
+ var ridesInBoot = key.startsWith('dir:') && bytes <= exports.SHARD_GROUP_BYTES;
592
+ if (ridesInBoot) {
593
+ (_c = shards[0].paths).push.apply(_c, __spreadArray([], __read(members), false));
594
+ shards[0].bytes += bytes;
595
+ continue;
596
+ }
597
+ shards.push({ paths: members, bytes: bytes });
598
+ }
599
+ }
600
+ catch (e_8_1) { e_8 = { error: e_8_1 }; }
601
+ finally {
602
+ try {
603
+ if (_e && !_e.done && (_b = _d.return)) _b.call(_d);
604
+ }
605
+ finally { if (e_8) throw e_8.error; }
606
+ }
607
+ var entries = {};
608
+ shards.forEach(function (shard, id) {
609
+ var e_9, _a;
610
+ try {
611
+ for (var _b = __values(shard.paths), _c = _b.next(); !_c.done; _c = _b.next()) {
612
+ var filePath = _c.value;
613
+ entries[filePath] = { shard: id, bytes: bytesOf(filePath) };
614
+ }
615
+ }
616
+ catch (e_9_1) { e_9 = { error: e_9_1 }; }
617
+ finally {
618
+ try {
619
+ if (_c && !_c.done && (_a = _b.return)) _a.call(_b);
620
+ }
621
+ finally { if (e_9) throw e_9.error; }
622
+ }
623
+ });
624
+ return {
625
+ index: {
626
+ format: exports.DRIVE_FORMAT,
627
+ sharded: true,
628
+ boot: 0,
629
+ files: paths.length,
630
+ entries: entries,
631
+ shards: shards.map(function (shard, id) { return ({
632
+ id: id,
633
+ file: "host/vfs-snapshot-".concat(id, ".json"),
634
+ bytes: shard.bytes,
635
+ files: shard.paths.length
636
+ }); })
637
+ },
638
+ shards: shards.map(function (shard, id) { return ({
639
+ id: id,
640
+ file: "vfs-snapshot-".concat(id, ".json"),
641
+ bytes: shard.bytes,
642
+ files: Object.fromEntries(shard.paths.map(function (filePath) { return [filePath, files[filePath]]; }))
643
+ }); })
644
+ };
645
+ };
514
646
  /** The script-tag attributes that change how an app's script runs. */
515
647
  var REPLAYED_ATTRIBUTES = {
516
648
  type: 'type',
@@ -519,6 +651,20 @@ var REPLAYED_ATTRIBUTES = {
519
651
  integrity: 'integrity',
520
652
  nomodule: 'noModule'
521
653
  };
654
+ /**
655
+ * Script types a preload may name.
656
+ *
657
+ * The loader only re-runs a tag once the worker controls the page, so without a
658
+ * hint the app's bundle is not requested until the install has finished - the
659
+ * largest asset on the site behind the largest prerequisite. A preload link
660
+ * executes nothing, so the download can start while the worker is still
661
+ * installing.
662
+ *
663
+ * `module` is excluded on purpose: a module is fetched with different
664
+ * credentials semantics from a classic script, so `as="script"` does not match
665
+ * the later tag and the file is fetched twice - slower than no hint at all.
666
+ */
667
+ var PRELOADABLE_TYPES = ['', 'text/javascript', 'application/javascript'];
522
668
  var parseAttributes = function (source) {
523
669
  var _a, _b, _c;
524
670
  var attributes = {};
@@ -537,6 +683,45 @@ var escapeAttribute = function (value) {
537
683
  .replace(/</g, '&lt;')
538
684
  .replace(/>/g, '&gt;');
539
685
  };
686
+ /** Puts markup at the end of the head, or leaves the page alone when it has none. */
687
+ var insertIntoHead = function (html, markup) {
688
+ var head = /<head(\s[^>]*)?>/i.exec(html);
689
+ if (!head)
690
+ return html;
691
+ var start = head.index + head[0].length;
692
+ var close = html.toLowerCase().indexOf('</head>', start);
693
+ var at = close < 0 ? start : close;
694
+ return html.slice(0, at) + markup + html.slice(at);
695
+ };
696
+ /**
697
+ * Hints the app scripts the loader will replay, so their download starts while
698
+ * the worker installs rather than after it.
699
+ *
700
+ * Only scripts this build owns are hinted - the file has to be in the output for
701
+ * the version query to be meaningful - and only classic ones. A `type="module"`
702
+ * tag is fetched with different credentials semantics, so an `as="script"`
703
+ * preload does not match it; and a cross-origin src would need `crossorigin` on
704
+ * the hint to match its tag. Either mismatch fetches the file twice, which is
705
+ * worse than no hint.
706
+ */
707
+ var preloadHints = function (scripts, outputFolder) {
708
+ return scripts
709
+ .filter(function (script) {
710
+ if (!script.src)
711
+ return false;
712
+ if (!PRELOADABLE_TYPES.includes((script.type || '').trim().toLowerCase()))
713
+ return false;
714
+ if (script.crossOrigin)
715
+ return false;
716
+ if (/^([a-z]+:)?\/\//i.test(script.src))
717
+ return false;
718
+ if (!outputFolder)
719
+ return true;
720
+ var pathname = script.src.split('?')[0];
721
+ return fs_1.default.existsSync(path_1.default.join(outputFolder, pathname.replace(/^(\.\/|\/)/, '')));
722
+ })
723
+ .map(function (script) { return "<link rel=\"preload\" as=\"script\" href=\"".concat(script.src, "\" />"); });
724
+ };
540
725
  /**
541
726
  * The boot loader is a FILE rather than an inline script: an app with a strict
542
727
  * Content-Security-Policy (`default-src 'self'`, no `unsafe-inline`) refuses an
@@ -713,7 +898,7 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
713
898
  var early = [];
714
899
  var tagPattern = /<script\b([^>]*)>([\s\S]*?)<\/script>[ \t]*\r?\n?/gi;
715
900
  patched = patched.replace(tagPattern, function (tag, source, body) {
716
- var e_7, _a;
901
+ var e_10, _a;
717
902
  var attributes = parseAttributes(source);
718
903
  var type = (attributes.type || '').trim().toLowerCase();
719
904
  var inlined = !attributes.src;
@@ -752,12 +937,12 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
752
937
  spec[name_2] = attribute === 'nomodule' ? 'true' : attributes[attribute];
753
938
  }
754
939
  }
755
- catch (e_7_1) { e_7 = { error: e_7_1 }; }
940
+ catch (e_10_1) { e_10 = { error: e_10_1 }; }
756
941
  finally {
757
942
  try {
758
943
  if (_c && !_c.done && (_a = _b.return)) _a.call(_b);
759
944
  }
760
- finally { if (e_7) throw e_7.error; }
945
+ finally { if (e_10) throw e_10.error; }
761
946
  }
762
947
  scripts.push(spec);
763
948
  return '';
@@ -766,6 +951,9 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
766
951
  var versioned = outputFolder
767
952
  ? versionScripts(scripts, outputFolder)
768
953
  : scripts;
954
+ var hints = preloadHints(versioned, outputFolder);
955
+ if (hints.length)
956
+ patched = insertIntoHead(patched, hints.join(''));
769
957
  var tag_1 = bootTag(versioned, version, basePath);
770
958
  // a function, not a replacement string: the tag carries the page's own srcs
771
959
  // in `data-scripts`, and a `$&` in one would expand to the matched markup
@@ -836,11 +1024,11 @@ var patchIndexHtml = function (html, version, basePath, outputFolder) {
836
1024
  */
837
1025
  function buildMockSite(target) {
838
1026
  return __awaiter(this, void 0, void 0, function () {
839
- var projectDir, appLocRaw, appLoc, buildFolder, basePath, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, sasPrograms, driveEntries, identity, webFileCount, hostFolder, _b, _c, asset, hostVersion, buildStamp, swPath, snapshotSource, hasSnapshot, transcriptSource, hasTranscript, indexPath, _d, patched, warnings;
840
- var e_8, _e;
841
- var _f, _g, _h, _j, _k, _l;
842
- return __generator(this, function (_m) {
843
- switch (_m.label) {
1027
+ var projectDir, appLocRaw, appLoc, buildFolder, basePath, webSourceRaw, webFolder, outputFolder, streamConfig, excluded, _a, seed, skipped, jsPrograms, sasPrograms, driveEntries, identity, webFileCount, hostFolder, _b, _c, asset, hostVersion, buildStamp, swPath, snapshotSource, hasSnapshot, snapshotShards, capture, files, split, _d, _e, shard, transcriptSource, hasTranscript, indexPath, _f, patched, warnings;
1028
+ var e_11, _g, e_12, _h;
1029
+ var _j, _k, _l, _m, _o, _p;
1030
+ return __generator(this, function (_q) {
1031
+ switch (_q.label) {
844
1032
  case 0:
845
1033
  projectDir = process.projectDir || process.cwd();
846
1034
  appLocRaw = target.appLoc;
@@ -852,24 +1040,24 @@ function buildMockSite(target) {
852
1040
  path_1.default.join(projectDir, 'sasjsbuild');
853
1041
  return [4 /*yield*/, (0, utils_1.folderExists)(buildFolder)];
854
1042
  case 1:
855
- if (!(_m.sent())) {
1043
+ if (!(_q.sent())) {
856
1044
  throw new Error("Build folder not found: ".concat(buildFolder, ". Run `sasjs compile` first."));
857
1045
  }
858
- basePath = normaliseBasePath((_f = target.streamConfig) === null || _f === void 0 ? void 0 : _f.mockWebBasePath);
859
- webSourceRaw = (_g = target.streamConfig) === null || _g === void 0 ? void 0 : _g.webSourcePath;
1046
+ basePath = normaliseBasePath((_j = target.streamConfig) === null || _j === void 0 ? void 0 : _j.mockWebBasePath);
1047
+ webSourceRaw = (_k = target.streamConfig) === null || _k === void 0 ? void 0 : _k.webSourcePath;
860
1048
  if (!webSourceRaw) {
861
1049
  throw new Error('No built web app. Set streamConfig.webSourcePath on the target (for example ./client/dist), and build the frontend first.');
862
1050
  }
863
1051
  webFolder = path_1.default.resolve(projectDir, webSourceRaw);
864
1052
  return [4 /*yield*/, (0, utils_1.folderExists)(webFolder)];
865
1053
  case 2:
866
- if (!(_m.sent())) {
1054
+ if (!(_q.sent())) {
867
1055
  throw new Error("Web source folder not found: ".concat(webFolder, ". Check streamConfig.webSourcePath - build the frontend first."));
868
1056
  }
869
1057
  if (!fs_1.default.existsSync(path_1.default.join(webFolder, 'index.html'))) {
870
1058
  throw new Error("".concat(webFolder, " does not contain an index.html - is streamConfig.webSourcePath pointing at the built app?"));
871
1059
  }
872
- outputFolder = ((_h = target.streamConfig) === null || _h === void 0 ? void 0 : _h.mockWebFolder)
1060
+ outputFolder = ((_l = target.streamConfig) === null || _l === void 0 ? void 0 : _l.mockWebFolder)
873
1061
  ? path_1.default.resolve(projectDir, target.streamConfig.mockWebFolder)
874
1062
  : path_1.default.join(buildFolder, 'mock');
875
1063
  assertSafeOutputFolder(outputFolder, projectDir, buildFolder, webFolder);
@@ -890,14 +1078,14 @@ function buildMockSite(target) {
890
1078
  // without a SAS licence and without a login. What they cannot do is run here,
891
1079
  // so the build says so rather than letting a Run button be the surprise.
892
1080
  if (jsPrograms === 0 && sasPrograms > 0) {
893
- (_j = process.logger) === null || _j === void 0 ? void 0 : _j.warn("".concat(sasPrograms, " SAS program(s) and no JavaScript in ").concat(buildFolder, ": they are placed on the drive to read, and cannot run in a browser."));
1081
+ (_m = process.logger) === null || _m === void 0 ? void 0 : _m.warn("".concat(sasPrograms, " SAS program(s) and no JavaScript in ").concat(buildFolder, ": they are placed on the drive to read, and cannot run in a browser."));
894
1082
  }
895
1083
  // No PROGRAMS is a different thing again: a compile that produced nothing, or
896
1084
  // a stale sasjsbuild from an unrelated command - `sasjs doc` leaves a folder
897
1085
  // behind that is not a program folder but does reach the drive, so the count
898
1086
  // that decides this is of programs, not of files.
899
1087
  if (jsPrograms + sasPrograms === 0) {
900
- (_k = process.logger) === null || _k === void 0 ? void 0 : _k.warn("No compiled programs in ".concat(buildFolder, ": this site ships with an empty drive. ") +
1088
+ (_o = process.logger) === null || _o === void 0 ? void 0 : _o.warn("No compiled programs in ".concat(buildFolder, ": this site ships with an empty drive. ") +
901
1089
  'That is expected for an interface with no services; for anything else, check that compile wrote the target.');
902
1090
  }
903
1091
  // The package supplies the drive rules the worker answers with, so an install
@@ -910,7 +1098,7 @@ function buildMockSite(target) {
910
1098
  'or check that @sasjs/api still publishes build/browser.js.');
911
1099
  }
912
1100
  if (skipped.length) {
913
- (_l = process.logger) === null || _l === void 0 ? void 0 : _l.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: ") +
1101
+ (_p = process.logger) === null || _p === void 0 ? void 0 : _p.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: ") +
914
1102
  "".concat(skipped
915
1103
  .slice(0, 3)
916
1104
  .map(function (file) {
@@ -941,12 +1129,12 @@ function buildMockSite(target) {
941
1129
  fs_1.default.copyFileSync(path_1.default.join(ASSETS_FOLDER, asset), path_1.default.join(hostFolder, asset));
942
1130
  }
943
1131
  }
944
- catch (e_8_1) { e_8 = { error: e_8_1 }; }
1132
+ catch (e_11_1) { e_11 = { error: e_11_1 }; }
945
1133
  finally {
946
1134
  try {
947
- if (_c && !_c.done && (_e = _b.return)) _e.call(_b);
1135
+ if (_c && !_c.done && (_g = _b.return)) _g.call(_b);
948
1136
  }
949
- finally { if (e_8) throw e_8.error; }
1137
+ finally { if (e_11) throw e_11.error; }
950
1138
  }
951
1139
  fs_1.default.copyFileSync(API_BROWSER_BUILD, path_1.default.join(hostFolder, 'api-browser.js'));
952
1140
  // 2. the linter, bundled from the installed packages so the code routes answer
@@ -958,7 +1146,7 @@ function buildMockSite(target) {
958
1146
  case 3:
959
1147
  // 2. the linter, bundled from the installed packages so the code routes answer
960
1148
  // with the rules this CLI resolved rather than a second implementation
961
- _m.sent();
1149
+ _q.sent();
962
1150
  // sw.js sits at the site root: a worker can only control the folder it is
963
1151
  // served from, and the app has to be controlled from /
964
1152
  fs_1.default.copyFileSync(path_1.default.join(ASSETS_FOLDER, 'sw.js'), path_1.default.join(outputFolder, 'sw.js'));
@@ -1014,8 +1202,34 @@ function buildMockSite(target) {
1014
1202
  .replace("importScripts('host/lint-browser.js')", "importScripts('host/lint-browser.js?v=".concat(hostVersion, "')")));
1015
1203
  snapshotSource = path_1.default.join(buildFolder, 'mock-snapshot.json');
1016
1204
  hasSnapshot = fs_1.default.existsSync(snapshotSource);
1205
+ snapshotShards = 0;
1017
1206
  if (hasSnapshot) {
1018
- fs_1.default.copyFileSync(snapshotSource, path_1.default.join(hostFolder, 'vfs-snapshot.json'));
1207
+ capture = JSON.parse(fs_1.default.readFileSync(snapshotSource, 'utf8'));
1208
+ if (capture &&
1209
+ capture.format !== undefined &&
1210
+ capture.format !== exports.DRIVE_FORMAT)
1211
+ throw new Error("The captured drive at ".concat(snapshotSource, " is format ") +
1212
+ "".concat(JSON.stringify(capture.format), ", and this build writes format ") +
1213
+ "".concat(exports.DRIVE_FORMAT, ". Seed the drive again with window.SASjsMock.download()."));
1214
+ files = capture && capture.files ? capture.files : capture;
1215
+ if (!files || typeof files !== 'object')
1216
+ throw new Error("The captured drive at ".concat(snapshotSource, " carries no files - the export is a JSON object of path to contents."));
1217
+ split = shardSnapshot(files);
1218
+ try {
1219
+ for (_d = __values(split.shards), _e = _d.next(); !_e.done; _e = _d.next()) {
1220
+ shard = _e.value;
1221
+ fs_1.default.writeFileSync(path_1.default.join(hostFolder, shard.file), JSON.stringify({ format: exports.DRIVE_FORMAT, files: shard.files }));
1222
+ }
1223
+ }
1224
+ catch (e_12_1) { e_12 = { error: e_12_1 }; }
1225
+ finally {
1226
+ try {
1227
+ if (_e && !_e.done && (_h = _d.return)) _h.call(_d);
1228
+ }
1229
+ finally { if (e_12) throw e_12.error; }
1230
+ }
1231
+ fs_1.default.writeFileSync(path_1.default.join(hostFolder, 'vfs-snapshot.json'), JSON.stringify(split.index, null, 2));
1232
+ snapshotShards = split.shards.length;
1019
1233
  }
1020
1234
  transcriptSource = path_1.default.join(buildFolder, 'sas-transcript.json');
1021
1235
  hasTranscript = fs_1.default.existsSync(transcriptSource);
@@ -1030,13 +1244,14 @@ function buildMockSite(target) {
1030
1244
  jsPrograms: jsPrograms,
1031
1245
  programFolders: PROGRAM_FOLDERS,
1032
1246
  snapshot: hasSnapshot,
1247
+ snapshotShards: snapshotShards,
1033
1248
  sasTranscript: hasTranscript,
1034
1249
  identity: identity.source,
1035
1250
  buildStamp: buildStamp,
1036
1251
  builtAt: new Date().toISOString()
1037
1252
  }, null, 2));
1038
1253
  indexPath = path_1.default.join(outputFolder, 'index.html');
1039
- _d = patchIndexHtml(fs_1.default.readFileSync(indexPath, 'utf8'), hostVersion, basePath, outputFolder), patched = _d.patched, warnings = _d.warnings;
1254
+ _f = patchIndexHtml(fs_1.default.readFileSync(indexPath, 'utf8'), hostVersion, basePath, outputFolder), patched = _f.patched, warnings = _f.warnings;
1040
1255
  fs_1.default.writeFileSync(indexPath, patched);
1041
1256
  warnings.forEach(function (warning) { var _a; return (_a = process.logger) === null || _a === void 0 ? void 0 : _a.warn(warning); });
1042
1257
  return [2 /*return*/, {
@@ -1046,6 +1261,7 @@ function buildMockSite(target) {
1046
1261
  jsPrograms: jsPrograms,
1047
1262
  webFileCount: webFileCount,
1048
1263
  snapshot: hasSnapshot,
1264
+ snapshotShards: snapshotShards,
1049
1265
  sasTranscript: hasTranscript,
1050
1266
  identitySource: identity.source
1051
1267
  }];
@@ -100,9 +100,11 @@ var loadBoot = function (specs, options) {
100
100
  };
101
101
  var listeners = {};
102
102
  var controller = options.controller || 'present';
103
+ var timelineAtRegister = [];
103
104
  var navigator = {
104
105
  serviceWorker: {
105
106
  register: function (url, init) {
107
+ timelineAtRegister = timeline.slice();
106
108
  registered.push(url + ' ' + JSON.stringify(init));
107
109
  return options.registerFails
108
110
  ? Promise.reject(new Error('nope'))
@@ -127,7 +129,11 @@ var loadBoot = function (specs, options) {
127
129
  global.navigator = navigator;
128
130
  global.window = options.bootError
129
131
  ? { __sasjsMockReady: Promise.resolve({ error: options.bootError }) }
130
- : {};
132
+ : options.driveError
133
+ ? {
134
+ __sasjsMockReady: Promise.resolve({ driveError: options.driveError })
135
+ }
136
+ : {};
131
137
  global.sessionStorage = {
132
138
  getItem: function (key) { return (key in stored ? stored[key] : null); },
133
139
  setItem: function (key, value) {
@@ -146,7 +152,7 @@ var loadBoot = function (specs, options) {
146
152
  late = true;
147
153
  (listeners['controllerchange'] || []).forEach(function (fn) { return fn(); });
148
154
  };
149
- return { appended: appended, timeline: timeline, registered: registered, stored: stored, claim: claim };
155
+ return { appended: appended, timeline: timeline, registered: registered, stored: stored, claim: claim, timelineAtRegister: timelineAtRegister };
150
156
  };
151
157
  var settle = function () { return __awaiter(void 0, void 0, void 0, function () {
152
158
  var i;
@@ -336,6 +342,29 @@ describe('the boot loader', function () {
336
342
  }
337
343
  });
338
344
  }); });
345
+ it('starts the drive restore before the worker is registered', function () { return __awaiter(void 0, void 0, void 0, function () {
346
+ var timelineAtRegister;
347
+ return __generator(this, function (_a) {
348
+ switch (_a.label) {
349
+ case 0:
350
+ timelineAtRegister = loadBoot([{ src: 'sasjs.js' }]).timelineAtRegister;
351
+ return [4 /*yield*/, settle()
352
+ // persist.js reads the page's own IndexedDB and hands the drive to the
353
+ // worker as soon as there is one, so nothing about it needs the worker to be
354
+ // ready: sequenced after the registration it is another download plus a
355
+ // store read on top of the install, which is what made a first visit slow
356
+ ];
357
+ case 1:
358
+ _a.sent();
359
+ // persist.js reads the page's own IndexedDB and hands the drive to the
360
+ // worker as soon as there is one, so nothing about it needs the worker to be
361
+ // ready: sequenced after the registration it is another download plus a
362
+ // store read on top of the install, which is what made a first visit slow
363
+ expect(timelineAtRegister).toEqual(['host/persist.js']);
364
+ return [2 /*return*/];
365
+ }
366
+ });
367
+ }); });
339
368
  it('keeps the attributes an app relies on', function () { return __awaiter(void 0, void 0, void 0, function () {
340
369
  var appended, app;
341
370
  return __generator(this, function (_a) {
@@ -430,6 +459,21 @@ describe('the boot loader', function () {
430
459
  }
431
460
  });
432
461
  }); });
462
+ it('reports a drive that was restored from less than the whole capture', function () { return __awaiter(void 0, void 0, void 0, function () {
463
+ return __generator(this, function (_a) {
464
+ switch (_a.label) {
465
+ case 0:
466
+ // the page still runs - a missing piece only fails the file that needed it -
467
+ // so the error is the only thing that says the drive is incomplete
468
+ loadBoot([], { driveError: 'the drive index answered 500' });
469
+ return [4 /*yield*/, settle()];
470
+ case 1:
471
+ _a.sent();
472
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('the drive index answered 500'));
473
+ return [2 /*return*/];
474
+ }
475
+ });
476
+ }); });
433
477
  it('refuses a boot config it cannot read instead of guessing', function () { return __awaiter(void 0, void 0, void 0, function () {
434
478
  var timeline;
435
479
  return __generator(this, function (_a) {
@@ -527,7 +571,10 @@ describe('the boot loader', function () {
527
571
  // into the next test's globals
528
572
  _b.sent();
529
573
  expect(stored['mock-sw-reloaded']).toEqual('1');
530
- expect(timeline).toEqual(['reload']);
574
+ // the drive restore is requested before the registration (it overlaps the
575
+ // install), so it is already in flight when the page decides to reload; the
576
+ // reload discards it, which costs nothing the cache does not already hold
577
+ expect(timeline).toEqual(['host/persist.js', 'reload']);
531
578
  return [2 /*return*/];
532
579
  }
533
580
  });