@sasjs/cli 6.13.3 → 6.15.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.
@@ -10,35 +10,53 @@
10
10
  * between the page and its worker. The page hands in the drive at boot and
11
11
  * receives the changed files back after every run.
12
12
  *
13
- * TRUST MODEL: this is a single-user, no-auth host. It answers every /SASjsApi/
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.
13
+ * TRUST MODEL: this is a single-user host on the site's own origin. It answers
14
+ * the API the way a SASjs server does, but there is no boundary behind it: a page
15
+ * at this origin can already reach everything here, because a visitor's browser
16
+ * is the client. The identity is the package's demo fixture (@sasjs/api's
17
+ * `DEFAULT_IDENTITY`: `sasjsadmin` and `sasjsuser`, both `Orion123`), and a
18
+ * request that carries no credential is read as the fixture administrator - the
19
+ * visitor, on a host with no login wall. A site holding anything private, or one
20
+ * served from a shared origin, belongs behind a real server rather than a static
21
+ * host.
22
+ *
23
+ * The drive is persisted by the page (host/persist.js) and the identity is not:
24
+ * an account, group, permission rule or client created through these routes
25
+ * lives in this worker's memory, so a worker the browser terminates starts again
26
+ * from the fixture. Edits that must survive a restart belong on a server.
21
27
  */
22
28
  importScripts('/host/runtime-core.js')
23
29
  importScripts('/host/api-browser.js')
24
30
 
25
31
  const R = self.SASjsMockRuntime
26
32
 
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
- )
33
+ // @sasjs/api is the package sasjs/server answers with. Every route in its table
34
+ // is answered here from its services; what this file owns is the transport (a
35
+ // Request is not an ApiRequest), the work (a runner over the browser's drive)
36
+ // and the few capabilities a static origin does not have, which the package
37
+ // reports as a 501 naming the missing service.
38
+ const API = self.SASjsApi
39
+
40
+ const DRIVE_STORE = R.driveStore
41
+ const IDENTITY_STORE = new API.MemoryIdentityStore(API.DEFAULT_IDENTITY)
42
+ const IDENTITY = API.createIdentityService(IDENTITY_STORE)
43
+ const RECORDS = API.createRecordService(IDENTITY_STORE)
44
+
45
+ /**
46
+ * The caller a request without a credential is talking as.
47
+ *
48
+ * This host has no login wall - that is its trust model, not an oversight - so
49
+ * a page that does not sign in is not anonymous, it is the visitor. The fixture
50
+ * administrator is the account the demo exists to give a client, and naming it
51
+ * here keeps a mock site usable with no `/SASLogon/login` call. A request that
52
+ * does carry a token is that token's subject, so the sign-in flow, the fixture's
53
+ * two accounts and their permission rules all behave as the package decides.
54
+ */
55
+ const IMPLICIT_CALLER = {
56
+ userId: (API.DEFAULT_IDENTITY.users.find((user) => user.isAdmin) || {}).uid,
57
+ clientId: ((API.DEFAULT_IDENTITY.clients || [])[0] || {}).clientId
58
+ }
59
+
42
60
  const LOGS_SEPARATOR = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
43
61
 
44
62
  self.addEventListener('install', () => self.skipWaiting())
@@ -70,6 +88,18 @@ const toBytes = (base64) => {
70
88
  return out
71
89
  }
72
90
 
91
+ const toBase64 = (bytes) => {
92
+ let binary = ''
93
+ const chunk = 0x8000
94
+ for (let i = 0; i < bytes.length; i += chunk)
95
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))
96
+ return btoa(binary)
97
+ }
98
+
99
+ const decoder = new TextDecoder()
100
+ const toText = (bytes) =>
101
+ typeof bytes === 'string' ? bytes : decoder.decode(bytes)
102
+
73
103
  // ------------------------------------------------------------ webout headers
74
104
  // A service declares the response it wants by writing `Name: value` lines to
75
105
  // the file the preamble hands it as _SASJS_WEBOUT_HEADERS - the same channel
@@ -128,17 +158,32 @@ const parseHeaders = (content) => {
128
158
  const SEEDED_HEADER_LINE = String(R.HEADERS_SEED)
129
159
 
130
160
  /**
131
- * The reply a run produces: the webout, in the shape and under the name the
161
+ * The reply a run produced: the webout, in the shape and under the name the
132
162
  * service asked for. A debug reply is the webout framed with the log, so it
133
163
  * stays text - honouring a download there would put the frame in the file.
164
+ *
165
+ * The headers travel with the artefact rather than being applied here, because
166
+ * both the execute answer and a triggered session's `/output` are that same
167
+ * artefact: one place decides what a service declared, and the transport reads
168
+ * it.
134
169
  */
135
- const weboutResponse = (result, debug) => {
170
+ const artifactOf = (result, debug) => {
136
171
  if (debug)
137
- return text(
138
- [result.webout, LOGS_SEPARATOR, result.log, LOGS_SEPARATOR].join('\n')
139
- )
172
+ return {
173
+ contents: [
174
+ result.webout,
175
+ LOGS_SEPARATOR,
176
+ result.log,
177
+ LOGS_SEPARATOR
178
+ ].join('\n'),
179
+ // the frame is text, and the service's own type and disposition are not
180
+ // applied to it - a download there would put the frame in the file
181
+ headers: { 'content-type': 'text/plain' },
182
+ log: result.log
183
+ }
140
184
 
141
185
  const headers = parseHeaders(result.headers)
186
+ let contents = result.webout
142
187
  if (result.binary) {
143
188
  // the seed is a default, so a byte body that leaves the seeded file untouched
144
189
  // declares nothing: it keeps the type an undeclared download gets. Compared as
@@ -148,11 +193,20 @@ const weboutResponse = (result, debug) => {
148
193
  delete headers['content-type']
149
194
  if (!headers['content-type'])
150
195
  headers['content-type'] = 'application/octet-stream'
151
- return new Response(toBytes(result.webout), { status: 200, headers })
196
+ contents = toBytes(result.webout)
152
197
  }
153
- return new Response(result.webout, { status: 200, headers })
198
+ return { contents, headers, log: result.log }
154
199
  }
155
200
 
201
+ /** The response an artefact becomes, with the headers its service declared. */
202
+ const artifactResponse = (artifact) =>
203
+ new Response(
204
+ artifact.contents === undefined || artifact.contents === null
205
+ ? ''
206
+ : artifact.contents,
207
+ { status: 200, headers: artifact.headers || {} }
208
+ )
209
+
156
210
  const broadcast = async (message) => {
157
211
  const clients = await self.clients.matchAll({ includeUncontrolled: true })
158
212
  clients.forEach((client) => client.postMessage(message))
@@ -164,33 +218,122 @@ const persistChanges = async () => {
164
218
  await broadcast({ type: 'sasjs-persist', files })
165
219
  }
166
220
 
167
- const readPayload = async (request) => {
168
- if (request.method === 'GET' || request.method === 'HEAD')
169
- return { params: {}, tables: [] }
170
- const type = request.headers.get('content-type') || ''
171
- if (!type.includes('application/json')) {
172
- try {
173
- const form = await request.formData()
174
- const params = {}
175
- const tables = []
176
- for (const [k, v] of form.entries()) {
177
- if (typeof v === 'string') params[k] = v
178
- else
179
- tables.push({
180
- name: k,
181
- filename: v.name || k + '.csv',
182
- csv: await v.text()
183
- })
221
+ // ---------------------------------------------------------------- the runner
222
+ /**
223
+ * The work a run does, over the browser's drive.
224
+ *
225
+ * `@sasjs/api` decides everything a client observes about a run - which
226
+ * program, which run time, who may start it, the session record - and this
227
+ * performs it. It is also the host's session store, because a session the
228
+ * trigger route created has to be one the session routes can read: two maps
229
+ * behind two ports would let a trigger write a record nothing can poll.
230
+ */
231
+ class BrowserRuntime extends API.MemorySessionHost {
232
+ constructor() {
233
+ // no fixture sessions: every session this host reports was run here
234
+ super({}, {}, {}, {})
235
+ }
236
+
237
+ async run(request) {
238
+ const program = request.program || {}
239
+ const params = Object.assign({}, request.vars || {})
240
+ const payload = { params, tables: tablesOf(request.files) }
241
+ if (program.code !== undefined) payload.code = program.code
242
+ else payload.program = program.path
243
+
244
+ const result = R.run(payload)
245
+ if (result.error) throw runFailure(result.error)
246
+
247
+ const artifact = artifactOf(result, params._debug !== undefined)
248
+ const session = request.session
249
+ if (session) {
250
+ const stamp = new Date().toISOString()
251
+ const summary = {
252
+ sessionId: session.sessionId,
253
+ state: 'completed',
254
+ creationTimeStamp: stamp,
255
+ deathTimeStamp: stamp
184
256
  }
185
- return { params, tables }
186
- } catch (e) {
187
- return { params: {}, tables: [] }
257
+ if (session.expiresAfterMins !== undefined)
258
+ summary.expiresAfterMins = {
259
+ mins: session.expiresAfterMins,
260
+ used: false
261
+ }
262
+ this.sessions[session.sessionId] = { owner: session.owner, summary }
263
+ this.states[session.sessionId] = { state: 'completed' }
264
+ this.logs[session.sessionId] = artifact.log || ''
265
+ this.outputs[session.sessionId] = artifact
188
266
  }
267
+ return { output: artifact }
189
268
  }
190
- const body = await request.json()
191
- return { params: body.params || {}, tables: body.tables || [], json: body }
192
269
  }
193
270
 
271
+ /**
272
+ * A run that failed, as the service stringifies it.
273
+ *
274
+ * The runtime catches whatever the runner throws and answers its documented
275
+ * `Job execution failed.` envelope with `String(error)`, so the message a
276
+ * service's stack carries has to survive that call unchanged - a wrapper Error
277
+ * would add its own prefix to a stack that already has one.
278
+ */
279
+ const runFailure = (message) => {
280
+ const error = new Error(message)
281
+ error.toString = () => message
282
+ return error
283
+ }
284
+
285
+ /**
286
+ * The multipart files a request carried, as the runtime's `_WEBIN_*` tables.
287
+ *
288
+ * The field name is the table's name and the uploaded file's name is its
289
+ * filename, which is what a stored program reads (`_WEBIN_NAME1`,
290
+ * `_WEBIN_FILENAME1`, `_WEBIN_FILEREF1`). The package's transport carries the
291
+ * bytes; the names are this host's, because they are the form the browser sent.
292
+ */
293
+ const tablesOf = (files) => {
294
+ if (!files) return []
295
+ const list = Array.isArray(files)
296
+ ? files
297
+ : Object.keys(files).map((name) => ({ name, contents: files[name] }))
298
+ return list.map((file, index) => ({
299
+ name: file.name || `file${index + 1}`,
300
+ filename: file.filename || file.name || `file${index + 1}`,
301
+ csv: toText(file.csv !== undefined ? file.csv : file.contents)
302
+ }))
303
+ }
304
+
305
+ const RUNTIME = new BrowserRuntime()
306
+
307
+ const SERVICES = {
308
+ drive: API.createDriveService(DRIVE_STORE),
309
+ info: API.createInfoService(API.BROWSER_INFO),
310
+ identity: IDENTITY,
311
+ records: RECORDS,
312
+ runtime: API.createRuntimeService(IDENTITY_STORE, RUNTIME, {
313
+ // the one run time a browser holds; a stored program asks for another and
314
+ // is refused with the package's own message
315
+ runTimes: ['js']
316
+ }),
317
+ session: API.createSessionService(IDENTITY_STORE, RUNTIME),
318
+ authConfig: API.createAuthConfigService({})
319
+ // `admin`, `directory`, `code` and `home` are deliberately absent: this host
320
+ // has no process to report on, no LDAP connection to import from, and the
321
+ // linter is a node-only dependency. The package answers a 501 naming the
322
+ // capability rather than a 404 that reads like a typo in the caller.
323
+ }
324
+
325
+ /** The routes this host performs itself, because the answer is not JSON. */
326
+ const RUN_ROUTES = new Set([
327
+ 'GET /SASjsApi/stp/execute',
328
+ 'POST /SASjsApi/stp/execute',
329
+ 'POST /SASjsApi/stp/trigger',
330
+ 'POST /SASjsApi/code/execute',
331
+ 'POST /SASjsApi/code/trigger'
332
+ ])
333
+
334
+ const SESSION_OUTPUT = 'GET /SASjsApi/session/{sessionId}/output'
335
+ const DEPLOY_UPLOAD = 'POST /SASjsApi/drive/deploy/upload'
336
+
194
337
  // ---------------------------------------------------------------- requests
195
338
  /** A request body as JSON, and {} when it is not JSON - a client that posts a
196
339
  * form where the route wants JSON gets the missing-field answer rather than a
@@ -204,38 +347,103 @@ const readJson = async (request) => {
204
347
  }
205
348
 
206
349
  const badRequest = (message) => {
207
- const error = new Error(message)
208
- error.status = 400
350
+ const error = new API.ApiError(400, message)
209
351
  return error
210
352
  }
211
353
 
212
354
  const payloadTooLarge = (limit) => {
213
- const error = new Error(
355
+ return new API.ApiError(
356
+ 413,
214
357
  `The upload is larger than the browser host allows (${Math.round(
215
358
  limit / (1024 * 1024)
216
359
  )} MB).`
217
360
  )
218
- error.status = 413
219
- return error
220
361
  }
221
362
 
222
- /** The bytes of an uploaded file, from the multipart field the API names. */
223
- const uploadBytes = async (request) => {
363
+ /**
364
+ * A JSON body, unwrapped from the envelope the page bridge posts.
365
+ *
366
+ * `host/intercept.js` turns an XHR body into `{ params, tables }` before it
367
+ * reaches the worker: reading a multipart body from an XHR-originated request is
368
+ * unreliable in a worker, so the page - where a FormData is trivial - does it.
369
+ * That envelope is this build's own wire format for page traffic, and a client
370
+ * that posts JSON directly (a `fetch` from the console, a test) sends its fields
371
+ * flat, so both shapes are read.
372
+ */
373
+ const readJsonBody = async (request) => {
374
+ const body = await readJson(request)
375
+ if (
376
+ body &&
377
+ typeof body === 'object' &&
378
+ !Array.isArray(body) &&
379
+ Array.isArray(body.tables) &&
380
+ body.params &&
381
+ typeof body.params === 'object'
382
+ ) {
383
+ return {
384
+ body: body.params,
385
+ files: body.tables.map((table, index) => ({
386
+ name: table.name || `table${index + 1}`,
387
+ filename:
388
+ table.filename || `${table.name || 'table' + (index + 1)}.csv`,
389
+ contents: table.csv === undefined ? '' : table.csv
390
+ }))
391
+ }
392
+ }
393
+ return { body, files: [] }
394
+ }
395
+
396
+ /**
397
+ * A request's payload: the form fields as a body, and the files apart from them.
398
+ *
399
+ * The two have to be separated here rather than in the package: a multipart
400
+ * body is the transport's, and the API names a file by a field the host reads.
401
+ */
402
+ const readPayload = async (request, allowLargeFile = false) => {
403
+ const parsed = { body: {}, files: [] }
404
+ if (request.method === 'GET' || request.method === 'HEAD') return parsed
405
+ const type = request.headers.get('content-type') || ''
406
+ if (!type.includes('multipart/form-data')) return await readJsonBody(request)
224
407
  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
408
+ if (!form) return parsed
409
+ for (const [name, value] of form.entries()) {
410
+ if (typeof value === 'string') {
411
+ parsed.body[name] = value
412
+ continue
413
+ }
414
+ // refused from the File's size, before the body is read: a worker holds the
415
+ // whole payload in memory, so an oversized upload there is an out-of-memory
416
+ // failure rather than an answer a client can act on. The deploy archive is
417
+ // the exception - the package measures the tree it expands to, which is the
418
+ // cap the document states for that route - and it is read here to be
419
+ // expanded before that measurement can run.
420
+ if (
421
+ !allowLargeFile &&
422
+ typeof value.size === 'number' &&
423
+ value.size > R.MAX_UPLOAD_BYTES
424
+ )
425
+ throw payloadTooLarge(R.MAX_UPLOAD_BYTES)
426
+ parsed.files.push({
427
+ name,
428
+ filename: value.name || name,
429
+ contents: new Uint8Array(await value.arrayBuffer())
430
+ })
238
431
  }
432
+ return parsed
433
+ }
434
+
435
+ /**
436
+ * The multipart file the API names `file`, for the routes that take one.
437
+ *
438
+ * Every route that carries a single file names the field `file`; the deploy
439
+ * upload is the exception, because its archive is expanded into a tree before
440
+ * the drive sees it.
441
+ */
442
+ const singleFile = (parsed) => {
443
+ const match =
444
+ parsed.files.find((file) => file.name === 'file') || parsed.files[0]
445
+ if (!match) return undefined
446
+ return { name: match.filename, contents: match.contents }
239
447
  }
240
448
 
241
449
  /**
@@ -272,133 +480,255 @@ const contentTypeFor = (filePath, binary) => {
272
480
  return binary ? 'application/octet-stream' : 'text/plain'
273
481
  }
274
482
 
483
+ // ------------------------------------------------------------- a deployment
275
484
  /**
276
- * One drive operation, answered by the package.
485
+ * The members of a zip archive, as the deploy route's tree.
486
+ *
487
+ * The server's transport is multer plus a zip library; a worker has
488
+ * `DecompressionStream`, so parsing the archive is this host's. Only the two
489
+ * methods a deploy archive uses are read - stored and deflate - and anything
490
+ * else is refused by name rather than half-expanded.
277
491
  *
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.
492
+ * Directory entries are dropped and a folder is derived from the members'
493
+ * names, which is how the tree the server builds from the same archive is
494
+ * shaped.
283
495
  */
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)
496
+ const unzip = async (bytes) => {
497
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
498
+ const u16 = (at) => view.getUint16(at, true)
499
+ const u32 = (at) => view.getUint32(at, true)
500
+
501
+ // the end-of-central-directory record is the last signature in the file, and
502
+ // it sits within 22 + 65535 bytes of the end (a comment is the only tail)
503
+ let eocd = -1
504
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) {
505
+ if (u32(i) === 0x06054b50) {
506
+ eocd = i
507
+ break
297
508
  }
298
509
  }
510
+ if (eocd < 0) throw badRequest('The archive is not a zip file.')
511
+
512
+ const count = u16(eocd + 10)
513
+ let offset = u32(eocd + 16)
514
+
515
+ const entries = []
516
+ for (let n = 0; n < count; n++) {
517
+ if (u32(offset) !== 0x02014b50)
518
+ throw badRequest('The archive is not a zip file.')
519
+ const method = u16(offset + 10)
520
+ const compressedSize = u32(offset + 20)
521
+ const nameLength = u16(offset + 28)
522
+ const extraLength = u16(offset + 30)
523
+ const commentLength = u16(offset + 32)
524
+ const localOffset = u32(offset + 42)
525
+ const name = decoder.decode(
526
+ bytes.subarray(offset + 46, offset + 46 + nameLength)
527
+ )
528
+ offset += 46 + nameLength + extraLength + commentLength
529
+
530
+ if (name.endsWith('/')) continue
531
+ if (u32(localOffset) !== 0x04034b50)
532
+ throw badRequest('The archive is not a zip file.')
533
+ const start =
534
+ localOffset + 30 + u16(localOffset + 26) + u16(localOffset + 28)
535
+ const data = bytes.subarray(start, start + compressedSize)
536
+
537
+ let contents
538
+ if (method === 0) contents = data
539
+ else if (method === 8) {
540
+ const stream = new Blob([data])
541
+ .stream()
542
+ .pipeThrough(new DecompressionStream('deflate-raw'))
543
+ contents = new Uint8Array(await new Response(stream).arrayBuffer())
544
+ } else
545
+ throw badRequest(
546
+ `The archive uses compression method ${method}, which this host does not read.`
547
+ )
548
+ entries.push({ name, contents })
549
+ }
550
+ return entries
551
+ }
299
552
 
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
- }
553
+ /** The nested tree the deploy route writes, from the archive's member names. */
554
+ const deployTree = (entries) => {
555
+ const root = { members: [] }
556
+ const folders = { '': root }
557
+ const folderFor = (path) => {
558
+ if (folders[path]) return folders[path]
559
+ const cut = path.lastIndexOf('/')
560
+ const parent = folderFor(cut < 0 ? '' : path.slice(0, cut))
561
+ const name = cut < 0 ? path : path.slice(cut + 1)
562
+ const folder = { name, type: 'folder', members: [] }
563
+ parent.members.push(folder)
564
+ folders[path] = folder
565
+ return folder
566
+ }
567
+ entries.forEach((entry) => {
568
+ const cut = entry.name.lastIndexOf('/')
569
+ const parent = folderFor(cut < 0 ? '' : entry.name.slice(0, cut))
570
+ parent.members.push({
571
+ name: cut < 0 ? entry.name : entry.name.slice(cut + 1),
572
+ type: 'file',
573
+ code: toBase64(entry.contents)
311
574
  })
575
+ })
576
+ return root
577
+ }
578
+
579
+ // ---------------------------------------------------------------- the routes
580
+ /** The subject a request is talking as, from its bearer token or the implicit
581
+ * caller when it carries none. An unknown token is the package's 401. */
582
+ const subjectOf = async (request, query) => {
583
+ const header = request.headers.get('authorization') || ''
584
+ const token =
585
+ header.replace(/^Bearer\s+/i, '').trim() ||
586
+ String(query.access_token || '').trim() ||
587
+ undefined
588
+ if (!token) return IMPLICIT_CALLER
589
+ const subject = await IDENTITY.authenticate(token)
590
+ if (!subject) throw new API.ApiError(401, 'Unauthorized')
591
+ return subject
592
+ }
593
+
594
+ /**
595
+ * One run, answered by the runtime service.
596
+ *
597
+ * The service is called directly rather than through `dispatch` because a run's
598
+ * answer is the webout *with the headers its service declared*
599
+ * (`content-disposition` is how a service names a download), and the artefact
600
+ * the service returns is where those headers are. Everything a client observes
601
+ * about the request - who may run it, which field names the program, what
602
+ * `_debug` means - is still the package's decision.
603
+ */
604
+ const runRoute = async (routeId, payload, subject) => {
605
+ const query = payload.query
606
+ const body = payload.body
607
+ const tables = payload.files.map((file) => ({
608
+ name: file.name,
609
+ filename: file.filename,
610
+ csv: toText(file.contents)
611
+ }))
612
+ const code =
613
+ routeId.endsWith('/code/execute') || routeId.endsWith('/code/trigger')
614
+ const trigger = routeId.endsWith('/trigger')
615
+ const vars = Object.assign({}, query, body)
616
+
617
+ const runRequest = {
618
+ source: code ? 'code' : 'program',
619
+ vars,
620
+ files: tables
312
621
  }
313
- return json(answer.body, answer.status)
622
+ if (code) {
623
+ runRequest.code = body.code
624
+ runRequest.runTime = body.runTime
625
+ } else {
626
+ // the query wins over the body, which is the order the server resolves them in
627
+ runRequest.program = query._program || body._program
628
+ }
629
+
630
+ if (trigger) {
631
+ runRequest.expiresAfterMins =
632
+ query.expiresAfterMins !== undefined
633
+ ? query.expiresAfterMins
634
+ : body.expiresAfterMins
635
+ const answer = await SERVICES.runtime.trigger(subject, runRequest)
636
+ await persistChanges()
637
+ return json(answer, 200)
638
+ }
639
+
640
+ const artifact = await SERVICES.runtime.execute(subject, runRequest)
641
+ await persistChanges()
642
+ return artifactResponse(artifact)
643
+ }
644
+
645
+ /**
646
+ * A triggered session's output, from the session service.
647
+ *
648
+ * Called directly for the same reason as a run: the artefact carries the content
649
+ * type and disposition its service declared, and a JSON-shaped answer would lose
650
+ * them.
651
+ */
652
+ const outputRoute = async (request, params, subject) => {
653
+ const artifact = await SERVICES.session.output(subject, params.sessionId)
654
+ return artifactResponse(artifact)
655
+ }
656
+
657
+ /** The response a `contents` answer becomes, typed by the path it names. */
658
+ const contentsResponse = (answer, path) => {
659
+ const binary = typeof answer.contents !== 'string'
660
+ return new Response(answer.contents, {
661
+ status: answer.status,
662
+ headers: { 'content-type': contentTypeFor(path, binary) }
663
+ })
314
664
  }
315
665
 
316
666
  const handle = async (request, url) => {
317
- const path = url.pathname.replace(/\/+$/, '')
667
+ const path = url.pathname.replace(/\/+$/, '') || '/'
318
668
  const query = {}
319
669
  for (const [k, v] of url.searchParams.entries()) query[k] = v
320
670
 
321
671
  try {
322
672
  await R.ensureReady()
323
673
 
324
- if (path.endsWith('/SASjsApi/info')) {
325
- // The documented body field for field, with the values @sasjs/api's
326
- // BROWSER_INFO states for this host: `cors` is the document's CorsType
327
- // (`enable` | `disable`), both ports are null because a static origin has
328
- // no listener, and `localLoginEnabled` is false because there is no
329
- // account to sign in to - true would render a login form with no route
330
- // behind it. `version` is `dev`, the fallback a server reports when it
331
- // carries no build stamp. This block goes when the release carrying the
332
- // package's own info service lands; until then the two answers agree.
333
- return json({
334
- mode: 'desktop',
335
- cors: 'disable',
336
- httpPort: null,
337
- httpsPort: null,
338
- runTimes: ['js'],
339
- authProviders: [],
340
- localLoginEnabled: false,
341
- version: 'dev'
342
- })
343
- }
674
+ // The package's own matcher, so a route is recognised however its path is
675
+ // cased and its parameters are read the way the document states them.
676
+ const matched = API.findRoute(request.method, path)
677
+ if (!matched) return json({ message: 'Not part of the API: ' + path }, 404)
344
678
 
345
- // the nine drive operations are the package's, matched off its own table
346
- // rather than a suffix chain, so a path this host does not own falls through
347
- // to its routes below instead of being answered twice
348
- const matched = SASJS_API.findRoute(request.method, path)
349
- if (matched && DRIVE_ROUTES.has(matched.route.id))
350
- return await driveRoute(matched.route, request, query)
679
+ const routeId = matched.route.id
680
+ const subject = await subjectOf(request, query)
681
+ const payload = await readPayload(request, routeId === DEPLOY_UPLOAD)
682
+ payload.query = query
351
683
 
352
- if (
353
- path.endsWith('/SASjsApi/stp/execute') ||
354
- path.endsWith('/SASjsApi/code/execute')
355
- ) {
356
- const payload = await readPayload(request)
357
- const merged = Object.assign({}, query, payload.params)
358
- const program = merged._program
359
- const code =
360
- payload.json && payload.json.code ? payload.json.code : undefined
361
- if (!program && !code)
362
- return json({ message: '_program is required' }, 400)
363
-
364
- const result = R.run({
365
- program,
366
- code,
367
- params: merged,
368
- tables: payload.tables
684
+ if (RUN_ROUTES.has(routeId))
685
+ return await runRoute(routeId, payload, subject)
686
+
687
+ const apiRequest = {
688
+ query,
689
+ params: matched.params,
690
+ subject,
691
+ body: payload.body
692
+ }
693
+ const file = singleFile(payload)
694
+ if (file) apiRequest.file = file
695
+ if (payload.files.length > 1) apiRequest.files = payload.files
696
+
697
+ if (routeId === SESSION_OUTPUT)
698
+ return await outputRoute(request, matched.params, subject)
699
+
700
+ // The upload route's transport is the archive: it is expanded here and the
701
+ // drive is handed the same tree the JSON route takes.
702
+ if (routeId === DEPLOY_UPLOAD) {
703
+ if (!payload.files.length) throw badRequest('"file" is not present.')
704
+ const entries = await unzip(payload.files[0].contents)
705
+ apiRequest.body = Object.assign({}, payload.body, {
706
+ fileTree: deployTree(entries)
369
707
  })
370
- await persistChanges()
371
- if (result.error) {
372
- return json(
373
- {
374
- status: 'failure',
375
- message: 'Job execution failed.',
376
- error: result.error
377
- },
378
- 500
379
- )
380
- }
381
- const debug = String(merged._debug || '').length > 0
382
- return weboutResponse(result, debug)
383
708
  }
384
709
 
385
- if (path.includes('/SASjsApi/session')) {
386
- return json({ state: 'completed' })
387
- }
710
+ const answer = await API.dispatch(SERVICES, routeId, apiRequest)
711
+ if (request.method !== 'GET') await persistChanges()
388
712
 
389
- return json(
390
- { message: 'Not implemented by the browser host: ' + path },
391
- 404
392
- )
713
+ if (answer.contents !== undefined)
714
+ return contentsResponse(
715
+ answer,
716
+ query._filePath || payload.body.filePath || path
717
+ )
718
+ return json(answer.body, answer.status)
393
719
  } catch (e) {
394
720
  const message = (e && e.stack) || String(e)
395
721
  broadcast({ type: 'sasjs-host-error', path, message })
396
- // A drive refusal is the message itself, in plain text: the server answers
397
- // these with `res.status(code).send(err.message)`, and its own suite asserts
398
- // `res.text`. The other routes answer JSON, because a program failure
399
- // carries more than a message (`status`, `error`), so they keep the envelope.
400
- if (path.includes('/SASjsApi/drive/') && e && e.status)
401
- return text((e && e.message) || String(e), e.status)
722
+ // A refusal the API decided on carries its status and, where the route
723
+ // answers an envelope, the body to send. The drive's routes send the message
724
+ // itself as plain text - the server answers them with `res.status(code)
725
+ // .send(err.message)` - so those stay text; everything else keeps the JSON
726
+ // envelope the other routes already answer with.
727
+ if (API.isApiError(e)) {
728
+ if (e.body !== undefined) return json(e.body, e.status)
729
+ if (path.includes('/SASjsApi/drive/')) return text(e.message, e.status)
730
+ return json({ message: e.message }, e.status)
731
+ }
402
732
  return json(
403
733
  { message: (e && e.message) || String(e) },
404
734
  (e && e.status) || 500
@@ -409,7 +739,12 @@ const handle = async (request, url) => {
409
739
  self.addEventListener('fetch', (event) => {
410
740
  const url = new URL(event.request.url)
411
741
  if (url.origin !== self.location.origin) return
412
- if (!url.pathname.includes('/SASjsApi/')) return
742
+ // The site's own page is the home route, and the static host serves it: a
743
+ // worker that answered `/` with the page it copied would be serving the app
744
+ // from two places. The API's own paths are answered here, including one the
745
+ // table does not have - falling through to the static host would put the SPA's
746
+ // index.html behind a typo in a caller's URL.
747
+ if (!/^\/(SASjsApi|SASLogon)(\/|$)/i.test(url.pathname)) return
413
748
  event.respondWith(handle(event.request, url))
414
749
  })
415
750