@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.
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +163 -241
- package/build/commands/build/internal/mockSite/assets/sw.js +62 -107
- package/build/commands/build/internal/mockSite/mockSite.js +35 -3
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +125 -2
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +380 -159
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +240 -20
- 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
|
|
@@ -1130,101 +1139,8 @@ return _webout;
|
|
|
1130
1139
|
}
|
|
1131
1140
|
}
|
|
1132
1141
|
|
|
1133
|
-
const listFolder = (folderPath) => {
|
|
1134
|
-
const abs = folderPath
|
|
1135
|
-
? P.resolve(
|
|
1136
|
-
DRIVE_APP,
|
|
1137
|
-
String(folderPath)
|
|
1138
|
-
.replace(/^\/+/, '')
|
|
1139
|
-
.replace(new RegExp('^' + APP_LOC + '/'), '')
|
|
1140
|
-
)
|
|
1141
|
-
: DRIVE_APP
|
|
1142
|
-
const alt = folderPath
|
|
1143
|
-
? P.resolve(DRIVE_ROOT, 'files', String(folderPath).replace(/^\/+/, ''))
|
|
1144
|
-
: DRIVE_APP
|
|
1145
|
-
const target = DIRS.has(abs) ? abs : alt
|
|
1146
|
-
if (!DIRS.has(target) && !FILES.has(target)) {
|
|
1147
|
-
const err = new Error(`Folder doesn't exist.`)
|
|
1148
|
-
err.status = 404
|
|
1149
|
-
throw err
|
|
1150
|
-
}
|
|
1151
|
-
const pre = target === '/' ? '/' : target + '/'
|
|
1152
|
-
const files = []
|
|
1153
|
-
const folders = []
|
|
1154
|
-
for (const k of FILES.keys()) {
|
|
1155
|
-
if (k.startsWith(pre)) {
|
|
1156
|
-
const rest = k.slice(pre.length)
|
|
1157
|
-
if (!rest.includes('/')) files.push(rest)
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
for (const d of DIRS) {
|
|
1161
|
-
if (d.startsWith(pre)) {
|
|
1162
|
-
const rest = d.slice(pre.length)
|
|
1163
|
-
if (rest && !rest.includes('/')) folders.push(rest)
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
return { files: files.sort(), folders: folders.sort() }
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
const readDriveFile = (filePath) => {
|
|
1170
|
-
const abs = P.resolve(
|
|
1171
|
-
DRIVE_ROOT,
|
|
1172
|
-
'files',
|
|
1173
|
-
String(filePath).replace(/^\/+/, '')
|
|
1174
|
-
)
|
|
1175
|
-
if (!FILES.has(abs)) {
|
|
1176
|
-
// the server's wording, because a client prints this: GET /drive/file
|
|
1177
|
-
// answers `res.status(404).send(err.message)`
|
|
1178
|
-
const err = new Error(`File doesn't exist.`)
|
|
1179
|
-
err.status = 404
|
|
1180
|
-
throw err
|
|
1181
|
-
}
|
|
1182
|
-
const contents = FILES.get(abs)
|
|
1183
|
-
// the host turns a base64 entry back into bytes before it answers with it,
|
|
1184
|
-
// and takes the escape off a text entry. A corrupt entry is refused here, so
|
|
1185
|
-
// the answer is the same error the service gets when it reads the file.
|
|
1186
|
-
return isBinary(contents)
|
|
1187
|
-
? { contents: validateEntry(contents), binary: true }
|
|
1188
|
-
: { contents: entryText(contents), binary: false }
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
/** The stored base64 of a byte entry, once it is known to decode. */
|
|
1192
|
-
const validateEntry = (contents) => cleanedBase64(contents)
|
|
1193
|
-
|
|
1194
|
-
// ------------------------------------------------------------- drive writes
|
|
1195
|
-
// The /drive endpoints answer the same contract sasjs/server serves, so a client
|
|
1196
|
-
// that uploads, renames or deletes needs no change for a mock build - only the
|
|
1197
|
-
// storage differs (a Map here, a folder on the server). Every mutation marks the
|
|
1198
|
-
// entry dirty, which is how the page learns to persist it: the worker can be
|
|
1199
|
-
// terminated between requests, so a write it did not hand back would live only
|
|
1200
|
-
// until the next reload.
|
|
1201
|
-
|
|
1202
1142
|
const DRIVE_FILES = () => `${DRIVE_ROOT}/files`
|
|
1203
1143
|
|
|
1204
|
-
const driveError = (status, message) => {
|
|
1205
|
-
const error = new Error(message)
|
|
1206
|
-
error.status = status
|
|
1207
|
-
return error
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
/**
|
|
1211
|
-
* A caller-supplied path as the absolute path under the drive root, or null when
|
|
1212
|
-
* it lands outside - the rule sasjs/server applies in resolveWithinDrive. A
|
|
1213
|
-
* leading slash means "under the drive root", never filesystem absolute, and the
|
|
1214
|
-
* test is on the whole normalised path rather than a substring: `/drive/filesx`
|
|
1215
|
-
* is not inside `/drive/files`.
|
|
1216
|
-
*/
|
|
1217
|
-
const drivePath = (unsafePath) => {
|
|
1218
|
-
const root = DRIVE_FILES()
|
|
1219
|
-
const normalised = String(
|
|
1220
|
-
unsafePath === undefined || unsafePath === null ? '' : unsafePath
|
|
1221
|
-
)
|
|
1222
|
-
.replace(/\\/g, '/')
|
|
1223
|
-
.replace(/^\/+/, '')
|
|
1224
|
-
const resolved = P.resolve(root, normalised)
|
|
1225
|
-
return resolved === root || resolved.startsWith(root + '/') ? resolved : null
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
1144
|
/**
|
|
1229
1145
|
* Writes one entry: bytes stay bytes (behind the base64 marker the drive reads
|
|
1230
1146
|
* them back from), a string is text, and the entry is marked for persistence.
|
|
@@ -1250,10 +1166,13 @@ const storeEntry = (abs, data) => {
|
|
|
1250
1166
|
}
|
|
1251
1167
|
|
|
1252
1168
|
/**
|
|
1253
|
-
* The ancestor of a path that is itself a file, if there is one.
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
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.
|
|
1257
1176
|
*/
|
|
1258
1177
|
const fileAncestorOf = (abs) => {
|
|
1259
1178
|
const parts = abs.split('/').filter(Boolean)
|
|
@@ -1265,108 +1184,158 @@ const fileAncestorOf = (abs) => {
|
|
|
1265
1184
|
return null
|
|
1266
1185
|
}
|
|
1267
1186
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
|
1199
|
+
)
|
|
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}`
|
|
1285
1207
|
)
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
const updateDriveFile = (filePath, data) => {
|
|
1292
|
-
const abs = drivePath(filePath)
|
|
1293
|
-
if (!abs || abs === DRIVE_FILES())
|
|
1294
|
-
throw driveError(400, `Can't modify file outside drive.`)
|
|
1295
|
-
if (!FILES.has(abs)) throw driveError(404, `File doesn't exist.`)
|
|
1296
|
-
storeEntry(abs, data)
|
|
1297
|
-
return { status: 'success' }
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
const removeDriveFile = (filePath) => {
|
|
1301
|
-
const abs = drivePath(filePath)
|
|
1302
|
-
if (!abs || abs === DRIVE_FILES())
|
|
1303
|
-
throw driveError(400, `Can't delete file outside drive.`)
|
|
1304
|
-
if (!FILES.has(abs)) throw driveError(404, `File doesn't exist.`)
|
|
1305
|
-
FILES.delete(abs)
|
|
1306
|
-
SIZES.delete(abs)
|
|
1307
|
-
markDirty(abs)
|
|
1308
|
-
return { status: 'success' }
|
|
1208
|
+
err.status = 400
|
|
1209
|
+
throw err
|
|
1210
|
+
}
|
|
1211
|
+
return abs
|
|
1309
1212
|
}
|
|
1310
1213
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
if (!abs || abs === DRIVE_FILES())
|
|
1314
|
-
throw driveError(400, `Can't put folder outside drive.`)
|
|
1315
|
-
if (DIRS.has(abs) || FILES.has(abs))
|
|
1316
|
-
throw driveError(409, 'Folder already exists.')
|
|
1317
|
-
mkdirp(abs)
|
|
1318
|
-
return { status: 'success' }
|
|
1319
|
-
}
|
|
1214
|
+
/** The drive-relative form of an absolute key, for a message a client reads. */
|
|
1215
|
+
const driveRelative = (abs) => abs.slice(DRIVE_FILES().length) || '/'
|
|
1320
1216
|
|
|
1321
1217
|
/**
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
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.
|
|
1327
1228
|
*/
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
}
|
|
1343
|
-
return { status: 'success' }
|
|
1344
|
-
}
|
|
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
|
|
1240
|
+
}
|
|
1241
|
+
return isBinary(stored) ? entryBytes(stored) : entryText(stored)
|
|
1242
|
+
},
|
|
1345
1243
|
|
|
1346
|
-
|
|
1347
|
-
const
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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}'`)
|
|
1355
1338
|
|
|
1356
|
-
const renameDriveEntry = (oldPath, newPath) => {
|
|
1357
|
-
const from = drivePath(oldPath)
|
|
1358
|
-
if (!from || from === DRIVE_FILES())
|
|
1359
|
-
throw driveError(400, `Old path can't be outside of drive.`)
|
|
1360
|
-
const to = drivePath(newPath)
|
|
1361
|
-
if (!to || to === DRIVE_FILES())
|
|
1362
|
-
throw driveError(400, `New path can't be outside of drive.`)
|
|
1363
|
-
|
|
1364
|
-
if (DIRS.has(from)) {
|
|
1365
|
-
// the server fails this at the filesystem; naming it here says why
|
|
1366
|
-
if (to.startsWith(from + '/'))
|
|
1367
|
-
throw driveError(400, `New path can't be inside the folder being moved.`)
|
|
1368
|
-
if (DIRS.has(to) || FILES.has(to))
|
|
1369
|
-
throw driveError(409, 'Folder with new name already exists.')
|
|
1370
1339
|
const prefix = from + '/'
|
|
1371
1340
|
mkdirp(P.dirname(to))
|
|
1372
1341
|
for (const dir of [...DIRS]) {
|
|
@@ -1378,46 +1347,7 @@ const renameDriveEntry = (oldPath, newPath) => {
|
|
|
1378
1347
|
for (const path of [...FILES.keys()]) {
|
|
1379
1348
|
if (path.startsWith(prefix)) moveEntry(path, to + path.slice(from.length))
|
|
1380
1349
|
}
|
|
1381
|
-
return { status: 'success' }
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
if (FILES.has(from)) {
|
|
1385
|
-
if (FILES.has(to) || DIRS.has(to))
|
|
1386
|
-
throw driveError(409, 'File with new name already exists.')
|
|
1387
|
-
mkdirp(P.dirname(to))
|
|
1388
|
-
moveEntry(from, to)
|
|
1389
|
-
return { status: 'success' }
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
throw driveError(404, 'No file/folder found for provided path.')
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
const buildTree = () => {
|
|
1396
|
-
const walk = (dir) => {
|
|
1397
|
-
const node = {
|
|
1398
|
-
name: P.basename(dir) || 'files',
|
|
1399
|
-
relativePath: dir.slice(DRIVE_ROOT.length + '/files'.length),
|
|
1400
|
-
absolutePath: dir,
|
|
1401
|
-
isFolder: true,
|
|
1402
|
-
children: []
|
|
1403
|
-
}
|
|
1404
|
-
node.relativePath = node.relativePath === '' ? '' : node.relativePath
|
|
1405
|
-
for (const child of FS.readdirSync(dir)) {
|
|
1406
|
-
const childAbs = `${dir}/${child}`
|
|
1407
|
-
if (DIRS.has(childAbs)) node.children.push(walk(childAbs))
|
|
1408
|
-
else {
|
|
1409
|
-
node.children.push({
|
|
1410
|
-
name: child,
|
|
1411
|
-
relativePath: childAbs.slice(DRIVE_ROOT.length + '/files'.length),
|
|
1412
|
-
absolutePath: childAbs,
|
|
1413
|
-
isFolder: false,
|
|
1414
|
-
children: []
|
|
1415
|
-
})
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
return node
|
|
1419
1350
|
}
|
|
1420
|
-
return { status: 'success', tree: walk(`${DRIVE_ROOT}/files`) }
|
|
1421
1351
|
}
|
|
1422
1352
|
|
|
1423
1353
|
// -------------------------------------------------------------------- boot
|
|
@@ -1567,17 +1497,9 @@ const api = {
|
|
|
1567
1497
|
bytes: [...FILES.keys()].reduce((total, abs) => total + entrySizeAt(abs), 0)
|
|
1568
1498
|
}),
|
|
1569
1499
|
run: (payload) => runProgram(payload),
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
// or throws an error carrying the server's status and message
|
|
1574
|
-
save: (filePath, data) => saveDriveFile(filePath, data),
|
|
1575
|
-
update: (filePath, data) => updateDriveFile(filePath, data),
|
|
1576
|
-
remove: (filePath) => removeDriveFile(filePath),
|
|
1577
|
-
mkdir: (folderPath) => addDriveFolder(folderPath),
|
|
1578
|
-
rmdir: (folderPath) => removeDriveFolder(folderPath),
|
|
1579
|
-
rename: (oldPath, newPath) => renameDriveEntry(oldPath, newPath),
|
|
1580
|
-
tree: () => buildTree(),
|
|
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,
|
|
1581
1503
|
// the content type a session's header file is seeded with, so the worker can
|
|
1582
1504
|
// tell a default from a declaration rather than carrying a second literal
|
|
1583
1505
|
HEADERS_SEED: SEEDED_HEADERS,
|
|
@@ -20,8 +20,25 @@
|
|
|
20
20
|
* server rather than a static host.
|
|
21
21
|
*/
|
|
22
22
|
importScripts('/host/runtime-core.js')
|
|
23
|
+
importScripts('/host/api-browser.js')
|
|
23
24
|
|
|
24
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
|
+
)
|
|
25
42
|
const LOGS_SEPARATOR = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
|
26
43
|
|
|
27
44
|
self.addEventListener('install', () => self.skipWaiting())
|
|
@@ -108,7 +125,7 @@ const parseHeaders = (content) => {
|
|
|
108
125
|
* so the value is taken from it rather than repeated here: a byte body that
|
|
109
126
|
* carries only this type is an undeclared download, not a text/html one.
|
|
110
127
|
*/
|
|
111
|
-
const
|
|
128
|
+
const SEEDED_HEADER_LINE = String(R.HEADERS_SEED)
|
|
112
129
|
|
|
113
130
|
/**
|
|
114
131
|
* The reply a run produces: the webout, in the shape and under the name the
|
|
@@ -127,7 +144,7 @@ const weboutResponse = (result, debug) => {
|
|
|
127
144
|
// declares nothing: it keeps the type an undeclared download gets. Compared as
|
|
128
145
|
// the raw text rather than as the parsed value, so a service that declares
|
|
129
146
|
// this same content type itself keeps it - only the untouched seed is dropped.
|
|
130
|
-
if (String(result.headers) ===
|
|
147
|
+
if (String(result.headers) === SEEDED_HEADER_LINE)
|
|
131
148
|
delete headers['content-type']
|
|
132
149
|
if (!headers['content-type'])
|
|
133
150
|
headers['content-type'] = 'application/octet-stream'
|
|
@@ -202,28 +219,6 @@ const payloadTooLarge = (limit) => {
|
|
|
202
219
|
return error
|
|
203
220
|
}
|
|
204
221
|
|
|
205
|
-
/**
|
|
206
|
-
* The value a route needs, or the 400 the server answers with when it is absent.
|
|
207
|
-
* The message is Joi's, because that is what a client sees from the real API.
|
|
208
|
-
*/
|
|
209
|
-
const required = (value, name) => {
|
|
210
|
-
if (typeof value === 'string' && value.length) return value
|
|
211
|
-
throw badRequest(`"${name}" is required`)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* sasjs/server refuses a file path with one of these extensions (filePathSchema
|
|
216
|
-
* in api/src/utils/validation.ts). Nothing is executable on a static host, but a
|
|
217
|
-
* client that would be refused on deploy should hear it here too.
|
|
218
|
-
*/
|
|
219
|
-
const BLOCKED_FILE = /\.(exe|sh|htaccess)$/i
|
|
220
|
-
|
|
221
|
-
const fileTarget = (value, name) => {
|
|
222
|
-
const given = required(value, name)
|
|
223
|
-
if (BLOCKED_FILE.test(given)) throw badRequest('Invalid file extension')
|
|
224
|
-
return given
|
|
225
|
-
}
|
|
226
|
-
|
|
227
222
|
/** The bytes of an uploaded file, from the multipart field the API names. */
|
|
228
223
|
const uploadBytes = async (request) => {
|
|
229
224
|
const form = await request.formData().catch(() => null)
|
|
@@ -237,6 +232,7 @@ const uploadBytes = async (request) => {
|
|
|
237
232
|
throw payloadTooLarge(R.MAX_UPLOAD_BYTES)
|
|
238
233
|
const target = form.get('filePath')
|
|
239
234
|
return {
|
|
235
|
+
name: upload.name,
|
|
240
236
|
bytes: new Uint8Array(await upload.arrayBuffer()),
|
|
241
237
|
filePath: target === null ? undefined : target
|
|
242
238
|
}
|
|
@@ -277,18 +273,44 @@ const contentTypeFor = (filePath, binary) => {
|
|
|
277
273
|
}
|
|
278
274
|
|
|
279
275
|
/**
|
|
280
|
-
*
|
|
281
|
-
* worker can be terminated between requests, so a write is only durable once the
|
|
282
|
-
* page has put it in IndexedDB.
|
|
276
|
+
* One drive operation, answered by the package.
|
|
283
277
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
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.
|
|
287
283
|
*/
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
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)
|
|
292
314
|
}
|
|
293
315
|
|
|
294
316
|
const handle = async (request, url) => {
|
|
@@ -309,79 +331,12 @@ const handle = async (request, url) => {
|
|
|
309
331
|
})
|
|
310
332
|
}
|
|
311
333
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return await driveResponse(() =>
|
|
319
|
-
R.mkdir(required(body.folderPath, 'folderPath'))
|
|
320
|
-
)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
if (request.method === 'DELETE') {
|
|
324
|
-
// an empty path names the drive root, which the server refuses at
|
|
325
|
-
// validation rather than deleting everything
|
|
326
|
-
const folderPath = required(query._folderPath, '_folderPath')
|
|
327
|
-
return await driveResponse(() => R.rmdir(folderPath))
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (path.endsWith('/SASjsApi/drive/filetree')) {
|
|
332
|
-
return json(R.tree())
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (path.endsWith('/SASjsApi/drive/rename')) {
|
|
336
|
-
if (request.method === 'POST') {
|
|
337
|
-
const body = await readJson(request)
|
|
338
|
-
// both ends go through the extension check: without it a file could be
|
|
339
|
-
// renamed into a blocked name and then be unreachable by DELETE and PATCH,
|
|
340
|
-
// which apply the same check to the path they are given
|
|
341
|
-
const oldPath = fileTarget(body.oldPath, 'oldPath')
|
|
342
|
-
const newPath = fileTarget(body.newPath, 'newPath')
|
|
343
|
-
return await driveResponse(() => R.rename(oldPath, newPath))
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (path.endsWith('/SASjsApi/drive/file')) {
|
|
348
|
-
if (request.method === 'GET') {
|
|
349
|
-
const file = R.read(query._filePath || '')
|
|
350
|
-
// the server answers this with `sendFile`, so the type comes from the
|
|
351
|
-
// extension: a .csv is text/csv whether it was uploaded as bytes or
|
|
352
|
-
// written by a service as text
|
|
353
|
-
return new Response(
|
|
354
|
-
file.binary ? toBytes(file.contents) : file.contents,
|
|
355
|
-
{
|
|
356
|
-
status: 200,
|
|
357
|
-
headers: {
|
|
358
|
-
'content-type': contentTypeFor(query._filePath, file.binary)
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
)
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (request.method === 'DELETE') {
|
|
365
|
-
const filePath = fileTarget(query._filePath, '_filePath')
|
|
366
|
-
return await driveResponse(() => R.remove(filePath))
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// POST creates (409 when the path is taken) and PATCH replaces (404 when
|
|
370
|
-
// it is not there) - the split the server's routes make, so a client that
|
|
371
|
-
// keys on the status code behaves the same way
|
|
372
|
-
if (request.method === 'POST' || request.method === 'PATCH') {
|
|
373
|
-
const upload = await uploadBytes(request)
|
|
374
|
-
const filePath = fileTarget(
|
|
375
|
-
query._filePath || upload.filePath,
|
|
376
|
-
'_filePath'
|
|
377
|
-
)
|
|
378
|
-
return await driveResponse(() =>
|
|
379
|
-
request.method === 'PATCH'
|
|
380
|
-
? R.update(filePath, upload.bytes)
|
|
381
|
-
: R.save(filePath, upload.bytes)
|
|
382
|
-
)
|
|
383
|
-
}
|
|
384
|
-
}
|
|
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)
|
|
385
340
|
|
|
386
341
|
if (
|
|
387
342
|
path.endsWith('/SASjsApi/stp/execute') ||
|