@sasjs/cli 6.10.1 → 6.12.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.
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +221 -75
- package/build/commands/build/internal/mockSite/assets/sw.js +160 -24
- package/build/commands/build/internal/mockSite/mockSite.js +34 -2
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +120 -1
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +480 -14
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +710 -4
- package/package.json +3 -2
|
@@ -207,6 +207,15 @@ const cleanedBase64 = (contents) => {
|
|
|
207
207
|
return clean
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The stored base64 of a byte entry, once it is known to decode: the run's read
|
|
212
|
+
* of the webout file uses this, so a corrupt entry fails the run that produced it
|
|
213
|
+
* rather than reaching the worker's decoder without the drive's name in the
|
|
214
|
+
* message. Drive *reads* go through `driveStore`, which decodes through the same
|
|
215
|
+
* check.
|
|
216
|
+
*/
|
|
217
|
+
const validateEntry = (contents) => cleanedBase64(contents)
|
|
218
|
+
|
|
210
219
|
const corruptEntry = () => {
|
|
211
220
|
const error = new Error('Corrupt binary entry on the drive: not valid base64')
|
|
212
221
|
error.status = 500
|
|
@@ -973,6 +982,16 @@ const resolveProgram = (program) => {
|
|
|
973
982
|
*/
|
|
974
983
|
const SEEDED_HEADERS = 'content-type: text/html; charset=utf-8\n'
|
|
975
984
|
|
|
985
|
+
/**
|
|
986
|
+
* The largest body a single upload may carry.
|
|
987
|
+
*
|
|
988
|
+
* A browser worker holds the whole payload in memory, so an oversized upload is
|
|
989
|
+
* an out-of-memory failure rather than a limit a client can act on. This is the
|
|
990
|
+
* same 10 MB a deploy allows for one file, and it is checked from the File's
|
|
991
|
+
* `size` before the body is read.
|
|
992
|
+
*/
|
|
993
|
+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
|
994
|
+
|
|
976
995
|
const runProgram = ({ program, code, params = {}, tables = [] }) => {
|
|
977
996
|
const payloadCode = code
|
|
978
997
|
const codePath = program ? resolveProgram(program) : null
|
|
@@ -1120,91 +1139,215 @@ return _webout;
|
|
|
1120
1139
|
}
|
|
1121
1140
|
}
|
|
1122
1141
|
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1142
|
+
const DRIVE_FILES = () => `${DRIVE_ROOT}/files`
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Writes one entry: bytes stay bytes (behind the base64 marker the drive reads
|
|
1146
|
+
* them back from), a string is text, and the entry is marked for persistence.
|
|
1147
|
+
*
|
|
1148
|
+
* An upload arrives as bytes and is stored as bytes, which is also what the
|
|
1149
|
+
* server does: multer writes the request body to disk and `saveFile` moves that
|
|
1150
|
+
* file, so an uploaded CSV is a byte file there too - a program reading it
|
|
1151
|
+
* without an encoding gets a Buffer on both hosts, and with `'utf8'` a string.
|
|
1152
|
+
* The difference a client actually sees is the content type a download is served
|
|
1153
|
+
* with, and that is decided by the extension on the GET route, as `sendFile`
|
|
1154
|
+
* does on the server.
|
|
1155
|
+
*/
|
|
1156
|
+
const storeEntry = (abs, data) => {
|
|
1157
|
+
const bytes = asBytes(data)
|
|
1158
|
+
FILES.set(
|
|
1159
|
+
abs,
|
|
1160
|
+
bytes
|
|
1161
|
+
? BINARY_PREFIX + bytesToBase64(bytes)
|
|
1162
|
+
: encodeText(String(data === undefined || data === null ? '' : data))
|
|
1163
|
+
)
|
|
1164
|
+
SIZES.delete(abs)
|
|
1165
|
+
markDirty(abs)
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* The ancestor of a path that is itself a file, if there is one.
|
|
1170
|
+
*
|
|
1171
|
+
* `mkdirp` would happily turn `/data/x.csv` into a folder to hold
|
|
1172
|
+
* `/data/x.csv/child`, leaving a path that is a file and has children at the
|
|
1173
|
+
* same time - a state the server never reaches, because the filesystem refuses
|
|
1174
|
+
* it (ENOTDIR). The server answers that with a 500, so this does too: it is a
|
|
1175
|
+
* refusal either way, and reproducing the host failure is the point.
|
|
1176
|
+
*/
|
|
1177
|
+
const fileAncestorOf = (abs) => {
|
|
1178
|
+
const parts = abs.split('/').filter(Boolean)
|
|
1179
|
+
let acc = ''
|
|
1180
|
+
for (const part of parts.slice(0, -1)) {
|
|
1181
|
+
acc += '/' + part
|
|
1182
|
+
if (FILES.has(acc)) return acc
|
|
1155
1183
|
}
|
|
1156
|
-
return
|
|
1184
|
+
return null
|
|
1157
1185
|
}
|
|
1158
1186
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1187
|
+
/**
|
|
1188
|
+
* A drive-relative path - the form the API uses - as the key the map holds.
|
|
1189
|
+
*
|
|
1190
|
+
* The containment test is the storage boundary's own rule, not a second copy of
|
|
1191
|
+
* the API's: `driveStore` is handed out on `self.SASjsMockRuntime`, so a caller
|
|
1192
|
+
* that skips the package can still name `../..` and this must not resolve it out
|
|
1193
|
+
* of the drive. The API refuses such a path first, with the wording a client
|
|
1194
|
+
* sees; reaching this refusal means something bypassed it.
|
|
1195
|
+
*/
|
|
1196
|
+
const absDrivePath = (driveRelative) => {
|
|
1197
|
+
const relative = String(
|
|
1198
|
+
driveRelative === undefined || driveRelative === null ? '' : driveRelative
|
|
1164
1199
|
)
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1200
|
+
.replace(/\\/g, '/')
|
|
1201
|
+
.replace(/^\/+/, '')
|
|
1202
|
+
const root = DRIVE_FILES()
|
|
1203
|
+
const abs = relative ? P.resolve(root, relative) : root
|
|
1204
|
+
if (abs !== root && !abs.startsWith(root + '/')) {
|
|
1205
|
+
const err = new Error(
|
|
1206
|
+
`Can't reach a path outside the drive: ${driveRelative}`
|
|
1207
|
+
)
|
|
1208
|
+
err.status = 400
|
|
1168
1209
|
throw err
|
|
1169
1210
|
}
|
|
1170
|
-
|
|
1171
|
-
// the host turns a base64 entry back into bytes before it answers with it,
|
|
1172
|
-
// and takes the escape off a text entry. A corrupt entry is refused here, so
|
|
1173
|
-
// the answer is the same error the service gets when it reads the file.
|
|
1174
|
-
return isBinary(contents)
|
|
1175
|
-
? { contents: validateEntry(contents), binary: true }
|
|
1176
|
-
: { contents: entryText(contents), binary: false }
|
|
1211
|
+
return abs
|
|
1177
1212
|
}
|
|
1178
1213
|
|
|
1179
|
-
/** The
|
|
1180
|
-
const
|
|
1214
|
+
/** The drive-relative form of an absolute key, for a message a client reads. */
|
|
1215
|
+
const driveRelative = (abs) => abs.slice(DRIVE_FILES().length) || '/'
|
|
1181
1216
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1217
|
+
/**
|
|
1218
|
+
* The drive as the API package's storage port.
|
|
1219
|
+
*
|
|
1220
|
+
* `@sasjs/api` owns the drive's *rules* - the statuses, the messages, the order
|
|
1221
|
+
* of the checks, whether a path climbs out of the drive - and asks a host for
|
|
1222
|
+
* eight operations over the bytes. This is those eight over the map above, which
|
|
1223
|
+
* is also what a program running in this host sees through `FS`: a file written
|
|
1224
|
+
* by a service and a file uploaded over HTTP are the same entry.
|
|
1225
|
+
*
|
|
1226
|
+
* Paths arrive drive-relative (`data/x.csv`), the way the API names them; the
|
|
1227
|
+
* drive's own root is added here and never appears in an answer.
|
|
1228
|
+
*/
|
|
1229
|
+
const driveStore = {
|
|
1230
|
+
async read(drivePath) {
|
|
1231
|
+
const abs = absDrivePath(drivePath)
|
|
1232
|
+
const stored = FILES.get(abs)
|
|
1233
|
+
if (stored === undefined) {
|
|
1234
|
+
// the API checks existence first, so this is a store that was handed a
|
|
1235
|
+
// path it does not hold - refused in the drive's own words rather than
|
|
1236
|
+
// answering undefined
|
|
1237
|
+
const err = new Error(`File doesn't exist.`)
|
|
1238
|
+
err.status = 404
|
|
1239
|
+
throw err
|
|
1190
1240
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1241
|
+
return isBinary(stored) ? entryBytes(stored) : entryText(stored)
|
|
1242
|
+
},
|
|
1243
|
+
|
|
1244
|
+
async write(drivePath, contents) {
|
|
1245
|
+
const abs = absDrivePath(drivePath)
|
|
1246
|
+
const ancestor = fileAncestorOf(abs)
|
|
1247
|
+
if (ancestor)
|
|
1248
|
+
throw new Error(
|
|
1249
|
+
`ENOTDIR: an ancestor of the path is a file: ${driveRelative(ancestor)}`
|
|
1250
|
+
)
|
|
1251
|
+
mkdirp(P.dirname(abs))
|
|
1252
|
+
storeEntry(abs, contents)
|
|
1253
|
+
},
|
|
1254
|
+
|
|
1255
|
+
async exists(drivePath) {
|
|
1256
|
+
const abs = absDrivePath(drivePath)
|
|
1257
|
+
return FILES.has(abs) || DIRS.has(abs)
|
|
1258
|
+
},
|
|
1259
|
+
|
|
1260
|
+
async stat(drivePath) {
|
|
1261
|
+
const abs = absDrivePath(drivePath)
|
|
1262
|
+
if (DIRS.has(abs)) return { directory: true, size: 0 }
|
|
1263
|
+
if (FILES.has(abs)) return { directory: false, size: entrySizeAt(abs) }
|
|
1264
|
+
const err = new Error(
|
|
1265
|
+
`ENOENT: no such file or directory, stat '${drivePath}'`
|
|
1266
|
+
)
|
|
1267
|
+
err.code = 'ENOENT'
|
|
1268
|
+
throw err
|
|
1269
|
+
},
|
|
1270
|
+
|
|
1271
|
+
async list(drivePath) {
|
|
1272
|
+
const abs = absDrivePath(drivePath)
|
|
1273
|
+
const pre = abs === '/' ? '/' : abs + '/'
|
|
1274
|
+
const files = []
|
|
1275
|
+
const folders = []
|
|
1276
|
+
for (const key of FILES.keys()) {
|
|
1277
|
+
if (!key.startsWith(pre)) continue
|
|
1278
|
+
const rest = key.slice(pre.length)
|
|
1279
|
+
if (rest && !rest.includes('/')) files.push(rest)
|
|
1280
|
+
}
|
|
1281
|
+
for (const dir of DIRS) {
|
|
1282
|
+
if (!dir.startsWith(pre)) continue
|
|
1283
|
+
const rest = dir.slice(pre.length)
|
|
1284
|
+
if (rest && !rest.includes('/')) folders.push(rest)
|
|
1285
|
+
}
|
|
1286
|
+
return { files: files.sort(), folders: folders.sort() }
|
|
1287
|
+
},
|
|
1288
|
+
|
|
1289
|
+
async mkdirp(drivePath) {
|
|
1290
|
+
mkdirp(absDrivePath(drivePath))
|
|
1291
|
+
},
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Removes a file, or a folder with everything under it. The drive is a flat
|
|
1295
|
+
* map of paths, so this is the entries' prefix rather than a directory walk -
|
|
1296
|
+
* and each file it removes is marked dirty (as a deletion) so the page drops
|
|
1297
|
+
* it from IndexedDB too. An empty folder is not represented in the persisted
|
|
1298
|
+
* drive, which is a map of files; see the note on the docs page.
|
|
1299
|
+
*/
|
|
1300
|
+
async remove(drivePath) {
|
|
1301
|
+
const abs = absDrivePath(drivePath)
|
|
1302
|
+
if (FILES.has(abs)) {
|
|
1303
|
+
FILES.delete(abs)
|
|
1304
|
+
SIZES.delete(abs)
|
|
1305
|
+
markDirty(abs)
|
|
1306
|
+
return
|
|
1307
|
+
}
|
|
1308
|
+
const prefix = abs + '/'
|
|
1309
|
+
for (const path of [...FILES.keys()]) {
|
|
1310
|
+
if (!path.startsWith(prefix)) continue
|
|
1311
|
+
FILES.delete(path)
|
|
1312
|
+
SIZES.delete(path)
|
|
1313
|
+
markDirty(path)
|
|
1314
|
+
}
|
|
1315
|
+
for (const dir of [...DIRS]) {
|
|
1316
|
+
if (dir === abs || dir.startsWith(prefix)) DIRS.delete(dir)
|
|
1317
|
+
}
|
|
1318
|
+
},
|
|
1319
|
+
|
|
1320
|
+
/** Moves a file, or a folder with everything under it, and marks both ends. */
|
|
1321
|
+
async move(fromPath, toPath) {
|
|
1322
|
+
const from = absDrivePath(fromPath)
|
|
1323
|
+
const to = absDrivePath(toPath)
|
|
1324
|
+
const moveEntry = (source, target) => {
|
|
1325
|
+
FILES.set(target, FILES.get(source))
|
|
1326
|
+
FILES.delete(source)
|
|
1327
|
+
SIZES.delete(source)
|
|
1328
|
+
mkdirp(P.dirname(target))
|
|
1329
|
+
markDirty(target)
|
|
1330
|
+
markDirty(source)
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (FILES.has(from)) {
|
|
1334
|
+
moveEntry(from, to)
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
if (!DIRS.has(from)) throw new Error(`ENOENT: no such folder '${fromPath}'`)
|
|
1338
|
+
|
|
1339
|
+
const prefix = from + '/'
|
|
1340
|
+
mkdirp(P.dirname(to))
|
|
1341
|
+
for (const dir of [...DIRS]) {
|
|
1342
|
+
if (dir === from || dir.startsWith(prefix)) {
|
|
1343
|
+
DIRS.delete(dir)
|
|
1344
|
+
mkdirp(to + dir.slice(from.length))
|
|
1203
1345
|
}
|
|
1204
1346
|
}
|
|
1205
|
-
|
|
1347
|
+
for (const path of [...FILES.keys()]) {
|
|
1348
|
+
if (path.startsWith(prefix)) moveEntry(path, to + path.slice(from.length))
|
|
1349
|
+
}
|
|
1206
1350
|
}
|
|
1207
|
-
return { status: 'success', tree: walk(`${DRIVE_ROOT}/files`) }
|
|
1208
1351
|
}
|
|
1209
1352
|
|
|
1210
1353
|
// -------------------------------------------------------------------- boot
|
|
@@ -1354,12 +1497,15 @@ const api = {
|
|
|
1354
1497
|
bytes: [...FILES.keys()].reduce((total, abs) => total + entrySizeAt(abs), 0)
|
|
1355
1498
|
}),
|
|
1356
1499
|
run: (payload) => runProgram(payload),
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1500
|
+
// the drive's bytes, for @sasjs/api's services: the rules that decide a
|
|
1501
|
+
// status are the package's, so this host hands over storage and nothing else
|
|
1502
|
+
driveStore,
|
|
1360
1503
|
// the content type a session's header file is seeded with, so the worker can
|
|
1361
1504
|
// tell a default from a declaration rather than carrying a second literal
|
|
1362
1505
|
HEADERS_SEED: SEEDED_HEADERS,
|
|
1506
|
+
// the largest single upload the host accepts, so the worker can refuse a body
|
|
1507
|
+
// before materialising it and the specs and docs read the same number
|
|
1508
|
+
MAX_UPLOAD_BYTES: MAX_UPLOAD_BYTES,
|
|
1363
1509
|
// the format the snapshot download stamps, so persist.js writes the number
|
|
1364
1510
|
// this runtime reads rather than a second literal
|
|
1365
1511
|
DRIVE_FORMAT
|
|
@@ -11,16 +11,34 @@
|
|
|
11
11
|
* receives the changed files back after every run.
|
|
12
12
|
*
|
|
13
13
|
* TRUST MODEL: this is a single-user, no-auth host. It answers every /SASjsApi/
|
|
14
|
-
* path on the site's origin,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* a
|
|
19
|
-
*
|
|
14
|
+
* path on the site's origin, writes to the drive when a drive request names a
|
|
15
|
+
* path, runs whatever program the request names, and runs the code a
|
|
16
|
+
* code/execute request carries - the same contract a SASjs server offers, minus
|
|
17
|
+
* the server. Nothing here is a boundary: a page at this origin can already do
|
|
18
|
+
* all of it, because a visitor's own browser is the client. So a site holding
|
|
19
|
+
* anything private, or one served from a shared origin, belongs behind a real
|
|
20
|
+
* server rather than a static host.
|
|
20
21
|
*/
|
|
21
22
|
importScripts('/host/runtime-core.js')
|
|
23
|
+
importScripts('/host/api-browser.js')
|
|
22
24
|
|
|
23
25
|
const R = self.SASjsMockRuntime
|
|
26
|
+
|
|
27
|
+
// @sasjs/api is the package sasjs/server answers with: the drive's rules - the
|
|
28
|
+
// statuses, the messages, the order of the checks - are its, and this worker is
|
|
29
|
+
// one of its two hosts. `HANDLED_ROUTES` is what it answers today, so the routes
|
|
30
|
+
// below are the ones this host still owns (info, the program routes, session).
|
|
31
|
+
const SASJS_API = self.SASjsApi
|
|
32
|
+
const DRIVE_SERVICES = { drive: SASJS_API.createDriveService(R.driveStore) }
|
|
33
|
+
// The services above are the drive's, so only the drive's operations are
|
|
34
|
+
// dispatched: a route the package adds outside /SASjsApi/drive/ would otherwise
|
|
35
|
+
// be sent to a host that has no service behind it, instead of falling through to
|
|
36
|
+
// the routes below that this worker owns (info, the program routes, session).
|
|
37
|
+
const DRIVE_ROUTES = new Set(
|
|
38
|
+
SASJS_API.HANDLED_ROUTES.filter((id) =>
|
|
39
|
+
id.split(' ')[1].includes('/SASjsApi/drive/')
|
|
40
|
+
)
|
|
41
|
+
)
|
|
24
42
|
const LOGS_SEPARATOR = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
|
25
43
|
|
|
26
44
|
self.addEventListener('install', () => self.skipWaiting())
|
|
@@ -107,7 +125,7 @@ const parseHeaders = (content) => {
|
|
|
107
125
|
* so the value is taken from it rather than repeated here: a byte body that
|
|
108
126
|
* carries only this type is an undeclared download, not a text/html one.
|
|
109
127
|
*/
|
|
110
|
-
const
|
|
128
|
+
const SEEDED_HEADER_LINE = String(R.HEADERS_SEED)
|
|
111
129
|
|
|
112
130
|
/**
|
|
113
131
|
* The reply a run produces: the webout, in the shape and under the name the
|
|
@@ -126,7 +144,7 @@ const weboutResponse = (result, debug) => {
|
|
|
126
144
|
// declares nothing: it keeps the type an undeclared download gets. Compared as
|
|
127
145
|
// the raw text rather than as the parsed value, so a service that declares
|
|
128
146
|
// this same content type itself keeps it - only the untouched seed is dropped.
|
|
129
|
-
if (String(result.headers) ===
|
|
147
|
+
if (String(result.headers) === SEEDED_HEADER_LINE)
|
|
130
148
|
delete headers['content-type']
|
|
131
149
|
if (!headers['content-type'])
|
|
132
150
|
headers['content-type'] = 'application/octet-stream'
|
|
@@ -173,6 +191,128 @@ const readPayload = async (request) => {
|
|
|
173
191
|
return { params: body.params || {}, tables: body.tables || [], json: body }
|
|
174
192
|
}
|
|
175
193
|
|
|
194
|
+
// ---------------------------------------------------------------- requests
|
|
195
|
+
/** A request body as JSON, and {} when it is not JSON - a client that posts a
|
|
196
|
+
* form where the route wants JSON gets the missing-field answer rather than a
|
|
197
|
+
* parse error. */
|
|
198
|
+
const readJson = async (request) => {
|
|
199
|
+
try {
|
|
200
|
+
return (await request.json()) || {}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
return {}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const badRequest = (message) => {
|
|
207
|
+
const error = new Error(message)
|
|
208
|
+
error.status = 400
|
|
209
|
+
return error
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const payloadTooLarge = (limit) => {
|
|
213
|
+
const error = new Error(
|
|
214
|
+
`The upload is larger than the browser host allows (${Math.round(
|
|
215
|
+
limit / (1024 * 1024)
|
|
216
|
+
)} MB).`
|
|
217
|
+
)
|
|
218
|
+
error.status = 413
|
|
219
|
+
return error
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The bytes of an uploaded file, from the multipart field the API names. */
|
|
223
|
+
const uploadBytes = async (request) => {
|
|
224
|
+
const form = await request.formData().catch(() => null)
|
|
225
|
+
const upload = form && form.get('file')
|
|
226
|
+
if (!upload || typeof upload === 'string')
|
|
227
|
+
throw badRequest('"file" is not present.')
|
|
228
|
+
// refused from the File's size, before the body is read: a worker holds the
|
|
229
|
+
// whole payload in memory, so an oversized upload there is an out-of-memory
|
|
230
|
+
// failure rather than an answer a client can act on
|
|
231
|
+
if (typeof upload.size === 'number' && upload.size > R.MAX_UPLOAD_BYTES)
|
|
232
|
+
throw payloadTooLarge(R.MAX_UPLOAD_BYTES)
|
|
233
|
+
const target = form.get('filePath')
|
|
234
|
+
return {
|
|
235
|
+
name: upload.name,
|
|
236
|
+
bytes: new Uint8Array(await upload.arrayBuffer()),
|
|
237
|
+
filePath: target === null ? undefined : target
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The content type `sendFile` would answer with for this path - by extension,
|
|
243
|
+
* because that is what express does, and because a byte entry would otherwise be
|
|
244
|
+
* served as `application/octet-stream` however text-like it is.
|
|
245
|
+
*/
|
|
246
|
+
const CONTENT_TYPES = {
|
|
247
|
+
'.csv': 'text/csv',
|
|
248
|
+
'.json': 'application/json',
|
|
249
|
+
'.sas': 'text/plain',
|
|
250
|
+
'.txt': 'text/plain',
|
|
251
|
+
'.log': 'text/plain',
|
|
252
|
+
'.html': 'text/html',
|
|
253
|
+
'.htm': 'text/html',
|
|
254
|
+
'.js': 'text/javascript',
|
|
255
|
+
'.css': 'text/css',
|
|
256
|
+
'.zip': 'application/zip',
|
|
257
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
258
|
+
'.xls': 'application/vnd.ms-excel',
|
|
259
|
+
'.pdf': 'application/pdf',
|
|
260
|
+
'.png': 'image/png',
|
|
261
|
+
'.jpg': 'image/jpeg',
|
|
262
|
+
'.jpeg': 'image/jpeg',
|
|
263
|
+
'.gif': 'image/gif',
|
|
264
|
+
'.svg': 'image/svg+xml'
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const contentTypeFor = (filePath, binary) => {
|
|
268
|
+
const name = String(filePath || '').toLowerCase()
|
|
269
|
+
const dot = name.lastIndexOf('.')
|
|
270
|
+
const known = dot > 0 ? CONTENT_TYPES[name.slice(dot)] : undefined
|
|
271
|
+
if (known) return known
|
|
272
|
+
return binary ? 'application/octet-stream' : 'text/plain'
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* One drive operation, answered by the package.
|
|
277
|
+
*
|
|
278
|
+
* The transport stays here - a Request is not an ApiRequest, and a multipart body
|
|
279
|
+
* has to be read before anything can look at it - and the decisions are the
|
|
280
|
+
* package's. A write is followed by `persistChanges()` because the worker can be
|
|
281
|
+
* terminated between requests: the answer is only durable once the page has put
|
|
282
|
+
* it in IndexedDB.
|
|
283
|
+
*/
|
|
284
|
+
const driveRoute = async (route, request, query) => {
|
|
285
|
+
const apiRequest = { query }
|
|
286
|
+
const contentType = request.headers.get('content-type') || ''
|
|
287
|
+
|
|
288
|
+
if (request.method === 'POST' || request.method === 'PATCH') {
|
|
289
|
+
if (contentType.includes('multipart/form-data')) {
|
|
290
|
+
const upload = await uploadBytes(request)
|
|
291
|
+
apiRequest.file = { name: upload.name, contents: upload.bytes }
|
|
292
|
+
if (upload.filePath !== undefined)
|
|
293
|
+
apiRequest.body = { filePath: upload.filePath }
|
|
294
|
+
} else {
|
|
295
|
+
// the routes that take their paths as JSON: folder create, rename
|
|
296
|
+
apiRequest.body = await readJson(request)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const answer = await SASJS_API.dispatch(DRIVE_SERVICES, route.id, apiRequest)
|
|
301
|
+
if (request.method !== 'GET') await persistChanges()
|
|
302
|
+
|
|
303
|
+
// the one route that answers with the file itself rather than JSON
|
|
304
|
+
if (answer.contents !== undefined) {
|
|
305
|
+
const binary = typeof answer.contents !== 'string'
|
|
306
|
+
return new Response(answer.contents, {
|
|
307
|
+
status: answer.status,
|
|
308
|
+
headers: {
|
|
309
|
+
'content-type': contentTypeFor(query._filePath, binary)
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
return json(answer.body, answer.status)
|
|
314
|
+
}
|
|
315
|
+
|
|
176
316
|
const handle = async (request, url) => {
|
|
177
317
|
const path = url.pathname.replace(/\/+$/, '')
|
|
178
318
|
const query = {}
|
|
@@ -191,22 +331,12 @@ const handle = async (request, url) => {
|
|
|
191
331
|
})
|
|
192
332
|
}
|
|
193
333
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (path.endsWith('/SASjsApi/drive/file') && request.method === 'GET') {
|
|
203
|
-
const file = R.read(query._filePath || '')
|
|
204
|
-
return file.binary
|
|
205
|
-
? new Response(toBytes(file.contents), {
|
|
206
|
-
headers: { 'content-type': 'application/octet-stream' }
|
|
207
|
-
})
|
|
208
|
-
: text(file.contents)
|
|
209
|
-
}
|
|
334
|
+
// the nine drive operations are the package's, matched off its own table
|
|
335
|
+
// rather than a suffix chain, so a path this host does not own falls through
|
|
336
|
+
// to its routes below instead of being answered twice
|
|
337
|
+
const matched = SASJS_API.findRoute(request.method, path)
|
|
338
|
+
if (matched && DRIVE_ROUTES.has(matched.route.id))
|
|
339
|
+
return await driveRoute(matched.route, request, query)
|
|
210
340
|
|
|
211
341
|
if (
|
|
212
342
|
path.endsWith('/SASjsApi/stp/execute') ||
|
|
@@ -252,6 +382,12 @@ const handle = async (request, url) => {
|
|
|
252
382
|
} catch (e) {
|
|
253
383
|
const message = (e && e.stack) || String(e)
|
|
254
384
|
broadcast({ type: 'sasjs-host-error', path, message })
|
|
385
|
+
// A drive refusal is the message itself, in plain text: the server answers
|
|
386
|
+
// these with `res.status(code).send(err.message)`, and its own suite asserts
|
|
387
|
+
// `res.text`. The other routes answer JSON, because a program failure
|
|
388
|
+
// carries more than a message (`status`, `error`), so they keep the envelope.
|
|
389
|
+
if (path.includes('/SASjsApi/drive/') && e && e.status)
|
|
390
|
+
return text((e && e.message) || String(e), e.status)
|
|
255
391
|
return json(
|
|
256
392
|
{ message: (e && e.message) || String(e) },
|
|
257
393
|
(e && e.status) || 500
|
|
@@ -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;
|
|
@@ -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([
|
|
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) {
|