@sasjs/cli 6.11.0 → 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.
@@ -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. `mkdirp` would
1254
- * happily turn `/data/x.csv` into a folder to hold `/data/x.csv/child`, leaving a
1255
- * path that is a file and has children at the same time - a state the server
1256
- * never reaches, because the filesystem refuses it (ENOTDIR).
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
- const saveDriveFile = (filePath, data) => {
1269
- const abs = drivePath(filePath)
1270
- if (!abs || abs === DRIVE_FILES())
1271
- throw driveError(400, `Can't put file outside drive.`)
1272
- // a folder at the same path would leave one path present as both, which the
1273
- // server fails at the filesystem (EISDIR) rather than reporting as a conflict
1274
- if (FILES.has(abs) || DIRS.has(abs))
1275
- throw driveError(409, 'File already exists.')
1276
- // the server fails this at the filesystem with ENOTDIR and answers a 500; a
1277
- // named refusal is more use to a client than reproducing that, so it is a 400
1278
- const ancestor = fileAncestorOf(abs)
1279
- if (ancestor)
1280
- // the drive-relative path, never the host's absolute one: a client has no
1281
- // business knowing where the drive lives
1282
- throw driveError(
1283
- 400,
1284
- `An ancestor of the path is a file: ${ancestor.slice(DRIVE_FILES().length) || '/'}`
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
- mkdirp(P.dirname(abs))
1287
- storeEntry(abs, data)
1288
- return { status: 'success' }
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
- const addDriveFolder = (folderPath) => {
1312
- const abs = drivePath(folderPath)
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
- * Deletes a folder and everything under it. The drive is a flat map of paths, so
1323
- * this is the entries' prefix rather than a directory walk - and each file it
1324
- * removes is marked dirty (as a deletion) so the page drops it from IndexedDB
1325
- * too. An empty folder is not represented in the persisted drive, which is a
1326
- * map of files; see the note on the docs page.
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 removeDriveFolder = (folderPath) => {
1329
- const abs = drivePath(folderPath)
1330
- if (!abs || abs === DRIVE_FILES())
1331
- throw driveError(400, `Can't delete folder outside drive.`)
1332
- if (!DIRS.has(abs)) throw driveError(404, `Folder doesn't exist.`)
1333
- const prefix = abs + '/'
1334
- for (const path of [...FILES.keys()]) {
1335
- if (!path.startsWith(prefix)) continue
1336
- FILES.delete(path)
1337
- SIZES.delete(path)
1338
- markDirty(path)
1339
- }
1340
- for (const dir of [...DIRS]) {
1341
- if (dir === abs || dir.startsWith(prefix)) DIRS.delete(dir)
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
- /** Moves one stored file, leaving both paths for the page to persist. */
1347
- const moveEntry = (from, to) => {
1348
- FILES.set(to, FILES.get(from))
1349
- FILES.delete(from)
1350
- SIZES.delete(from)
1351
- SIZES.delete(to)
1352
- markDirty(to)
1353
- markDirty(from)
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
- list: (folder) => listFolder(folder),
1571
- read: (file) => readDriveFile(file),
1572
- // the write half of the drive API: each one answers the server's success body
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 SEEDED_HEADERS = String(R.HEADERS_SEED)
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) === SEEDED_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
- * Runs one drive mutation and then hands the page the entries it changed. The
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
- * Await it at the call site: a bare `return driveResponse(...)` hands the
285
- * promise back through handle's try without running its catch, so a refused
286
- * write would reach the page as an unhandled rejection instead of its status.
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 driveResponse = async (runner) => {
289
- const result = runner()
290
- await persistChanges()
291
- return json(result)
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
- if (path.endsWith('/SASjsApi/drive/folder')) {
313
- if (request.method === 'GET')
314
- return json(await R.list(query._folderPath || ''))
315
-
316
- if (request.method === 'POST') {
317
- const body = await readJson(request)
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') ||