@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.
@@ -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 listFolder = (folderPath) => {
1124
- const abs = folderPath
1125
- ? P.resolve(
1126
- DRIVE_APP,
1127
- String(folderPath)
1128
- .replace(/^\/+/, '')
1129
- .replace(new RegExp('^' + APP_LOC + '/'), '')
1130
- )
1131
- : DRIVE_APP
1132
- const alt = folderPath
1133
- ? P.resolve(DRIVE_ROOT, 'files', String(folderPath).replace(/^\/+/, ''))
1134
- : DRIVE_APP
1135
- const target = DIRS.has(abs) ? abs : alt
1136
- if (!DIRS.has(target) && !FILES.has(target)) {
1137
- const err = new Error(`Folder doesn't exist.`)
1138
- err.status = 404
1139
- throw err
1140
- }
1141
- const pre = target === '/' ? '/' : target + '/'
1142
- const files = []
1143
- const folders = []
1144
- for (const k of FILES.keys()) {
1145
- if (k.startsWith(pre)) {
1146
- const rest = k.slice(pre.length)
1147
- if (!rest.includes('/')) files.push(rest)
1148
- }
1149
- }
1150
- for (const d of DIRS) {
1151
- if (d.startsWith(pre)) {
1152
- const rest = d.slice(pre.length)
1153
- if (rest && !rest.includes('/')) folders.push(rest)
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 { files: files.sort(), folders: folders.sort() }
1184
+ return null
1157
1185
  }
1158
1186
 
1159
- const readDriveFile = (filePath) => {
1160
- const abs = P.resolve(
1161
- DRIVE_ROOT,
1162
- 'files',
1163
- String(filePath).replace(/^\/+/, '')
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
- if (!FILES.has(abs)) {
1166
- const err = new Error('File not found')
1167
- err.status = 404
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
- const contents = FILES.get(abs)
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 stored base64 of a byte entry, once it is known to decode. */
1180
- const validateEntry = (contents) => cleanedBase64(contents)
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
- const buildTree = () => {
1183
- const walk = (dir) => {
1184
- const node = {
1185
- name: P.basename(dir) || 'files',
1186
- relativePath: dir.slice(DRIVE_ROOT.length + '/files'.length),
1187
- absolutePath: dir,
1188
- isFolder: true,
1189
- children: []
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
- node.relativePath = node.relativePath === '' ? '' : node.relativePath
1192
- for (const child of FS.readdirSync(dir)) {
1193
- const childAbs = `${dir}/${child}`
1194
- if (DIRS.has(childAbs)) node.children.push(walk(childAbs))
1195
- else {
1196
- node.children.push({
1197
- name: child,
1198
- relativePath: childAbs.slice(DRIVE_ROOT.length + '/files'.length),
1199
- absolutePath: childAbs,
1200
- isFolder: false,
1201
- children: []
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
- return node
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
- list: (folder) => listFolder(folder),
1358
- read: (file) => readDriveFile(file),
1359
- 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,
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, runs whatever program the request names, and runs
15
- * the code a code/execute request carries - the same contract a SASjs server
16
- * offers, minus the server. Nothing here is a boundary: a page at this origin
17
- * can already do all of it, because a visitor's own browser is the client. So
18
- * a site holding anything private, or one served from a shared origin, belongs
19
- * behind a real server rather than a static host.
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 SEEDED_HEADERS = String(R.HEADERS_SEED)
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) === SEEDED_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
- if (path.endsWith('/SASjsApi/drive/folder')) {
195
- return json(await R.list(query._folderPath || ''))
196
- }
197
-
198
- if (path.endsWith('/SASjsApi/drive/filetree')) {
199
- return json(R.tree())
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(['runtime-core.js', 'persist.js', 'intercept.js', 'webout-prelude.json']
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) {