@sasjs/cli 6.2.1 → 6.2.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.
@@ -176,9 +176,13 @@
176
176
  'sasjs-snapshot'
177
177
  )
178
178
  const files = (snapshot && snapshot.files) || {}
179
- const blob = new Blob([JSON.stringify({ files }, null, 2)], {
180
- type: 'application/json'
181
- })
179
+ // the format stamp the runtime checks on load: a snapshot that outlived a
180
+ // marker-format change is refused by name, instead of decoding as text
181
+ const driveFormat = (self.SASjsMockRuntime || {}).DRIVE_FORMAT
182
+ const blob = new Blob(
183
+ [JSON.stringify({ format: driveFormat, files }, null, 2)],
184
+ { type: 'application/json' }
185
+ )
182
186
  const url = URL.createObjectURL(blob)
183
187
  const link = document.createElement('a')
184
188
  link.href = url
@@ -490,7 +490,12 @@ const BUFFERS = {
490
490
  )
491
491
  if (end < start || end > value.byteLength)
492
492
  throw new RangeError('"length" is outside of buffer bounds')
493
- return asBuffer(new Uint8Array(value, start, end - start))
493
+ // node returns a VIEW over the ArrayBuffer for this overload - a service
494
+ // that patches the bytes through the buffer is patching the ArrayBuffer
495
+ // it kept - and copies only for a Uint8Array input, which is what
496
+ // asBytes does below. `decorate` adds the methods to the view in place;
497
+ // `asBuffer` would copy it and hide the patch.
498
+ return decorate(new Uint8Array(value, start, end - start))
494
499
  }
495
500
  const bytes = asBytes(value)
496
501
  if (!bytes)
@@ -821,6 +826,19 @@ let DRIVE_APP = `${DRIVE_ROOT}/files${APP_LOC}`
821
826
  const SEED_PATH = '/host/vfs-seed.json'
822
827
  const SNAPSHOT_PATH = '/host/vfs-snapshot.json'
823
828
 
829
+ /**
830
+ * The drive storage format, stamped into the seed and the snapshot. The markers
831
+ * themselves (`\u0000base64:` / `\u0000text:`) are written by the build
832
+ * (DRIVE_BINARY_PREFIX / DRIVE_TEXT_PREFIX in mockSite.ts) and read here, so the
833
+ * two sides hold the same format independently - and a one-sided edit would
834
+ * otherwise show up as a byte file reading as text, or a 500 per entry.
835
+ *
836
+ * The stamp is what makes that loud instead of silent: a file written by a build
837
+ * that speaks a different format is refused here, naming both numbers. Bump this
838
+ * whenever the marker format changes, and the builder's DRIVE_FORMAT with it.
839
+ */
840
+ const DRIVE_FORMAT = 1
841
+
824
842
  /**
825
843
  * Reads the generated mock-config.json (appLoc and provenance). Absent or
826
844
  * malformed, the defaults above apply - a demo can then only work when the
@@ -998,10 +1016,19 @@ return _webout;
998
1016
  const binary = !corrupt && !!fromFile && isBinary(stored)
999
1017
  const webout = fromFile || (typeof returned === 'string' ? returned : '')
1000
1018
 
1019
+ // The headers file is read as text whatever way it was written, which is what
1020
+ // the server does (`extractHeaders` reads the file, not the write). A service
1021
+ // that writes it as bytes - a Buffer, or any non-UTF-8 encoding - leaves a
1022
+ // byte entry on the drive, so decoding the entry text alone would hand the
1023
+ // worker the `\u0000base64:...` marker and drop every declared header.
1024
+ const storedHeaders = FILES.get(headersPath) || ''
1025
+
1001
1026
  return {
1002
1027
  webout,
1003
1028
  log: logLines.join('\n'),
1004
- headers: entryText(FILES.get(headersPath) || ''),
1029
+ headers: isBinary(storedHeaders)
1030
+ ? utf8Decoder.decode(entryBytes(storedHeaders))
1031
+ : entryText(storedHeaders),
1005
1032
  // a byte body travels to the host as base64, which is how it is stored
1006
1033
  binary,
1007
1034
  error: thrown
@@ -1115,8 +1142,29 @@ const fetchDrive = async () => {
1115
1142
  if (!res.ok) continue
1116
1143
  const body = await res.json()
1117
1144
  const files = body && body.files ? body.files : body
1118
- if (files && typeof files === 'object') return { source, files }
1145
+ if (!files || typeof files !== 'object') continue
1146
+
1147
+ // A file that declares a format this runtime does not speak is refused
1148
+ // rather than misread: the markers inside it would decode as text, and the
1149
+ // failure would surface as a corrupt download or a 500 per entry with
1150
+ // nothing naming the cause. A file with no stamp predates the stamp and is
1151
+ // read as the current format.
1152
+ if (body && body.format !== undefined && body.format !== DRIVE_FORMAT)
1153
+ throw new Error(
1154
+ 'The drive in ' +
1155
+ url +
1156
+ ' is format ' +
1157
+ JSON.stringify(body.format) +
1158
+ ', and this runtime speaks format ' +
1159
+ DRIVE_FORMAT +
1160
+ '. Rebuild the mock site with the matching @sasjs/cli.'
1161
+ )
1162
+
1163
+ return { source, files }
1119
1164
  } catch (e) {
1165
+ // a format mismatch is not a reason to try the other file: it is the file
1166
+ // the operator meant to ship, and falling through would hide it
1167
+ if (e && /is format /.test(e.message)) throw e
1120
1168
  /* fall through to the next source */
1121
1169
  }
1122
1170
  }
@@ -1222,7 +1270,10 @@ const api = {
1222
1270
  run: (payload) => runProgram(payload),
1223
1271
  list: (folder) => listFolder(folder),
1224
1272
  read: (file) => readDriveFile(file),
1225
- tree: () => buildTree()
1273
+ tree: () => buildTree(),
1274
+ // the format the snapshot download stamps, so persist.js writes the number
1275
+ // this runtime reads rather than a second literal
1276
+ DRIVE_FORMAT
1226
1277
  }
1227
1278
 
1228
1279
  self.SASjsMockRuntime = api
@@ -21,6 +21,14 @@ export interface MockSiteResult {
21
21
  */
22
22
  export declare const MAX_BINARY_SEED_BYTES: number;
23
23
  export declare const MAX_SEED_BYTES: number;
24
+ /**
25
+ * The storage format these markers belong to, stamped into `vfs-seed.json` and
26
+ * into the snapshot the site downloads. runtime-core.js holds its own copy (it
27
+ * ships as a plain browser script) and refuses a file whose stamp it does not
28
+ * speak, so the two cannot drift silently. Bump both numbers together when the
29
+ * marker format changes.
30
+ */
31
+ export declare const DRIVE_FORMAT = 1;
24
32
  /**
25
33
  * Builds a static site that runs the project's JS services (its mocks) in the
26
34
  * browser, so a frontend can be tested with no server and no SAS.
@@ -75,7 +75,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
75
75
  return (mod && mod.__esModule) ? mod : { "default": mod };
76
76
  };
77
77
  Object.defineProperty(exports, "__esModule", { value: true });
78
- exports.MAX_SEED_BYTES = exports.MAX_BINARY_SEED_BYTES = void 0;
78
+ exports.DRIVE_FORMAT = exports.MAX_SEED_BYTES = exports.MAX_BINARY_SEED_BYTES = void 0;
79
79
  exports.buildMockSite = buildMockSite;
80
80
  var crypto_1 = require("crypto");
81
81
  var fs_1 = __importDefault(require("fs"));
@@ -162,6 +162,14 @@ exports.MAX_SEED_BYTES = 25 * 1024 * 1024;
162
162
  */
163
163
  var DRIVE_BINARY_PREFIX = '\u0000base64:';
164
164
  var DRIVE_TEXT_PREFIX = '\u0000text:';
165
+ /**
166
+ * The storage format these markers belong to, stamped into `vfs-seed.json` and
167
+ * into the snapshot the site downloads. runtime-core.js holds its own copy (it
168
+ * ships as a plain browser script) and refuses a file whose stamp it does not
169
+ * speak, so the two cannot drift silently. Bump both numbers together when the
170
+ * marker format changes.
171
+ */
172
+ exports.DRIVE_FORMAT = 1;
165
173
  /** A byte count in MB, for the message that names a file too large to seed. */
166
174
  var megabytes = function (bytes) {
167
175
  return "".concat(Math.round((bytes / 1024 / 1024) * 10) / 10, " MB");
@@ -447,7 +455,7 @@ function buildMockSite(target) {
447
455
  fs_1.default.copyFileSync(snapshotSource, path_1.default.join(hostFolder, 'vfs-snapshot.json'));
448
456
  }
449
457
  // 3. the drive, laid out from the compile output
450
- fs_1.default.writeFileSync(path_1.default.join(hostFolder, 'vfs-seed.json'), JSON.stringify({ files: seed }));
458
+ fs_1.default.writeFileSync(path_1.default.join(hostFolder, 'vfs-seed.json'), JSON.stringify({ format: exports.DRIVE_FORMAT, files: seed }));
451
459
  fs_1.default.writeFileSync(path_1.default.join(hostFolder, 'mock-config.json'), JSON.stringify({
452
460
  appLoc: appLoc,
453
461
  seedPath: '/host/vfs-seed.json',
@@ -136,6 +136,24 @@ describe('browser-hosted build', function () {
136
136
  }
137
137
  });
138
138
  }); });
139
+ it('stamps the seed with the drive format the runtime checks', function () { return __awaiter(void 0, void 0, void 0, function () {
140
+ return __generator(this, function (_a) {
141
+ switch (_a.label) {
142
+ case 0: return [4 /*yield*/, (0, mockSite_1.buildMockSite)(target())
143
+ // the runtime ships as a plain browser script and holds its own copy of the
144
+ // marker format, so the seed carries the number and a mismatch is refused
145
+ // there by name rather than decoded as text
146
+ ];
147
+ case 1:
148
+ _a.sent();
149
+ // the runtime ships as a plain browser script and holds its own copy of the
150
+ // marker format, so the seed carries the number and a mismatch is refused
151
+ // there by name rather than decoded as text
152
+ expect(readSeed().format).toBe(mockSite_1.DRIVE_FORMAT);
153
+ return [2 /*return*/];
154
+ }
155
+ });
156
+ }); });
139
157
  it('writes the runtime host and the worker at the site root', function () { return __awaiter(void 0, void 0, void 0, function () {
140
158
  var _a, _b, asset;
141
159
  var e_1, _c;
@@ -87,6 +87,47 @@ describe('browser runtime', function () {
87
87
  delete global.self;
88
88
  delete global.fetch;
89
89
  });
90
+ it('refuses a drive file whose format it does not speak', function () { return __awaiter(void 0, void 0, void 0, function () {
91
+ var runtime, result;
92
+ return __generator(this, function (_a) {
93
+ switch (_a.label) {
94
+ case 0:
95
+ runtime = loadRuntime();
96
+ global.fetch = jest.fn(function (url) { return __awaiter(void 0, void 0, void 0, function () {
97
+ return __generator(this, function (_a) {
98
+ return [2 /*return*/, url.includes('mock-config')
99
+ ? jsonResponse({ appLoc: appLoc })
100
+ : jsonResponse({ format: 999, files: {} })];
101
+ });
102
+ }); });
103
+ return [4 /*yield*/, runtime.seed()];
104
+ case 1:
105
+ result = _a.sent();
106
+ expect(result.booted).toBe(false);
107
+ expect(result.error).toContain('format 999');
108
+ expect(result.error).toContain('speaks format 1');
109
+ return [2 /*return*/];
110
+ }
111
+ });
112
+ }); });
113
+ it('reads a drive file with no format stamp as the current format', function () { return __awaiter(void 0, void 0, void 0, function () {
114
+ var runtime;
115
+ var _a;
116
+ return __generator(this, function (_b) {
117
+ switch (_b.label) {
118
+ case 0:
119
+ runtime = loadRuntime();
120
+ // every snapshot taken before the stamp existed looks like this
121
+ return [4 /*yield*/, seedDrive(runtime, (_a = {}, _a[drivePath('a.txt')] = 'a', _a))];
122
+ case 1:
123
+ // every snapshot taken before the stamp existed looks like this
124
+ _b.sent();
125
+ expect(runtime.stats().files).toBe(1);
126
+ expect(runtime.DRIVE_FORMAT).toBe(1);
127
+ return [2 /*return*/];
128
+ }
129
+ });
130
+ }); });
90
131
  it('exposes request parameters as variables, with JSON escaping', function () {
91
132
  var runtime = loadRuntime();
92
133
  var result = runtime.run({
@@ -216,6 +257,26 @@ describe('browser runtime', function () {
216
257
  });
217
258
  expect(result.headers).toBe('content-type: application/json');
218
259
  });
260
+ it('reads headers written as bytes the way the server does', function () {
261
+ var runtime = loadRuntime();
262
+ // The server reads the headers FILE, whatever way the service wrote it, so
263
+ // a byte write must still reach the host as text - handing over the drive's
264
+ // storage marker instead would drop every declared header and leave the
265
+ // download with its default type and name.
266
+ var result = runtime.run({
267
+ code: [
268
+ 'fs.writeFileSync(_SASJS_WEBOUT_HEADERS, Buffer.from([',
269
+ " 'Content-type: text/csv',",
270
+ ' \'Content-Disposition: attachment; filename="report.csv"\'',
271
+ '].join(String.fromCharCode(10))))',
272
+ 'fs.writeFileSync(weboutPath, Buffer.from([97, 44, 98]))'
273
+ ].join('\n')
274
+ });
275
+ expect(result.error).toBeNull();
276
+ expect(result.binary).toBe(true);
277
+ expect(result.headers).toContain('Content-type: text/csv');
278
+ expect(result.headers).toContain('filename="report.csv"');
279
+ });
219
280
  it('hands a byte body back to the host as base64', function () {
220
281
  var runtime = loadRuntime();
221
282
  // the shape a service that streams a file uses on the server: write the
@@ -632,6 +693,24 @@ describe('browser runtime', function () {
632
693
  ].join('\n')
633
694
  }).webout).toBe('504b0304');
634
695
  });
696
+ it('does not copy an ArrayBuffer into a Buffer, and does copy a view', function () {
697
+ var runtime = loadRuntime();
698
+ var result = runtime.run({
699
+ code: [
700
+ 'const ab = new Uint8Array([1, 2, 3, 4]).buffer',
701
+ 'const over = Buffer.from(ab)',
702
+ 'over[0] = 9',
703
+ 'const source = new Uint8Array([1, 2, 3])',
704
+ 'const copy = Buffer.from(source)',
705
+ 'copy[0] = 7',
706
+ '_webout = [over.length, new Uint8Array(ab)[0], copy[0], source[0]].join("|")'
707
+ ].join('\n')
708
+ });
709
+ expect(result.error).toBeNull();
710
+ // node returns a view for the ArrayBuffer overload, so the patch is visible
711
+ // in the ArrayBuffer the service kept, and copies a Uint8Array input
712
+ expect(result.webout).toBe('4|9|7|1');
713
+ });
635
714
  it('takes the offset and length node takes for an ArrayBuffer, and a length for concat', function () {
636
715
  var runtime = loadRuntime();
637
716
  var result = runtime.run({
package/package.json CHANGED
@@ -94,7 +94,7 @@
94
94
  "@babel/preset-env": "7.29.7",
95
95
  "@babel/preset-typescript": "7.29.7",
96
96
  "@nogoo9/gitleaks": "8.30.1-post.2",
97
- "@sasjs/lint": "^4.3.1",
97
+ "@sasjs/lint": "^4.3.2",
98
98
  "@types/debug": "4.1.13",
99
99
  "@types/find": "0.2.4",
100
100
  "@types/jest": "30.0.0",
@@ -124,7 +124,7 @@
124
124
  "nanoid": "3.3.18"
125
125
  },
126
126
  "peerDependencies": {
127
- "@sasjs/lint": "^4.3.1"
127
+ "@sasjs/lint": "^4.3.2"
128
128
  },
129
- "version": "6.2.1"
129
+ "version": "6.2.3"
130
130
  }