@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.
- package/build/commands/build/internal/mockSite/assets/persist.js +7 -3
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +55 -4
- package/build/commands/build/internal/mockSite/mockSite.d.ts +8 -0
- package/build/commands/build/internal/mockSite/mockSite.js +10 -2
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +18 -0
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +79 -0
- package/package.json +3 -3
|
@@ -176,9 +176,13 @@
|
|
|
176
176
|
'sasjs-snapshot'
|
|
177
177
|
)
|
|
178
178
|
const files = (snapshot && snapshot.files) || {}
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
127
|
+
"@sasjs/lint": "^4.3.2"
|
|
128
128
|
},
|
|
129
|
-
"version": "6.2.
|
|
129
|
+
"version": "6.2.3"
|
|
130
130
|
}
|