@sasjs/cli 6.11.0 → 6.12.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.
@@ -100,6 +100,21 @@ exports.WEBOUT_SENTINELS = {
100
100
  apiServerUrl: '__SASJS_WEBOUT_APIURL__'
101
101
  };
102
102
  var ASSETS_FOLDER = path_1.default.join(__dirname, 'assets');
103
+ /**
104
+ * The browser build of @sasjs/api, from the installed package rather than a copy
105
+ * in this folder: the worker answers the drive with the same rules sasjs/server
106
+ * does, so the version that ships is the version the CLI resolves - and a stale
107
+ * second copy is exactly the drift the package exists to remove.
108
+ *
109
+ * The path is walked from `require.resolve` because `exports` in @sasjs/api
110
+ * exposes only the package root - `require.resolve('@sasjs/api/browser.js')`
111
+ * fails with ERR_PACKAGE_PATH_NOT_EXPORTED, and no subpath can reach
112
+ * `build/browser.js`. The walk lands on that file only while `main` stays
113
+ * `build/cjs/index.js`; a change to `main` or `exports` in the package repoints
114
+ * it silently, so a missing file is reported by name rather than as a bare
115
+ * ENOENT three levels up.
116
+ */
117
+ var API_BROWSER_BUILD = path_1.default.join(path_1.default.dirname(require.resolve('@sasjs/api')), '..', '..', 'build', 'browser.js');
103
118
  var toPosix = function (value) { return value.split(path_1.default.sep).join('/'); };
104
119
  var listFiles = function (folder, base) {
105
120
  var e_1, _a;
@@ -326,7 +341,7 @@ var countJsPrograms = function (files) {
326
341
  return inProgramFolder && key.toLowerCase().endsWith('.js');
327
342
  }).length;
328
343
  };
329
- 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
+ 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 // The worker script's own URL is the only thing a returning visitor\n // cannot bypass: the host files behind it are cache-busted with a\n // version query, but a cached sw.js keeps serving the previous\n // build's worker until the browser next revalidates it - and that\n // worker imports the previous runtime, which may know nothing of\n // what this build deploys. Stamping the script URL makes the new\n // worker a different script, so it installs and takes over at once;\n // updateViaCache keeps a proxy from answering the check with the\n // old copy.\n .register('/sw.js?v=").concat(version, "', { scope: '/', updateViaCache: 'none' })\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>"); };
330
345
  /**
331
346
  * Points the app at its own origin and replaces its bundle tag with the
332
347
  * boot loader. Both changes are reported when the expected markup is absent,
@@ -417,6 +432,15 @@ function buildMockSite(target) {
417
432
  throw new Error("No .js programs in ".concat(buildFolder, "/services, /jobs or /tests. This target builds SAS code, which cannot run in a browser. ") +
418
433
  'Build a target whose services are JavaScript (typically a mocks project).');
419
434
  }
435
+ // The package supplies the drive rules the worker answers with, so an install
436
+ // without its browser build is a build that cannot run. Checked here, with the
437
+ // other refusals, so a missing dependency leaves the previous site in place -
438
+ // and named, rather than a bare ENOENT from a path three levels up.
439
+ if (!fs_1.default.existsSync(API_BROWSER_BUILD)) {
440
+ throw new Error("The @sasjs/api browser build is missing: expected it at ".concat(API_BROWSER_BUILD, ". ") +
441
+ 'Reinstall dependencies (npm ci) so the package that ships the drive rules is present, ' +
442
+ 'or check that @sasjs/api still publishes build/browser.js.');
443
+ }
420
444
  if (skipped.length) {
421
445
  (_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: ") +
422
446
  "".concat(skipped
@@ -454,6 +478,7 @@ function buildMockSite(target) {
454
478
  }
455
479
  finally { if (e_6) throw e_6.error; }
456
480
  }
481
+ fs_1.default.copyFileSync(API_BROWSER_BUILD, path_1.default.join(hostFolder, 'api-browser.js'));
457
482
  // sw.js sits at the site root: a worker can only control the folder it is
458
483
  // served from, and the app has to be controlled from /
459
484
  fs_1.default.copyFileSync(path_1.default.join(ASSETS_FOLDER, 'sw.js'), path_1.default.join(outputFolder, 'sw.js'));
@@ -473,7 +498,13 @@ function buildMockSite(target) {
473
498
  })
474
499
  }));
475
500
  hostVersion = (0, crypto_1.createHash)('md5')
476
- .update(['runtime-core.js', 'persist.js', 'intercept.js', 'webout-prelude.json']
501
+ .update([
502
+ 'runtime-core.js',
503
+ 'persist.js',
504
+ 'intercept.js',
505
+ 'api-browser.js',
506
+ 'webout-prelude.json'
507
+ ]
477
508
  .map(function (asset) { return fs_1.default.readFileSync(path_1.default.join(hostFolder, asset)); })
478
509
  .reduce(function (all, buf) { return Buffer.concat([all, buf]); }, Buffer.alloc(0)))
479
510
  .digest('hex')
@@ -481,7 +512,8 @@ function buildMockSite(target) {
481
512
  swPath = path_1.default.join(outputFolder, 'sw.js');
482
513
  fs_1.default.writeFileSync(swPath, fs_1.default
483
514
  .readFileSync(swPath, 'utf8')
484
- .replace("importScripts('/host/runtime-core.js')", "importScripts('/host/runtime-core.js?v=".concat(hostVersion, "')")));
515
+ .replace("importScripts('/host/runtime-core.js')", "importScripts('/host/runtime-core.js?v=".concat(hostVersion, "')"))
516
+ .replace("importScripts('/host/api-browser.js')", "importScripts('/host/api-browser.js?v=".concat(hostVersion, "')")));
485
517
  snapshotSource = path_1.default.join(buildFolder, 'mock-snapshot.json');
486
518
  hasSnapshot = fs_1.default.existsSync(snapshotSource);
487
519
  if (hasSnapshot) {
@@ -167,7 +167,8 @@ describe('browser-hosted build', function () {
167
167
  'md5.js',
168
168
  'runtime-core.js',
169
169
  'persist.js',
170
- 'intercept.js'
170
+ 'intercept.js',
171
+ 'api-browser.js'
171
172
  ]), _b = _a.next(); !_b.done; _b = _a.next()) {
172
173
  asset = _b.value;
173
174
  expect(fs_1.default.existsSync(path_1.default.join(out(), 'host', asset))).toBe(true);
@@ -189,6 +190,124 @@ describe('browser-hosted build', function () {
189
190
  }
190
191
  });
191
192
  }); });
193
+ it('ships the @sasjs/api build the CLI resolves, and versions both imports', function () { return __awaiter(void 0, void 0, void 0, function () {
194
+ var shipped, installed, sw;
195
+ return __generator(this, function (_a) {
196
+ switch (_a.label) {
197
+ case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())
198
+ // the same file the installed package carries: a vendored copy is exactly
199
+ // how the worker and the server drift apart
200
+ ];
201
+ case 1:
202
+ _a.sent();
203
+ shipped = fs_1.default.readFileSync(path_1.default.join(out(), 'host', 'api-browser.js'));
204
+ installed = fs_1.default.readFileSync(path_1.default.join(path_1.default.dirname(require.resolve('@sasjs/api')), '..', '..', 'build', 'browser.js'));
205
+ expect(shipped.equals(installed)).toBe(true);
206
+ sw = fs_1.default.readFileSync(path_1.default.join(out(), 'sw.js'), 'utf8');
207
+ // both imports carry the host version: a worker caches what it imports, so
208
+ // a rebuild that changes either file has to change the URL
209
+ expect(sw).toMatch(/importScripts\('\/host\/runtime-core\.js\?v=[0-9a-f]{10}'\)/);
210
+ expect(sw).toMatch(/importScripts\('\/host\/api-browser\.js\?v=[0-9a-f]{10}'\)/);
211
+ expect(sw).not.toContain("importScripts('/host/runtime-core.js')");
212
+ return [2 /*return*/];
213
+ }
214
+ });
215
+ }); });
216
+ it('names the package when its browser build is missing, and keeps the previous site', function () { return __awaiter(void 0, void 0, void 0, function () {
217
+ var browserBuild, exists;
218
+ return __generator(this, function (_a) {
219
+ switch (_a.label) {
220
+ case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
221
+ case 1:
222
+ _a.sent();
223
+ write(path_1.default.join(out(), 'stale.txt'), 'previous');
224
+ browserBuild = path_1.default.join(path_1.default.dirname(require.resolve('@sasjs/api')), '..', '..', 'build', 'browser.js');
225
+ exists = jest.spyOn(fs_1.default, 'existsSync');
226
+ exists.mockImplementation(function (checked) { return String(checked) !== browserBuild; });
227
+ _a.label = 2;
228
+ case 2:
229
+ _a.trys.push([2, , 4, 5]);
230
+ return [4 /*yield*/, expect((0, mockSite_1.buildMockSite)(target())).rejects.toThrow(/@sasjs\/api browser build is missing/)];
231
+ case 3:
232
+ _a.sent();
233
+ return [3 /*break*/, 5];
234
+ case 4:
235
+ exists.mockRestore();
236
+ return [7 /*endfinally*/];
237
+ case 5:
238
+ // and the refusal lands before anything is written, so the site that was
239
+ // there is still there
240
+ expect(fs_1.default.existsSync(path_1.default.join(out(), 'stale.txt'))).toBe(true);
241
+ expect(fs_1.default.readFileSync(path_1.default.join(out(), 'stale.txt'), 'utf8')).toBe('previous');
242
+ return [2 /*return*/];
243
+ }
244
+ });
245
+ }); });
246
+ /**
247
+ * The worker's scripts share one global scope, so a name declared at the top
248
+ * level of two of them is a SyntaxError in the browser - and invisible to this
249
+ * suite, where each file is a module of its own. `importScripts` is the
250
+ * browser's rule: evaluate the files the build wrote, in one context, once.
251
+ */
252
+ it('evaluates the shipped scripts in one global scope, the way a worker does', function () { return __awaiter(void 0, void 0, void 0, function () {
253
+ var vm, sandbox, context, imported;
254
+ return __generator(this, function (_a) {
255
+ switch (_a.label) {
256
+ case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())];
257
+ case 1:
258
+ _a.sent();
259
+ vm = require('vm');
260
+ sandbox = {
261
+ console: console,
262
+ Request: Request,
263
+ Response: Response,
264
+ Headers: Headers,
265
+ URL: URL,
266
+ FormData: FormData,
267
+ Blob: Blob,
268
+ TextEncoder: TextEncoder,
269
+ TextDecoder: TextDecoder,
270
+ Uint8Array: Uint8Array,
271
+ ArrayBuffer: ArrayBuffer,
272
+ atob: atob,
273
+ btoa: btoa,
274
+ setTimeout: setTimeout,
275
+ clearTimeout: clearTimeout,
276
+ location: { origin: 'https://mock.example.com' },
277
+ clients: { claim: function () { }, matchAll: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
278
+ return [2 /*return*/, []];
279
+ }); }); } },
280
+ skipWaiting: function () { },
281
+ addEventListener: function () { },
282
+ fetch: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
283
+ return [2 /*return*/, new Response('{}', { headers: { 'content-type': 'application/json' } })];
284
+ }); }); }
285
+ };
286
+ sandbox.self = sandbox;
287
+ context = vm.createContext(sandbox);
288
+ imported = new Set();
289
+ sandbox.importScripts = function (url) {
290
+ if (imported.has(url))
291
+ return;
292
+ imported.add(url);
293
+ var file = path_1.default.join(out(), url.replace(/^\//, '').split('?')[0]);
294
+ vm.runInContext(fs_1.default.readFileSync(file, 'utf8'), context, {
295
+ filename: file
296
+ });
297
+ };
298
+ expect(function () {
299
+ return vm.runInContext(fs_1.default.readFileSync(path_1.default.join(out(), 'sw.js'), 'utf8'), context, {
300
+ filename: 'sw.js'
301
+ });
302
+ }).not.toThrow();
303
+ // both scripts are loaded, and the drive the API is handed came from the
304
+ // runtime rather than a second implementation
305
+ expect(Object.keys(sandbox.SASjsMockRuntime)).toContain('driveStore');
306
+ expect(typeof sandbox.SASjsApi.createDriveService).toBe('function');
307
+ return [2 /*return*/];
308
+ }
309
+ });
310
+ }); });
192
311
  it('ships the webout() block with sentinels the runtime fills in', function () { return __awaiter(void 0, void 0, void 0, function () {
193
312
  var prelude, _a, _b, sentinel;
194
313
  var e_2, _c;
@@ -234,7 +353,11 @@ describe('browser-hosted build', function () {
234
353
  expect(html).toContain('serverurl=""');
235
354
  expect(html).toContain('loginmechanism="Default"');
236
355
  expect(html).not.toContain('https://sas.example.com');
237
- expect(html).toContain("register('/sw.js'");
356
+ expect(html).toContain("register('/sw.js?v=");
357
+ // A cached worker script must not keep a returning visitor on the previous
358
+ // build's runtime: the URL carries the host version, and the check skips
359
+ // the HTTP cache.
360
+ expect(html).toContain("updateViaCache: 'none'");
238
361
  expect(html).toContain('host/persist.js');
239
362
  expect(html).toContain('main.abc123.js');
240
363
  return [2 /*return*/];