@sasjs/cli 6.12.0 → 6.13.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.
@@ -0,0 +1,149 @@
1
+ /*
2
+ * The mock host's boot loader. It ships as a file rather than an inline
3
+ * <script> because an inline one is refused by any app that sets a strict
4
+ * Content-Security-Policy (default-src 'self' with no 'unsafe-inline' is the
5
+ * SASjs norm), and the builder cannot add a hash or nonce to a policy it does
6
+ * not own.
7
+ *
8
+ * Order: register the service worker that hosts the mock backend, wait until it
9
+ * controls this page, hand it the persisted drive (persist.js), install the XHR
10
+ * bridge (intercept.js), and only then replay the application's own script tags
11
+ * - in the order the page listed them. Nothing about the app changes: it is the
12
+ * same tags, the same order, and the same globals, just later than the parser
13
+ * would have run them.
14
+ *
15
+ * The tag list arrives in the `data-scripts` attribute as JSON, written by
16
+ * buildMockSite. `defer` and `async` are not replayed: execution is sequenced
17
+ * on purpose here, because the host has to be ready before the first request
18
+ * the app makes.
19
+ *
20
+ * The fallback ladder, because a site whose worker never claims the page fails
21
+ * every request: an unreadable config stops before the app boots; a script that
22
+ * fails to load is reported and the next one still runs; a worker that takes
23
+ * control late is waited for; one that never does gets a single reload, then the
24
+ * app boots anyway with the failure in the console; and no service worker
25
+ * support at all boots the app in localStorage-only mode rather than a blank
26
+ * page.
27
+ */
28
+ ;(function () {
29
+ 'use strict'
30
+
31
+ var script = document.currentScript
32
+ var specs = []
33
+ try {
34
+ specs = JSON.parse((script && script.getAttribute('data-scripts')) || '[]')
35
+ } catch (err) {
36
+ console.error('[mock] the boot config could not be read: ' + err)
37
+ return
38
+ }
39
+
40
+ function load(src, next) {
41
+ var s = document.createElement('script')
42
+ s.src = src
43
+ s.onload = next
44
+ s.onerror = function () {
45
+ console.error('[mock] failed to load ' + src)
46
+ next()
47
+ }
48
+ document.body.appendChild(s)
49
+ }
50
+
51
+ /** Replay one original tag: same src, and the attributes an app can rely on. */
52
+ function replay(spec, next) {
53
+ var s = document.createElement('script')
54
+ if (spec.type) s.setAttribute('type', spec.type)
55
+ if (spec.id) s.id = spec.id
56
+ if (spec.crossOrigin) s.crossOrigin = spec.crossOrigin
57
+ if (spec.integrity) s.integrity = spec.integrity
58
+ if (spec.noModule) s.noModule = true
59
+
60
+ // an inline snippet runs synchronously on insertion, exactly as it did in
61
+ // the page - there is no load event to wait for
62
+ if (spec.code !== undefined && spec.src === undefined) {
63
+ s.textContent = spec.code
64
+ document.body.appendChild(s)
65
+ return next()
66
+ }
67
+
68
+ s.src = spec.src
69
+ s.onload = next
70
+ s.onerror = function () {
71
+ console.error('[mock] failed to load ' + spec.src)
72
+ next()
73
+ }
74
+ document.body.appendChild(s)
75
+ }
76
+
77
+ function replayAll(list, done) {
78
+ if (!list.length) return done()
79
+ replay(list[0], function () {
80
+ replayAll(list.slice(1), done)
81
+ })
82
+ }
83
+
84
+ function start() {
85
+ load('host/persist.js', function () {
86
+ Promise.resolve(window.__sasjsMockReady).then(function (boot) {
87
+ if (boot && boot.error) console.error('[mock] boot: ' + boot.error)
88
+ load('host/intercept.js', function () {
89
+ replayAll(specs, function () {})
90
+ })
91
+ })
92
+ })
93
+ }
94
+
95
+ if (!('serviceWorker' in navigator)) {
96
+ console.error(
97
+ '[mock] service workers are unavailable - the mock backend cannot run'
98
+ )
99
+ start()
100
+ return
101
+ }
102
+
103
+ /*
104
+ * The worker script's own URL is the only thing a returning visitor cannot
105
+ * bypass: the host files behind it are cache-busted with a version query, but
106
+ * a cached sw.js keeps serving the previous build's worker - and through it
107
+ * the previous runtime. The boot tag already carries the host version, so its
108
+ * query string is reused rather than passing the version twice.
109
+ */
110
+ var stamp = ''
111
+ try {
112
+ stamp = new URL(script.src, location.href).search
113
+ } catch (err) {
114
+ stamp = ''
115
+ }
116
+
117
+ navigator.serviceWorker
118
+ .register('/sw.js' + stamp, { scope: '/', updateViaCache: 'none' })
119
+ .then(function () {
120
+ return navigator.serviceWorker.ready
121
+ })
122
+ .then(function () {
123
+ if (navigator.serviceWorker.controller) return
124
+ return new Promise(function (resolve) {
125
+ var timer = setTimeout(resolve, 4000)
126
+ navigator.serviceWorker.addEventListener(
127
+ 'controllerchange',
128
+ function () {
129
+ clearTimeout(timer)
130
+ resolve()
131
+ }
132
+ )
133
+ })
134
+ })
135
+ .then(function () {
136
+ if (navigator.serviceWorker.controller) return start()
137
+ if (!sessionStorage.getItem('mock-sw-reloaded')) {
138
+ sessionStorage.setItem('mock-sw-reloaded', '1')
139
+ location.reload()
140
+ return
141
+ }
142
+ console.error('[mock] no service worker controller - requests will fail')
143
+ start()
144
+ })
145
+ .catch(function (err) {
146
+ console.error('[mock] service worker registration failed: ' + err)
147
+ start()
148
+ })
149
+ })()
@@ -341,11 +341,60 @@ var countJsPrograms = function (files) {
341
341
  return inProgramFolder && key.toLowerCase().endsWith('.js');
342
342
  }).length;
343
343
  };
344
- var loaderScript = function (mainScript, version) { return "<script>\n // Boot order: register the service worker that hosts the mock backend, wait\n // until it controls this page, hand it the persisted drive, install the XHR\n // bridge, then start the app.\n (function () {\n function load(src, next, isModule) {\n var s = document.createElement('script')\n if (isModule) s.type = 'module'\n s.src = src\n s.onload = next\n s.onerror = function () { console.error('[mock] failed to load ' + src); next() }\n document.body.appendChild(s)\n }\n function start() {\n load('host/persist.js?v=".concat(version, "', function () {\n Promise.resolve(window.__sasjsMockReady).then(function (boot) {\n if (boot && boot.error) console.error('[mock] boot: ' + boot.error)\n load('host/intercept.js?v=").concat(version, "', function () {\n load('").concat(mainScript, "', function () {}, true)\n })\n })\n })\n }\n if (!('serviceWorker' in navigator)) {\n console.error('[mock] service workers are unavailable - the mock backend cannot run')\n start()\n return\n }\n navigator.serviceWorker\n .register('/sw.js', { scope: '/' })\n .then(function () { return navigator.serviceWorker.ready })\n .then(function () {\n if (navigator.serviceWorker.controller) return\n return new Promise(function (resolve) {\n var timer = setTimeout(resolve, 4000)\n navigator.serviceWorker.addEventListener('controllerchange', function () {\n clearTimeout(timer)\n resolve()\n })\n })\n })\n .then(function () {\n if (navigator.serviceWorker.controller) return start()\n if (!sessionStorage.getItem('mock-sw-reloaded')) {\n sessionStorage.setItem('mock-sw-reloaded', '1')\n location.reload()\n return\n }\n console.error('[mock] no service worker controller - requests will fail')\n start()\n })\n .catch(function (err) {\n console.error('[mock] service worker registration failed: ' + err)\n start()\n })\n })()\n </script>"); };
344
+ /** The script-tag attributes that change how an app's script runs. */
345
+ var REPLAYED_ATTRIBUTES = {
346
+ type: 'type',
347
+ id: 'id',
348
+ crossorigin: 'crossOrigin',
349
+ integrity: 'integrity',
350
+ nomodule: 'noModule'
351
+ };
352
+ var parseAttributes = function (source) {
353
+ var _a, _b, _c;
354
+ var attributes = {};
355
+ var pattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
356
+ var match;
357
+ while ((match = pattern.exec(source))) {
358
+ var value = (_c = (_b = (_a = match[2]) !== null && _a !== void 0 ? _a : match[3]) !== null && _b !== void 0 ? _b : match[4]) !== null && _c !== void 0 ? _c : '';
359
+ attributes[match[1].toLowerCase()] = value;
360
+ }
361
+ return attributes;
362
+ };
363
+ var escapeAttribute = function (value) {
364
+ return value
365
+ .replace(/&/g, '&amp;')
366
+ .replace(/'/g, '&#39;')
367
+ .replace(/</g, '&lt;')
368
+ .replace(/>/g, '&gt;');
369
+ };
370
+ /**
371
+ * The boot loader is a FILE rather than an inline script: an app with a strict
372
+ * Content-Security-Policy (`default-src 'self'`, no `unsafe-inline`) refuses an
373
+ * inline one, and the builder cannot add a hash or nonce to a policy it does
374
+ * not own.
375
+ *
376
+ * The app's script tags travel to it in `data-scripts`, in document order. Any
377
+ * number of tags works - one bundle, or several plain scripts a page loads in
378
+ * order, plus the inline snippets some pages use to set up a global before the
379
+ * bundle runs - so the host does not dictate how the app is built.
380
+ */
381
+ var bootTag = function (scripts, version) {
382
+ return "<script src=\"host/boot.js?v=".concat(version, "\" data-scripts='").concat(escapeAttribute(JSON.stringify(scripts)), "'></script>");
383
+ };
384
+ /**
385
+ * Script types the browser executes. Anything else (`application/ld+json`, a
386
+ * template) is data the page keeps for later, so it stays where it is.
387
+ */
388
+ var EXECUTABLE_TYPES = [
389
+ '',
390
+ 'module',
391
+ 'text/javascript',
392
+ 'application/javascript'
393
+ ];
345
394
  /**
346
- * Points the app at its own origin and replaces its bundle tag with the
347
- * boot loader. Both changes are reported when the expected markup is absent,
348
- * rather than producing a site that silently talks to a real server.
395
+ * Points the app at its own origin and replaces its script tags with the boot
396
+ * loader. Both changes are reported when the expected markup is absent, rather
397
+ * than producing a site that silently talks to a real server.
349
398
  */
350
399
  var patchIndexHtml = function (html, version) {
351
400
  var warnings = [];
@@ -357,12 +406,56 @@ var patchIndexHtml = function (html, version) {
357
406
  else {
358
407
  warnings.push('no <sasjs> element found - check the adapter configuration manually');
359
408
  }
360
- var mainMatch = patched.match(/<script[^>]*src="([^"]*main[^"]*\.js)"[^>]*><\/script>/i);
361
- if (mainMatch) {
362
- patched = patched.replace(mainMatch[0], loaderScript(mainMatch[1], version));
409
+ var scripts = [];
410
+ var tagPattern = /<script\b([^>]*)>([\s\S]*?)<\/script>[ \t]*\r?\n?/gi;
411
+ patched = patched.replace(tagPattern, function (tag, source, body) {
412
+ var e_6, _a;
413
+ var attributes = parseAttributes(source);
414
+ var type = (attributes.type || '').trim().toLowerCase();
415
+ var inlined = !attributes.src;
416
+ // data blocks stay in the page; they are not scripts to replay
417
+ if (!EXECUTABLE_TYPES.includes(type))
418
+ return tag;
419
+ var spec = inlined
420
+ ? {}
421
+ : { src: attributes.src };
422
+ if (inlined) {
423
+ var code = body.trim();
424
+ // an empty inline tag (a placeholder, or a commented-out block) is not
425
+ // worth a round trip
426
+ if (!code)
427
+ return '';
428
+ spec.code = code;
429
+ }
430
+ try {
431
+ for (var _b = __values(Object.keys(REPLAYED_ATTRIBUTES)), _c = _b.next(); !_c.done; _c = _b.next()) {
432
+ var attribute = _c.value;
433
+ if (attributes[attribute] === undefined)
434
+ continue;
435
+ if (attribute === 'type' && !attributes.type)
436
+ continue;
437
+ var name_1 = REPLAYED_ATTRIBUTES[attribute];
438
+ spec[name_1] = attribute === 'nomodule' ? 'true' : attributes[attribute];
439
+ }
440
+ }
441
+ catch (e_6_1) { e_6 = { error: e_6_1 }; }
442
+ finally {
443
+ try {
444
+ if (_c && !_c.done && (_a = _b.return)) _a.call(_b);
445
+ }
446
+ finally { if (e_6) throw e_6.error; }
447
+ }
448
+ scripts.push(spec);
449
+ return '';
450
+ });
451
+ if (scripts.length) {
452
+ var tag = bootTag(scripts, version);
453
+ patched = /<\/body>/i.test(patched)
454
+ ? patched.replace(/<\/body>/i, "".concat(tag, "\n</body>"))
455
+ : patched + tag;
363
456
  }
364
457
  else {
365
- warnings.push('no main bundle script tag found - the mock loader was not installed');
458
+ warnings.push('no script tags found - the mock loader was not installed');
366
459
  }
367
460
  return { patched: patched, warnings: warnings };
368
461
  };
@@ -383,7 +476,7 @@ var patchIndexHtml = function (html, version) {
383
476
  function buildMockSite(target) {
384
477
  return __awaiter(this, void 0, void 0, function () {
385
478
  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;
386
- var e_6, _e;
479
+ var e_7, _e;
387
480
  var _f, _g, _h;
388
481
  return __generator(this, function (_j) {
389
482
  switch (_j.label) {
@@ -465,18 +558,19 @@ function buildMockSite(target) {
465
558
  'md5.js',
466
559
  'runtime-core.js',
467
560
  'persist.js',
468
- 'intercept.js'
561
+ 'intercept.js',
562
+ 'boot.js'
469
563
  ]), _c = _b.next(); !_c.done; _c = _b.next()) {
470
564
  asset = _c.value;
471
565
  fs_1.default.copyFileSync(path_1.default.join(ASSETS_FOLDER, asset), path_1.default.join(hostFolder, asset));
472
566
  }
473
567
  }
474
- catch (e_6_1) { e_6 = { error: e_6_1 }; }
568
+ catch (e_7_1) { e_7 = { error: e_7_1 }; }
475
569
  finally {
476
570
  try {
477
571
  if (_c && !_c.done && (_e = _b.return)) _e.call(_b);
478
572
  }
479
- finally { if (e_6) throw e_6.error; }
573
+ finally { if (e_7) throw e_7.error; }
480
574
  }
481
575
  fs_1.default.copyFileSync(API_BROWSER_BUILD, path_1.default.join(hostFolder, 'api-browser.js'));
482
576
  // sw.js sits at the site root: a worker can only control the folder it is
@@ -502,6 +596,7 @@ function buildMockSite(target) {
502
596
  'runtime-core.js',
503
597
  'persist.js',
504
598
  'intercept.js',
599
+ 'boot.js',
505
600
  'api-browser.js',
506
601
  'webout-prelude.json'
507
602
  ]
@@ -0,0 +1,462 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __generator = (this && this.__generator) || function (thisArg, body) {
12
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
13
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
14
+ function verb(n) { return function (v) { return step([n, v]); }; }
15
+ function step(op) {
16
+ if (f) throw new TypeError("Generator is already executing.");
17
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
18
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
19
+ if (y = 0, t) op = [op[0] & 2, t.value];
20
+ switch (op[0]) {
21
+ case 0: case 1: t = op; break;
22
+ case 4: _.label++; return { value: op[1], done: false };
23
+ case 5: _.label++; y = op[1]; op = [0]; continue;
24
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
25
+ default:
26
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
27
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
28
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
29
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
30
+ if (t[2]) _.ops.pop();
31
+ _.trys.pop(); continue;
32
+ }
33
+ op = body.call(thisArg, _);
34
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
35
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
36
+ }
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ var loadBoot = function (specs, options) {
40
+ if (options === void 0) { options = {}; }
41
+ jest.resetModules();
42
+ var appended = [];
43
+ var timeline = [];
44
+ var registered = [];
45
+ var stored = {};
46
+ if (options.guard)
47
+ stored['mock-sw-reloaded'] = options.guard;
48
+ var createElement = function () {
49
+ var element = {
50
+ attributes: {},
51
+ setAttribute: function (name, value) {
52
+ element.attributes[name] = value;
53
+ if (name === 'type')
54
+ element.type = value;
55
+ }
56
+ };
57
+ return element;
58
+ };
59
+ var document = {
60
+ currentScript: options.noScript
61
+ ? null
62
+ : {
63
+ // the builder stamps the boot tag with the host version, and the
64
+ // loader reuses that query for the worker's own URL
65
+ src: options.noStamp
66
+ ? 'http://localhost:8123/host/boot.js'
67
+ : 'http://localhost:8123/host/boot.js?v=abc123',
68
+ getAttribute: function (name) {
69
+ if (name !== 'data-scripts')
70
+ return null;
71
+ return options.brokenConfig ? 'not json' : JSON.stringify(specs);
72
+ }
73
+ },
74
+ createElement: createElement,
75
+ body: {
76
+ appendChild: function (element) {
77
+ appended.push(element);
78
+ timeline.push(element.src || '[' + element.textContent + ']');
79
+ if (!element.src)
80
+ return element;
81
+ setTimeout(function () {
82
+ var failed = (options.failSrcs || []).includes(element.src);
83
+ if (failed && element.onerror)
84
+ element.onerror();
85
+ else if (element.onload)
86
+ element.onload();
87
+ }, 0);
88
+ return element;
89
+ }
90
+ }
91
+ };
92
+ var listeners = {};
93
+ var controller = options.controller || 'present';
94
+ var navigator = {
95
+ serviceWorker: {
96
+ register: function (url, init) {
97
+ registered.push(url + ' ' + JSON.stringify(init));
98
+ return options.registerFails
99
+ ? Promise.reject(new Error('nope'))
100
+ : Promise.resolve();
101
+ },
102
+ ready: Promise.resolve(),
103
+ get controller() {
104
+ if (controller === 'present')
105
+ return {};
106
+ if (controller === 'late' && late)
107
+ return {};
108
+ return null;
109
+ },
110
+ addEventListener: function (type, fn) {
111
+ listeners[type] = listeners[type] || [];
112
+ listeners[type].push(fn);
113
+ }
114
+ }
115
+ };
116
+ var late = false;
117
+ global.document = document;
118
+ global.navigator = navigator;
119
+ global.window = options.bootError
120
+ ? { __sasjsMockReady: Promise.resolve({ error: options.bootError }) }
121
+ : {};
122
+ global.sessionStorage = {
123
+ getItem: function (key) { return (key in stored ? stored[key] : null); },
124
+ setItem: function (key, value) {
125
+ stored[key] = value;
126
+ }
127
+ };
128
+ global.location = { reload: function () { return timeline.push('reload'); } };
129
+ jest.spyOn(console, 'error').mockImplementation(function () { });
130
+ require('../assets/boot.js');
131
+ var claim = function () {
132
+ late = true;
133
+ (listeners['controllerchange'] || []).forEach(function (fn) { return fn(); });
134
+ };
135
+ return { appended: appended, timeline: timeline, registered: registered, stored: stored, claim: claim };
136
+ };
137
+ var settle = function () { return __awaiter(void 0, void 0, void 0, function () {
138
+ var i;
139
+ return __generator(this, function (_a) {
140
+ switch (_a.label) {
141
+ case 0:
142
+ i = 0;
143
+ _a.label = 1;
144
+ case 1:
145
+ if (!(i < 20)) return [3 /*break*/, 4];
146
+ return [4 /*yield*/, new Promise(function (r) { return setTimeout(r, 1); })];
147
+ case 2:
148
+ _a.sent();
149
+ _a.label = 3;
150
+ case 3:
151
+ i++;
152
+ return [3 /*break*/, 1];
153
+ case 4: return [2 /*return*/];
154
+ }
155
+ });
156
+ }); };
157
+ describe('the boot loader', function () {
158
+ afterEach(function () {
159
+ delete global.document;
160
+ delete global.navigator;
161
+ delete global.window;
162
+ delete global.sessionStorage;
163
+ delete global.location;
164
+ });
165
+ it("registers the worker at the site root, at the build's version", function () { return __awaiter(void 0, void 0, void 0, function () {
166
+ var registered;
167
+ return __generator(this, function (_a) {
168
+ switch (_a.label) {
169
+ case 0:
170
+ registered = loadBoot([]).registered;
171
+ return [4 /*yield*/, settle()
172
+ // a cached sw.js would keep a returning visitor on the previous build's
173
+ // worker, so the URL carries the host version and the check skips the cache
174
+ ];
175
+ case 1:
176
+ _a.sent();
177
+ // a cached sw.js would keep a returning visitor on the previous build's
178
+ // worker, so the URL carries the host version and the check skips the cache
179
+ expect(registered).toEqual([
180
+ '/sw.js?v=abc123 ' +
181
+ JSON.stringify({ scope: '/', updateViaCache: 'none' })
182
+ ]);
183
+ return [2 /*return*/];
184
+ }
185
+ });
186
+ }); });
187
+ it('boots the host before the app, then replays every tag in order', function () { return __awaiter(void 0, void 0, void 0, function () {
188
+ var timeline;
189
+ return __generator(this, function (_a) {
190
+ switch (_a.label) {
191
+ case 0:
192
+ timeline = loadBoot([
193
+ { src: 'sasjs.js', id: 'sasjs-adapter' },
194
+ { code: 'window.__config = 1' },
195
+ { src: 'js/backend.js' },
196
+ { src: 'js/game.js' }
197
+ ]).timeline;
198
+ return [4 /*yield*/, settle()];
199
+ case 1:
200
+ _a.sent();
201
+ expect(timeline).toEqual([
202
+ 'host/persist.js',
203
+ 'host/intercept.js',
204
+ 'sasjs.js',
205
+ '[window.__config = 1]',
206
+ 'js/backend.js',
207
+ 'js/game.js'
208
+ ]);
209
+ return [2 /*return*/];
210
+ }
211
+ });
212
+ }); });
213
+ it('keeps the attributes an app relies on', function () { return __awaiter(void 0, void 0, void 0, function () {
214
+ var appended, app;
215
+ return __generator(this, function (_a) {
216
+ switch (_a.label) {
217
+ case 0:
218
+ appended = loadBoot([
219
+ { src: 'sasjs.js', id: 'sasjs-adapter' },
220
+ { src: 'main.js', type: 'module' },
221
+ { src: 'cdn.js', crossOrigin: 'anonymous', integrity: 'sha384-x' },
222
+ { src: 'legacy.js', noModule: 'true' }
223
+ ]).appended;
224
+ return [4 /*yield*/, settle()
225
+ // the first two are the host's own: persist.js and intercept.js
226
+ ];
227
+ case 1:
228
+ _a.sent();
229
+ app = appended.slice(2);
230
+ expect(app.map(function (element) { return element.src; })).toEqual([
231
+ 'sasjs.js',
232
+ 'main.js',
233
+ 'cdn.js',
234
+ 'legacy.js'
235
+ ]);
236
+ expect(app[0].id).toEqual('sasjs-adapter');
237
+ expect(app[1].type).toEqual('module');
238
+ expect(app[2].crossOrigin).toEqual('anonymous');
239
+ expect(app[2].integrity).toEqual('sha384-x');
240
+ expect(app[3].noModule).toBe(true);
241
+ return [2 /*return*/];
242
+ }
243
+ });
244
+ }); });
245
+ it('runs an inline snippet without waiting for a load event', function () { return __awaiter(void 0, void 0, void 0, function () {
246
+ var appended, inline;
247
+ return __generator(this, function (_a) {
248
+ switch (_a.label) {
249
+ case 0:
250
+ appended = loadBoot([{ code: 'window.__boot = 1' }]).appended;
251
+ return [4 /*yield*/, settle()];
252
+ case 1:
253
+ _a.sent();
254
+ inline = appended.find(function (element) { return element.textContent; });
255
+ expect(inline).toBeDefined();
256
+ expect(inline.src).toBeUndefined();
257
+ return [2 /*return*/];
258
+ }
259
+ });
260
+ }); });
261
+ it('moves on when a script fails to load', function () { return __awaiter(void 0, void 0, void 0, function () {
262
+ var timeline;
263
+ return __generator(this, function (_a) {
264
+ switch (_a.label) {
265
+ case 0:
266
+ timeline = loadBoot([{ src: 'missing.js' }, { src: 'js/game.js' }], { failSrcs: ['missing.js'] }).timeline;
267
+ return [4 /*yield*/, settle()];
268
+ case 1:
269
+ _a.sent();
270
+ expect(timeline.slice(-2)).toEqual(['missing.js', 'js/game.js']);
271
+ return [2 /*return*/];
272
+ }
273
+ });
274
+ }); });
275
+ it('moves on when a host script fails to load', function () { return __awaiter(void 0, void 0, void 0, function () {
276
+ var timeline;
277
+ return __generator(this, function (_a) {
278
+ switch (_a.label) {
279
+ case 0:
280
+ timeline = loadBoot([{ src: 'js/game.js' }], {
281
+ failSrcs: ['host/persist.js']
282
+ }).timeline;
283
+ return [4 /*yield*/, settle()
284
+ // a dead persist.js still reaches the app rather than hanging the page
285
+ ];
286
+ case 1:
287
+ _a.sent();
288
+ // a dead persist.js still reaches the app rather than hanging the page
289
+ expect(timeline).toContain('js/game.js');
290
+ return [2 /*return*/];
291
+ }
292
+ });
293
+ }); });
294
+ it('reports an error the host booted with', function () { return __awaiter(void 0, void 0, void 0, function () {
295
+ return __generator(this, function (_a) {
296
+ switch (_a.label) {
297
+ case 0:
298
+ loadBoot([], { bootError: 'seed missing' });
299
+ return [4 /*yield*/, settle()];
300
+ case 1:
301
+ _a.sent();
302
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('seed missing'));
303
+ return [2 /*return*/];
304
+ }
305
+ });
306
+ }); });
307
+ it('refuses a boot config it cannot read instead of guessing', function () { return __awaiter(void 0, void 0, void 0, function () {
308
+ var timeline;
309
+ return __generator(this, function (_a) {
310
+ switch (_a.label) {
311
+ case 0:
312
+ timeline = loadBoot([], { brokenConfig: true }).timeline;
313
+ return [4 /*yield*/, settle()];
314
+ case 1:
315
+ _a.sent();
316
+ expect(timeline).toEqual([]);
317
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('boot config could not be read'));
318
+ return [2 /*return*/];
319
+ }
320
+ });
321
+ }); });
322
+ it('registers the plain worker URL when the tag carries no version', function () { return __awaiter(void 0, void 0, void 0, function () {
323
+ var registered;
324
+ return __generator(this, function (_a) {
325
+ switch (_a.label) {
326
+ case 0:
327
+ registered = loadBoot([], { noStamp: true }).registered;
328
+ return [4 /*yield*/, settle()];
329
+ case 1:
330
+ _a.sent();
331
+ expect(registered[0]).toMatch(/^\/sw\.js /);
332
+ return [2 /*return*/];
333
+ }
334
+ });
335
+ }); });
336
+ it('boots the host with no app scripts when the tag carries no config', function () { return __awaiter(void 0, void 0, void 0, function () {
337
+ var timeline;
338
+ return __generator(this, function (_a) {
339
+ switch (_a.label) {
340
+ case 0:
341
+ timeline = loadBoot([], { noScript: true }).timeline;
342
+ return [4 /*yield*/, settle()];
343
+ case 1:
344
+ _a.sent();
345
+ expect(timeline).toEqual(['host/persist.js', 'host/intercept.js']);
346
+ return [2 /*return*/];
347
+ }
348
+ });
349
+ }); });
350
+ it('waits for a worker that takes control a moment later', function () { return __awaiter(void 0, void 0, void 0, function () {
351
+ var _a, timeline, claim;
352
+ return __generator(this, function (_b) {
353
+ switch (_b.label) {
354
+ case 0:
355
+ _a = loadBoot([{ src: 'app.js' }], {
356
+ controller: 'late'
357
+ }), timeline = _a.timeline, claim = _a.claim;
358
+ return [4 /*yield*/, settle()];
359
+ case 1:
360
+ _b.sent();
361
+ claim();
362
+ return [4 /*yield*/, settle()];
363
+ case 2:
364
+ _b.sent();
365
+ expect(timeline).toEqual(['host/persist.js', 'host/intercept.js', 'app.js']);
366
+ return [2 /*return*/];
367
+ }
368
+ });
369
+ }); });
370
+ it('boots anyway when the worker cannot be registered at all', function () { return __awaiter(void 0, void 0, void 0, function () {
371
+ var timeline;
372
+ return __generator(this, function (_a) {
373
+ switch (_a.label) {
374
+ case 0:
375
+ timeline = loadBoot([{ src: 'app.js' }], {
376
+ registerFails: true,
377
+ controller: 'never'
378
+ }).timeline;
379
+ return [4 /*yield*/, settle()];
380
+ case 1:
381
+ _a.sent();
382
+ expect(timeline).toEqual(['host/persist.js', 'host/intercept.js', 'app.js']);
383
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('service worker registration failed'));
384
+ return [2 /*return*/];
385
+ }
386
+ });
387
+ }); });
388
+ it('reloads once when the worker never claims the page', function () { return __awaiter(void 0, void 0, void 0, function () {
389
+ var _a, timeline, stored;
390
+ return __generator(this, function (_b) {
391
+ switch (_b.label) {
392
+ case 0:
393
+ _a = loadBoot([{ src: 'app.js' }], {
394
+ controller: 'never'
395
+ }), timeline = _a.timeline, stored = _a.stored;
396
+ // the loader's grace period is 4s: wait past it so its timer cannot fire
397
+ // into the next test's globals
398
+ return [4 /*yield*/, new Promise(function (r) { return setTimeout(r, 4200); })];
399
+ case 1:
400
+ // the loader's grace period is 4s: wait past it so its timer cannot fire
401
+ // into the next test's globals
402
+ _b.sent();
403
+ expect(stored['mock-sw-reloaded']).toEqual('1');
404
+ expect(timeline).toEqual(['reload']);
405
+ return [2 /*return*/];
406
+ }
407
+ });
408
+ }); }, 10000);
409
+ it('boots anyway once the reload has already been tried', function () { return __awaiter(void 0, void 0, void 0, function () {
410
+ var timeline;
411
+ return __generator(this, function (_a) {
412
+ switch (_a.label) {
413
+ case 0:
414
+ timeline = loadBoot([{ src: 'app.js' }], {
415
+ controller: 'never',
416
+ guard: '1'
417
+ }).timeline;
418
+ return [4 /*yield*/, new Promise(function (r) { return setTimeout(r, 4200); })];
419
+ case 1:
420
+ _a.sent();
421
+ expect(timeline).toEqual(['host/persist.js', 'host/intercept.js', 'app.js']);
422
+ return [2 /*return*/];
423
+ }
424
+ });
425
+ }); }, 10000);
426
+ it('starts without a worker when service workers are unavailable', function () { return __awaiter(void 0, void 0, void 0, function () {
427
+ var timeline, document;
428
+ return __generator(this, function (_a) {
429
+ switch (_a.label) {
430
+ case 0:
431
+ jest.resetModules();
432
+ timeline = [];
433
+ document = {
434
+ currentScript: {
435
+ getAttribute: function () { return JSON.stringify([{ src: 'app.js' }]); }
436
+ },
437
+ createElement: function () { return ({}); },
438
+ body: {
439
+ appendChild: function (element) {
440
+ timeline.push(element.src);
441
+ setTimeout(function () { return element.onload && element.onload(); }, 0);
442
+ return element;
443
+ }
444
+ }
445
+ };
446
+ global.document = document;
447
+ global.navigator = {};
448
+ global.window = {};
449
+ global.sessionStorage = { getItem: function () { return null; }, setItem: function () { } };
450
+ global.location = { reload: function () { } };
451
+ jest.spyOn(console, 'error').mockImplementation(function () { });
452
+ require('../assets/boot.js');
453
+ return [4 /*yield*/, settle()];
454
+ case 1:
455
+ _a.sent();
456
+ expect(timeline).toEqual(['host/persist.js', 'host/intercept.js', 'app.js']);
457
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('service workers are unavailable'));
458
+ return [2 /*return*/];
459
+ }
460
+ });
461
+ }); });
462
+ });
@@ -168,6 +168,7 @@ describe('browser-hosted build', function () {
168
168
  'runtime-core.js',
169
169
  'persist.js',
170
170
  'intercept.js',
171
+ 'boot.js',
171
172
  'api-browser.js'
172
173
  ]), _b = _a.next(); !_b.done; _b = _a.next()) {
173
174
  asset = _b.value;
@@ -353,9 +354,50 @@ describe('browser-hosted build', function () {
353
354
  expect(html).toContain('serverurl=""');
354
355
  expect(html).toContain('loginmechanism="Default"');
355
356
  expect(html).not.toContain('https://sas.example.com');
356
- expect(html).toContain("register('/sw.js'");
357
- expect(html).toContain('host/persist.js');
358
- expect(html).toContain('main.abc123.js');
357
+ // the loader is a file, never an inline script: an app with a strict
358
+ // Content-Security-Policy would refuse an inline one
359
+ expect(html).toContain('<script src="host/boot.js?v=');
360
+ expect(html).not.toContain('<script>');
361
+ expect(fs_1.default.existsSync(path_1.default.join(out(), 'host', 'boot.js'))).toBe(true);
362
+ // the app's own tags are replayed by the loader, in the order the page has
363
+ // them, and are gone from the page itself
364
+ expect(html).not.toContain('<script src="main.abc123.js"');
365
+ expect(html).toContain('"src":"main.abc123.js","type":"module"');
366
+ return [2 /*return*/];
367
+ }
368
+ });
369
+ }); });
370
+ it('replays every script tag a page loads, not just a bundled entry point', function () { return __awaiter(void 0, void 0, void 0, function () {
371
+ var html, tag, specs;
372
+ return __generator(this, function (_a) {
373
+ switch (_a.label) {
374
+ case 0:
375
+ write(path_1.default.join(root, 'client', 'dist', 'index.html'), [
376
+ '<!doctype html><html><body>',
377
+ '<sasjs serverurl="https://sas.example.com" apploc="/Public/app/test"></sasjs>',
378
+ '<script type="application/ld+json">{"name":"app"}</script>',
379
+ '<script defer id="sasjs-adapter" src="sasjs.js"></script>',
380
+ '<script>window.__boot = {ready: false}</script>',
381
+ '<script src="js/backend.js"></script>',
382
+ '<script src="js/game.js" crossorigin="anonymous"></script>',
383
+ '</body></html>'
384
+ ].join('\n'));
385
+ return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
386
+ case 1:
387
+ _a.sent();
388
+ html = fs_1.default.readFileSync(path_1.default.join(out(), 'index.html'), 'utf8');
389
+ tag = html.match(/data-scripts='([^']*)'/);
390
+ expect(tag).toBeTruthy();
391
+ specs = JSON.parse(tag[1].replace(/&#39;/g, "'").replace(/&amp;/g, '&'));
392
+ expect(specs).toEqual([
393
+ { src: 'sasjs.js', id: 'sasjs-adapter' },
394
+ { code: 'window.__boot = {ready: false}' },
395
+ { src: 'js/backend.js' },
396
+ { src: 'js/game.js', crossOrigin: 'anonymous' }
397
+ ]);
398
+ expect(html).not.toContain('<script src="js/backend.js"');
399
+ // a data block is not a script: it stays in the page
400
+ expect(html).toContain('<script type="application/ld+json">');
359
401
  return [2 /*return*/];
360
402
  }
361
403
  });
@@ -580,7 +622,7 @@ describe('browser-hosted build', function () {
580
622
  result = _c.sent();
581
623
  expect(result.webFileCount).toBeGreaterThan(0);
582
624
  expect((_a = process.logger) === null || _a === void 0 ? void 0 : _a.warn).toHaveBeenCalledWith(expect.stringContaining('no <sasjs> element found'));
583
- expect((_b = process.logger) === null || _b === void 0 ? void 0 : _b.warn).toHaveBeenCalledWith(expect.stringContaining('no main bundle script tag found'));
625
+ expect((_b = process.logger) === null || _b === void 0 ? void 0 : _b.warn).toHaveBeenCalledWith(expect.stringContaining('no script tags found'));
584
626
  return [2 /*return*/];
585
627
  }
586
628
  });
package/package.json CHANGED
@@ -124,5 +124,5 @@
124
124
  "ws": "8.21.0",
125
125
  "nanoid": "3.3.18"
126
126
  },
127
- "version": "6.12.0"
127
+ "version": "6.13.0"
128
128
  }