@sasjs/cli 6.14.0 → 6.16.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,66 @@
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. The
27
+ * activity log the Admin routes report is in this worker's memory for the same
28
+ * reason.
29
+ *
30
+ * Every operation in @sasjs/api's route table is answered here: the drive, the
31
+ * sign-in flow, the records, the runs, the sessions, the diagnostics and logs,
32
+ * the directory import, the app-stream delete, the linter and the home page. The
33
+ * services are the package's; this file supplies the transport, the worker's own
34
+ * activity, and the browser's answers to questions a server asks its process.
21
35
  */
22
36
  importScripts('/host/runtime-core.js')
23
37
  importScripts('/host/api-browser.js')
38
+ importScripts('/host/lint-browser.js')
24
39
 
25
40
  const R = self.SASjsMockRuntime
26
41
 
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
- )
42
+ // @sasjs/api is the package sasjs/server answers with. Every route in its table
43
+ // is answered here from its services; what this file owns is the transport (a
44
+ // Request is not an ApiRequest), the work (a runner over the browser's drive)
45
+ // and the few capabilities a static origin does not have, which the package
46
+ // reports as a 501 naming the missing service.
47
+ const API = self.SASjsApi
48
+ // The linter, bundled at build time from the installed @sasjs/api/code and
49
+ // @sasjs/lint (see mockSite.ts). It is a separate script because the linter is
50
+ // not part of the root package a service worker can import.
51
+ const CODE = self.SASjsCode
52
+
53
+ const DRIVE_STORE = R.driveStore
54
+ const IDENTITY_STORE = new API.MemoryIdentityStore(API.DEFAULT_IDENTITY)
55
+ const IDENTITY = API.createIdentityService(IDENTITY_STORE)
56
+ const RECORDS = API.createRecordService(IDENTITY_STORE)
57
+
58
+ /**
59
+ * The caller a request without a credential is talking as.
60
+ *
61
+ * This host has no login wall - that is its trust model, not an oversight - so
62
+ * a page that does not sign in is not anonymous, it is the visitor. The fixture
63
+ * administrator is the account the demo exists to give a client, and naming it
64
+ * here keeps a mock site usable with no `/SASLogon/login` call. A request that
65
+ * does carry a token is that token's subject, so the sign-in flow, the fixture's
66
+ * two accounts and their permission rules all behave as the package decides.
67
+ */
68
+ const IMPLICIT_CALLER = {
69
+ userId: (API.DEFAULT_IDENTITY.users.find((user) => user.isAdmin) || {}).uid,
70
+ clientId: ((API.DEFAULT_IDENTITY.clients || [])[0] || {}).clientId
71
+ }
72
+
42
73
  const LOGS_SEPARATOR = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
43
74
 
44
75
  self.addEventListener('install', () => self.skipWaiting())
@@ -70,6 +101,18 @@ const toBytes = (base64) => {
70
101
  return out
71
102
  }
72
103
 
104
+ const toBase64 = (bytes) => {
105
+ let binary = ''
106
+ const chunk = 0x8000
107
+ for (let i = 0; i < bytes.length; i += chunk)
108
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))
109
+ return btoa(binary)
110
+ }
111
+
112
+ const decoder = new TextDecoder()
113
+ const toText = (bytes) =>
114
+ typeof bytes === 'string' ? bytes : decoder.decode(bytes)
115
+
73
116
  // ------------------------------------------------------------ webout headers
74
117
  // A service declares the response it wants by writing `Name: value` lines to
75
118
  // the file the preamble hands it as _SASJS_WEBOUT_HEADERS - the same channel
@@ -128,17 +171,32 @@ const parseHeaders = (content) => {
128
171
  const SEEDED_HEADER_LINE = String(R.HEADERS_SEED)
129
172
 
130
173
  /**
131
- * The reply a run produces: the webout, in the shape and under the name the
174
+ * The reply a run produced: the webout, in the shape and under the name the
132
175
  * service asked for. A debug reply is the webout framed with the log, so it
133
176
  * stays text - honouring a download there would put the frame in the file.
177
+ *
178
+ * The headers travel with the artefact rather than being applied here, because
179
+ * both the execute answer and a triggered session's `/output` are that same
180
+ * artefact: one place decides what a service declared, and the transport reads
181
+ * it.
134
182
  */
135
- const weboutResponse = (result, debug) => {
183
+ const artifactOf = (result, debug) => {
136
184
  if (debug)
137
- return text(
138
- [result.webout, LOGS_SEPARATOR, result.log, LOGS_SEPARATOR].join('\n')
139
- )
185
+ return {
186
+ contents: [
187
+ result.webout,
188
+ LOGS_SEPARATOR,
189
+ result.log,
190
+ LOGS_SEPARATOR
191
+ ].join('\n'),
192
+ // the frame is text, and the service's own type and disposition are not
193
+ // applied to it - a download there would put the frame in the file
194
+ headers: { 'content-type': 'text/plain' },
195
+ log: result.log
196
+ }
140
197
 
141
198
  const headers = parseHeaders(result.headers)
199
+ let contents = result.webout
142
200
  if (result.binary) {
143
201
  // the seed is a default, so a byte body that leaves the seeded file untouched
144
202
  // declares nothing: it keeps the type an undeclared download gets. Compared as
@@ -148,11 +206,20 @@ const weboutResponse = (result, debug) => {
148
206
  delete headers['content-type']
149
207
  if (!headers['content-type'])
150
208
  headers['content-type'] = 'application/octet-stream'
151
- return new Response(toBytes(result.webout), { status: 200, headers })
209
+ contents = toBytes(result.webout)
152
210
  }
153
- return new Response(result.webout, { status: 200, headers })
211
+ return { contents, headers, log: result.log }
154
212
  }
155
213
 
214
+ /** The response an artefact becomes, with the headers its service declared. */
215
+ const artifactResponse = (artifact) =>
216
+ new Response(
217
+ artifact.contents === undefined || artifact.contents === null
218
+ ? ''
219
+ : artifact.contents,
220
+ { status: 200, headers: artifact.headers || {} }
221
+ )
222
+
156
223
  const broadcast = async (message) => {
157
224
  const clients = await self.clients.matchAll({ includeUncontrolled: true })
158
225
  clients.forEach((client) => client.postMessage(message))
@@ -164,33 +231,297 @@ const persistChanges = async () => {
164
231
  await broadcast({ type: 'sasjs-persist', files })
165
232
  }
166
233
 
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
- })
234
+ // ---------------------------------------------------------------- the runner
235
+ /**
236
+ * The work a run does, over the browser's drive.
237
+ *
238
+ * `@sasjs/api` decides everything a client observes about a run - which
239
+ * program, which run time, who may start it, the session record - and this
240
+ * performs it. It is also the host's session store, because a session the
241
+ * trigger route created has to be one the session routes can read: two maps
242
+ * behind two ports would let a trigger write a record nothing can poll.
243
+ */
244
+ class BrowserRuntime extends API.MemorySessionHost {
245
+ constructor() {
246
+ // no fixture sessions: every session this host reports was run here
247
+ super({}, {}, {}, {})
248
+ }
249
+
250
+ async run(request) {
251
+ const program = request.program || {}
252
+ const params = Object.assign({}, request.vars || {})
253
+ const payload = { params, tables: tablesOf(request.files) }
254
+ if (program.code !== undefined) payload.code = program.code
255
+ else payload.program = program.path
256
+
257
+ const result = R.run(payload)
258
+ if (result.error) throw runFailure(result.error)
259
+
260
+ const artifact = artifactOf(result, params._debug !== undefined)
261
+ const session = request.session
262
+ if (session) {
263
+ const stamp = new Date().toISOString()
264
+ const summary = {
265
+ sessionId: session.sessionId,
266
+ state: 'completed',
267
+ creationTimeStamp: stamp,
268
+ deathTimeStamp: stamp
184
269
  }
185
- return { params, tables }
186
- } catch (e) {
187
- return { params: {}, tables: [] }
270
+ if (session.expiresAfterMins !== undefined)
271
+ summary.expiresAfterMins = {
272
+ mins: session.expiresAfterMins,
273
+ used: false
274
+ }
275
+ this.sessions[session.sessionId] = { owner: session.owner, summary }
276
+ this.states[session.sessionId] = { state: 'completed' }
277
+ this.logs[session.sessionId] = artifact.log || ''
278
+ this.outputs[session.sessionId] = artifact
188
279
  }
280
+ return { output: artifact }
189
281
  }
190
- const body = await request.json()
191
- return { params: body.params || {}, tables: body.tables || [], json: body }
192
282
  }
193
283
 
284
+ /**
285
+ * A run that failed, as the service stringifies it.
286
+ *
287
+ * The runtime catches whatever the runner throws and answers its documented
288
+ * `Job execution failed.` envelope with `String(error)`, so the message a
289
+ * service's stack carries has to survive that call unchanged - a wrapper Error
290
+ * would add its own prefix to a stack that already has one.
291
+ */
292
+ const runFailure = (message) => {
293
+ const error = new Error(message)
294
+ error.toString = () => message
295
+ return error
296
+ }
297
+
298
+ /**
299
+ * The multipart files a request carried, as the runtime's `_WEBIN_*` tables.
300
+ *
301
+ * The field name is the table's name and the uploaded file's name is its
302
+ * filename, which is what a stored program reads (`_WEBIN_NAME1`,
303
+ * `_WEBIN_FILENAME1`, `_WEBIN_FILEREF1`). The package's transport carries the
304
+ * bytes; the names are this host's, because they are the form the browser sent.
305
+ */
306
+ const tablesOf = (files) => {
307
+ if (!files) return []
308
+ const list = Array.isArray(files)
309
+ ? files
310
+ : Object.keys(files).map((name) => ({ name, contents: files[name] }))
311
+ return list.map((file, index) => ({
312
+ name: file.name || `file${index + 1}`,
313
+ filename: file.filename || file.name || `file${index + 1}`,
314
+ csv: toText(file.csv !== undefined ? file.csv : file.contents)
315
+ }))
316
+ }
317
+
318
+ const RUNTIME = new BrowserRuntime()
319
+
320
+ // ------------------------------------------------------------- host reports
321
+ // The Admin routes ask a host what it is doing. A browser has no process, but it
322
+ // does have a page, a heap and this worker's own activity, so the reports are
323
+ // built from those rather than refused: the request log below is the log file,
324
+ // and the memory reading is `performance.memory` where the browser exposes it.
325
+ const STARTED = Date.now()
326
+ const HOST_LOG_LIMIT = 2000
327
+ const HOST_LOG = []
328
+ const LOG_FILE = 'app.log'
329
+ // the moment the last line was written, which is the file's mtime - not the
330
+ // moment a report read it
331
+ let LOG_UPDATED = null
332
+
333
+ const logEntry = (line) => {
334
+ LOG_UPDATED = new Date().toISOString()
335
+ HOST_LOG.push(`${LOG_UPDATED} ${line}`)
336
+ if (HOST_LOG.length > HOST_LOG_LIMIT)
337
+ HOST_LOG.splice(0, HOST_LOG.length - HOST_LOG_LIMIT)
338
+ }
339
+
340
+ const logText = () => HOST_LOG.join('\n')
341
+ const bytesOf = (text) => new TextEncoder().encode(String(text)).length
342
+
343
+ const adminReports = {
344
+ diagnostics: async () => {
345
+ const perf = self.performance || {}
346
+ const heap = perf.memory || {}
347
+ const deviceBytes = Number(navigator.deviceMemory || 0) * 1024 ** 3
348
+ const total = Number(heap.jsHeapSizeLimit || deviceBytes || 0)
349
+ const used = Number(heap.usedJSHeapSize || 0)
350
+ return {
351
+ build: { version: 'dev', gitHash: 'unknown', buildDate: null },
352
+ server: {
353
+ // a page is not a process: the fields exist so a diagnostics screen
354
+ // renders, and their values describe this worker rather than inventing
355
+ // one behind it
356
+ mode: 'browser',
357
+ uptimeSeconds: (Date.now() - STARTED) / 1000,
358
+ startedAt: new Date(STARTED).toISOString(),
359
+ pid: 0,
360
+ nodeVersion: 'n/a',
361
+ platform: navigator.platform || 'browser',
362
+ arch: 'browser',
363
+ cpuCount: navigator.hardwareConcurrency || 1,
364
+ cpuModel: 'unknown',
365
+ loadAverage: [],
366
+ runTimes: ['js'],
367
+ sasjsRoot: '/'
368
+ },
369
+ memory: {
370
+ rssBytes: used,
371
+ heapTotalBytes: total,
372
+ heapUsedBytes: used,
373
+ externalBytes: 0,
374
+ systemTotalBytes: deviceBytes || total,
375
+ systemFreeBytes: Math.max(0, (deviceBytes || total) - used)
376
+ },
377
+ // a static origin cannot read the device's filesystem, which the document
378
+ // allows the host to state
379
+ filesystem: null,
380
+ folders: [],
381
+ logs: {
382
+ folder: 'worker memory',
383
+ // no file until a request has written one, and the stamp is the last
384
+ // write rather than the moment this report was assembled
385
+ fileCount: HOST_LOG.length ? 1 : 0,
386
+ totalBytes: bytesOf(logText()),
387
+ newestFile: HOST_LOG.length ? LOG_FILE : null,
388
+ newestModified: LOG_UPDATED
389
+ }
390
+ }
391
+ },
392
+ logs: async () =>
393
+ HOST_LOG.length
394
+ ? [
395
+ {
396
+ name: LOG_FILE,
397
+ sizeBytes: bytesOf(logText()),
398
+ modified: LOG_UPDATED
399
+ }
400
+ ]
401
+ : [],
402
+ logTail: async (fileName, lines) => {
403
+ if (fileName !== LOG_FILE || !HOST_LOG.length) return undefined
404
+ const tail = HOST_LOG.slice(-lines)
405
+ const content = tail.join('\n')
406
+ return {
407
+ fileName,
408
+ lines: tail.length,
409
+ bytesRead: bytesOf(content),
410
+ truncated: tail.length < HOST_LOG.length,
411
+ content
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * The directory the LDAP import reads.
418
+ *
419
+ * A browser has no bind to make, so the source is the fixture this host already
420
+ * signs in with: every account and group it names is present, so the import
421
+ * answers the route (the package owns the idempotency) and creates nothing. A
422
+ * host with a real directory supplies its own source.
423
+ */
424
+ const directorySource = {
425
+ users: async () =>
426
+ API.DEFAULT_IDENTITY.users.map((user) => ({
427
+ username: user.username,
428
+ displayName: user.displayName
429
+ })),
430
+ groups: async () =>
431
+ API.DEFAULT_IDENTITY.groups.map((group) => ({
432
+ name: group.name,
433
+ members: group.users || []
434
+ }))
435
+ }
436
+
437
+ /**
438
+ * The app streams this host published: the site itself.
439
+ *
440
+ * The folder is the identity, so unpublishing the app the site was built for
441
+ * answers `success`; any other folder is the documented 404. The static files
442
+ * stay where they are - a worker cannot delete the site it is served from - so
443
+ * this is a record of the publication, not a filesystem operation.
444
+ */
445
+ let registryPromise
446
+ const registry = () => {
447
+ if (!registryPromise)
448
+ registryPromise = (async () => {
449
+ let appLoc
450
+ try {
451
+ const config = await (await fetch('/host/mock-config.json')).json()
452
+ appLoc = config && config.appLoc
453
+ } catch (e) {
454
+ appLoc = undefined
455
+ }
456
+ return new API.MemoryAppStreamRegistry(appLoc ? [appLoc] : [])
457
+ })()
458
+ return registryPromise
459
+ }
460
+
461
+ /**
462
+ * The page at `/`.
463
+ *
464
+ * The site's own index.html is already in the build, so the home route reads it
465
+ * from the static host and hands the package the page and a token of its
466
+ * contents. The package decides what to do with the token; these builds do not
467
+ * reference `index.bundle.js`, so in practice it is returned unchanged.
468
+ */
469
+ const contentVersion = (text) => {
470
+ let hash = 0x811c9dc5
471
+ for (let i = 0; i < text.length; i++) {
472
+ hash ^= text.charCodeAt(i)
473
+ hash = Math.imul(hash, 0x01000193)
474
+ }
475
+ return (hash >>> 0).toString(16)
476
+ }
477
+
478
+ const homeService = API.createHomeService(async () => {
479
+ try {
480
+ const response = await fetch('/index.html', { cache: 'no-store' })
481
+ if (!response.ok) return undefined
482
+ const html = await response.text()
483
+ return { html, version: contentVersion(html) }
484
+ } catch (e) {
485
+ return undefined
486
+ }
487
+ })
488
+
489
+ const SERVICES = {
490
+ drive: API.createDriveService(DRIVE_STORE),
491
+ info: API.createInfoService(API.BROWSER_INFO),
492
+ identity: IDENTITY,
493
+ records: RECORDS,
494
+ runtime: API.createRuntimeService(IDENTITY_STORE, RUNTIME, {
495
+ // the one run time a browser holds; a stored program asks for another and
496
+ // is refused with the package's own message
497
+ runTimes: ['js']
498
+ }),
499
+ session: API.createSessionService(IDENTITY_STORE, RUNTIME),
500
+ authConfig: API.createAuthConfigService({}),
501
+ admin: API.createAdminService(adminReports),
502
+ directory: API.createDirectoryService(IDENTITY_STORE, directorySource),
503
+ registry: {
504
+ unpublish: async (appLoc) => (await registry()).unpublish(appLoc)
505
+ },
506
+ home: homeService,
507
+ // the package's own code service over the bundled linter; the host passes no
508
+ // lint config, so a buffer is linted against the linter's defaults - a mock
509
+ // has no `.sasjslint` tree to resolve one from
510
+ code: CODE.createCodeService({})
511
+ }
512
+
513
+ /** The routes this host performs itself, because the answer is not JSON. */
514
+ const RUN_ROUTES = new Set([
515
+ 'GET /SASjsApi/stp/execute',
516
+ 'POST /SASjsApi/stp/execute',
517
+ 'POST /SASjsApi/stp/trigger',
518
+ 'POST /SASjsApi/code/execute',
519
+ 'POST /SASjsApi/code/trigger'
520
+ ])
521
+
522
+ const SESSION_OUTPUT = 'GET /SASjsApi/session/{sessionId}/output'
523
+ const DEPLOY_UPLOAD = 'POST /SASjsApi/drive/deploy/upload'
524
+
194
525
  // ---------------------------------------------------------------- requests
195
526
  /** A request body as JSON, and {} when it is not JSON - a client that posts a
196
527
  * form where the route wants JSON gets the missing-field answer rather than a
@@ -204,38 +535,103 @@ const readJson = async (request) => {
204
535
  }
205
536
 
206
537
  const badRequest = (message) => {
207
- const error = new Error(message)
208
- error.status = 400
538
+ const error = new API.ApiError(400, message)
209
539
  return error
210
540
  }
211
541
 
212
542
  const payloadTooLarge = (limit) => {
213
- const error = new Error(
543
+ return new API.ApiError(
544
+ 413,
214
545
  `The upload is larger than the browser host allows (${Math.round(
215
546
  limit / (1024 * 1024)
216
547
  )} MB).`
217
548
  )
218
- error.status = 413
219
- return error
220
549
  }
221
550
 
222
- /** The bytes of an uploaded file, from the multipart field the API names. */
223
- const uploadBytes = async (request) => {
551
+ /**
552
+ * A JSON body, unwrapped from the envelope the page bridge posts.
553
+ *
554
+ * `host/intercept.js` turns an XHR body into `{ params, tables }` before it
555
+ * reaches the worker: reading a multipart body from an XHR-originated request is
556
+ * unreliable in a worker, so the page - where a FormData is trivial - does it.
557
+ * That envelope is this build's own wire format for page traffic, and a client
558
+ * that posts JSON directly (a `fetch` from the console, a test) sends its fields
559
+ * flat, so both shapes are read.
560
+ */
561
+ const readJsonBody = async (request) => {
562
+ const body = await readJson(request)
563
+ if (
564
+ body &&
565
+ typeof body === 'object' &&
566
+ !Array.isArray(body) &&
567
+ Array.isArray(body.tables) &&
568
+ body.params &&
569
+ typeof body.params === 'object'
570
+ ) {
571
+ return {
572
+ body: body.params,
573
+ files: body.tables.map((table, index) => ({
574
+ name: table.name || `table${index + 1}`,
575
+ filename:
576
+ table.filename || `${table.name || 'table' + (index + 1)}.csv`,
577
+ contents: table.csv === undefined ? '' : table.csv
578
+ }))
579
+ }
580
+ }
581
+ return { body, files: [] }
582
+ }
583
+
584
+ /**
585
+ * A request's payload: the form fields as a body, and the files apart from them.
586
+ *
587
+ * The two have to be separated here rather than in the package: a multipart
588
+ * body is the transport's, and the API names a file by a field the host reads.
589
+ */
590
+ const readPayload = async (request, allowLargeFile = false) => {
591
+ const parsed = { body: {}, files: [] }
592
+ if (request.method === 'GET' || request.method === 'HEAD') return parsed
593
+ const type = request.headers.get('content-type') || ''
594
+ if (!type.includes('multipart/form-data')) return await readJsonBody(request)
224
595
  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
596
+ if (!form) return parsed
597
+ for (const [name, value] of form.entries()) {
598
+ if (typeof value === 'string') {
599
+ parsed.body[name] = value
600
+ continue
601
+ }
602
+ // refused from the File's size, before the body is read: a worker holds the
603
+ // whole payload in memory, so an oversized upload there is an out-of-memory
604
+ // failure rather than an answer a client can act on. The deploy archive is
605
+ // the exception - the package measures the tree it expands to, which is the
606
+ // cap the document states for that route - and it is read here to be
607
+ // expanded before that measurement can run.
608
+ if (
609
+ !allowLargeFile &&
610
+ typeof value.size === 'number' &&
611
+ value.size > R.MAX_UPLOAD_BYTES
612
+ )
613
+ throw payloadTooLarge(R.MAX_UPLOAD_BYTES)
614
+ parsed.files.push({
615
+ name,
616
+ filename: value.name || name,
617
+ contents: new Uint8Array(await value.arrayBuffer())
618
+ })
238
619
  }
620
+ return parsed
621
+ }
622
+
623
+ /**
624
+ * The multipart file the API names `file`, for the routes that take one.
625
+ *
626
+ * Every route that carries a single file names the field `file`; the deploy
627
+ * upload is the exception, because its archive is expanded into a tree before
628
+ * the drive sees it.
629
+ */
630
+ const singleFile = (parsed) => {
631
+ const match =
632
+ parsed.files.find((file) => file.name === 'file') || parsed.files[0]
633
+ if (!match) return undefined
634
+ return { name: match.filename, contents: match.contents }
239
635
  }
240
636
 
241
637
  /**
@@ -272,133 +668,264 @@ const contentTypeFor = (filePath, binary) => {
272
668
  return binary ? 'application/octet-stream' : 'text/plain'
273
669
  }
274
670
 
671
+ // ------------------------------------------------------------- a deployment
275
672
  /**
276
- * One drive operation, answered by the package.
673
+ * The members of a zip archive, as the deploy route's tree.
277
674
  *
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.
675
+ * The server's transport is multer plus a zip library; a worker has
676
+ * `DecompressionStream`, so parsing the archive is this host's. Only the two
677
+ * methods a deploy archive uses are read - stored and deflate - and anything
678
+ * else is refused by name rather than half-expanded.
679
+ *
680
+ * Directory entries are dropped and a folder is derived from the members'
681
+ * names, which is how the tree the server builds from the same archive is
682
+ * shaped.
283
683
  */
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)
684
+ const unzip = async (bytes) => {
685
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
686
+ const u16 = (at) => view.getUint16(at, true)
687
+ const u32 = (at) => view.getUint32(at, true)
688
+
689
+ // the end-of-central-directory record is the last signature in the file, and
690
+ // it sits within 22 + 65535 bytes of the end (a comment is the only tail)
691
+ let eocd = -1
692
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) {
693
+ if (u32(i) === 0x06054b50) {
694
+ eocd = i
695
+ break
297
696
  }
298
697
  }
698
+ if (eocd < 0) throw badRequest('The archive is not a zip file.')
299
699
 
300
- const answer = await SASJS_API.dispatch(DRIVE_SERVICES, route.id, apiRequest)
301
- if (request.method !== 'GET') await persistChanges()
700
+ const count = u16(eocd + 10)
701
+ let offset = u32(eocd + 16)
302
702
 
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
- }
703
+ const entries = []
704
+ for (let n = 0; n < count; n++) {
705
+ if (u32(offset) !== 0x02014b50)
706
+ throw badRequest('The archive is not a zip file.')
707
+ const method = u16(offset + 10)
708
+ const compressedSize = u32(offset + 20)
709
+ const nameLength = u16(offset + 28)
710
+ const extraLength = u16(offset + 30)
711
+ const commentLength = u16(offset + 32)
712
+ const localOffset = u32(offset + 42)
713
+ const name = decoder.decode(
714
+ bytes.subarray(offset + 46, offset + 46 + nameLength)
715
+ )
716
+ offset += 46 + nameLength + extraLength + commentLength
717
+
718
+ if (name.endsWith('/')) continue
719
+ if (u32(localOffset) !== 0x04034b50)
720
+ throw badRequest('The archive is not a zip file.')
721
+ const start =
722
+ localOffset + 30 + u16(localOffset + 26) + u16(localOffset + 28)
723
+ const data = bytes.subarray(start, start + compressedSize)
724
+
725
+ let contents
726
+ if (method === 0) contents = data
727
+ else if (method === 8) {
728
+ const stream = new Blob([data])
729
+ .stream()
730
+ .pipeThrough(new DecompressionStream('deflate-raw'))
731
+ contents = new Uint8Array(await new Response(stream).arrayBuffer())
732
+ } else
733
+ throw badRequest(
734
+ `The archive uses compression method ${method}, which this host does not read.`
735
+ )
736
+ entries.push({ name, contents })
737
+ }
738
+ return entries
739
+ }
740
+
741
+ /** The nested tree the deploy route writes, from the archive's member names. */
742
+ const deployTree = (entries) => {
743
+ const root = { members: [] }
744
+ const folders = { '': root }
745
+ const folderFor = (path) => {
746
+ if (folders[path]) return folders[path]
747
+ const cut = path.lastIndexOf('/')
748
+ const parent = folderFor(cut < 0 ? '' : path.slice(0, cut))
749
+ const name = cut < 0 ? path : path.slice(cut + 1)
750
+ const folder = { name, type: 'folder', members: [] }
751
+ parent.members.push(folder)
752
+ folders[path] = folder
753
+ return folder
754
+ }
755
+ entries.forEach((entry) => {
756
+ const cut = entry.name.lastIndexOf('/')
757
+ const parent = folderFor(cut < 0 ? '' : entry.name.slice(0, cut))
758
+ parent.members.push({
759
+ name: cut < 0 ? entry.name : entry.name.slice(cut + 1),
760
+ type: 'file',
761
+ code: toBase64(entry.contents)
311
762
  })
763
+ })
764
+ return root
765
+ }
766
+
767
+ // ---------------------------------------------------------------- the routes
768
+ /** The subject a request is talking as, from its bearer token or the implicit
769
+ * caller when it carries none. An unknown token is the package's 401. */
770
+ const subjectOf = async (request, query) => {
771
+ const header = request.headers.get('authorization') || ''
772
+ const token =
773
+ header.replace(/^Bearer\s+/i, '').trim() ||
774
+ String(query.access_token || '').trim() ||
775
+ undefined
776
+ if (!token) return IMPLICIT_CALLER
777
+ const subject = await IDENTITY.authenticate(token)
778
+ if (!subject) throw new API.ApiError(401, 'Unauthorized')
779
+ return subject
780
+ }
781
+
782
+ /**
783
+ * One run, answered by the runtime service.
784
+ *
785
+ * The service is called directly rather than through `dispatch` because a run's
786
+ * answer is the webout *with the headers its service declared*
787
+ * (`content-disposition` is how a service names a download), and the artefact
788
+ * the service returns is where those headers are. Everything a client observes
789
+ * about the request - who may run it, which field names the program, what
790
+ * `_debug` means - is still the package's decision.
791
+ */
792
+ const runRoute = async (routeId, payload, subject) => {
793
+ const query = payload.query
794
+ const body = payload.body
795
+ const tables = payload.files.map((file) => ({
796
+ name: file.name,
797
+ filename: file.filename,
798
+ csv: toText(file.contents)
799
+ }))
800
+ const code =
801
+ routeId.endsWith('/code/execute') || routeId.endsWith('/code/trigger')
802
+ const trigger = routeId.endsWith('/trigger')
803
+ const vars = Object.assign({}, query, body)
804
+
805
+ const runRequest = {
806
+ source: code ? 'code' : 'program',
807
+ vars,
808
+ files: tables
809
+ }
810
+ if (code) {
811
+ runRequest.code = body.code
812
+ runRequest.runTime = body.runTime
813
+ } else {
814
+ // the query wins over the body, which is the order the server resolves them in
815
+ runRequest.program = query._program || body._program
312
816
  }
313
- return json(answer.body, answer.status)
817
+
818
+ if (trigger) {
819
+ runRequest.expiresAfterMins =
820
+ query.expiresAfterMins !== undefined
821
+ ? query.expiresAfterMins
822
+ : body.expiresAfterMins
823
+ const answer = await SERVICES.runtime.trigger(subject, runRequest)
824
+ await persistChanges()
825
+ return json(answer, 200)
826
+ }
827
+
828
+ const artifact = await SERVICES.runtime.execute(subject, runRequest)
829
+ await persistChanges()
830
+ return artifactResponse(artifact)
314
831
  }
315
832
 
316
- const handle = async (request, url) => {
317
- const path = url.pathname.replace(/\/+$/, '')
833
+ /**
834
+ * A triggered session's output, from the session service.
835
+ *
836
+ * Called directly for the same reason as a run: the artefact carries the content
837
+ * type and disposition its service declared, and a JSON-shaped answer would lose
838
+ * them.
839
+ */
840
+ const outputRoute = async (request, params, subject) => {
841
+ const artifact = await SERVICES.session.output(subject, params.sessionId)
842
+ return artifactResponse(artifact)
843
+ }
844
+
845
+ /** The response a `contents` answer becomes, typed by the path it names. */
846
+ const contentsResponse = (answer, path) => {
847
+ const binary = typeof answer.contents !== 'string'
848
+ return new Response(answer.contents, {
849
+ status: answer.status,
850
+ headers: { 'content-type': contentTypeFor(path, binary) }
851
+ })
852
+ }
853
+
854
+ const routeRequest = async (request, url) => {
855
+ const path = url.pathname.replace(/\/+$/, '') || '/'
318
856
  const query = {}
319
857
  for (const [k, v] of url.searchParams.entries()) query[k] = v
320
858
 
321
859
  try {
322
860
  await R.ensureReady()
323
861
 
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
- })
862
+ // The package's own matcher, so a route is recognised however its path is
863
+ // cased and its parameters are read the way the document states them.
864
+ const matched = API.findRoute(request.method, path)
865
+ if (!matched) return json({ message: 'Not part of the API: ' + path }, 404)
866
+
867
+ const routeId = matched.route.id
868
+ const subject = await subjectOf(request, query)
869
+ const payload = await readPayload(request, routeId === DEPLOY_UPLOAD)
870
+ payload.query = query
871
+
872
+ if (RUN_ROUTES.has(routeId))
873
+ return await runRoute(routeId, payload, subject)
874
+
875
+ const apiRequest = {
876
+ query,
877
+ params: matched.params,
878
+ subject,
879
+ body: payload.body
343
880
  }
881
+ const file = singleFile(payload)
882
+ if (file) apiRequest.file = file
883
+ if (payload.files.length > 1) apiRequest.files = payload.files
344
884
 
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)
885
+ if (routeId === SESSION_OUTPUT)
886
+ return await outputRoute(request, matched.params, subject)
351
887
 
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
888
+ // The upload route's transport is the archive: it is expanded here and the
889
+ // drive is handed the same tree the JSON route takes.
890
+ if (routeId === DEPLOY_UPLOAD) {
891
+ if (!payload.files.length) throw badRequest('"file" is not present.')
892
+ const entries = await unzip(payload.files[0].contents)
893
+ apiRequest.body = Object.assign({}, payload.body, {
894
+ fileTree: deployTree(entries)
369
895
  })
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
896
  }
384
897
 
385
- if (path.includes('/SASjsApi/session')) {
386
- return json({ state: 'completed' })
387
- }
898
+ const answer = await API.dispatch(SERVICES, routeId, apiRequest)
899
+ if (request.method !== 'GET') await persistChanges()
388
900
 
389
- return json(
390
- { message: 'Not implemented by the browser host: ' + path },
391
- 404
392
- )
901
+ // the shell is a page, not a file the caller named: the document states
902
+ // text/html for it, and the extension of `/` says nothing
903
+ if (routeId === 'GET /')
904
+ return new Response(answer.contents, {
905
+ status: answer.status,
906
+ headers: { 'content-type': 'text/html; charset=utf-8' }
907
+ })
908
+
909
+ if (answer.contents !== undefined)
910
+ return contentsResponse(
911
+ answer,
912
+ query._filePath || payload.body.filePath || path
913
+ )
914
+ return json(answer.body, answer.status)
393
915
  } catch (e) {
394
916
  const message = (e && e.stack) || String(e)
917
+ logEntry(`${request.method} ${path} ERROR ${(e && e.message) || message}`)
395
918
  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)
919
+ // A refusal the API decided on carries its status and, where the route
920
+ // answers an envelope, the body to send. The drive's routes send the message
921
+ // itself as plain text - the server answers them with `res.status(code)
922
+ // .send(err.message)` - so those stay text; everything else keeps the JSON
923
+ // envelope the other routes already answer with.
924
+ if (API.isApiError(e)) {
925
+ if (e.body !== undefined) return json(e.body, e.status)
926
+ if (path.includes('/SASjsApi/drive/')) return text(e.message, e.status)
927
+ return json({ message: e.message }, e.status)
928
+ }
402
929
  return json(
403
930
  { message: (e && e.message) || String(e) },
404
931
  (e && e.status) || 500
@@ -406,10 +933,52 @@ const handle = async (request, url) => {
406
933
  }
407
934
  }
408
935
 
936
+ /**
937
+ * One request, answered and written to the host's activity log.
938
+ *
939
+ * The log is what the Admin routes report: a browser has no server log file, so
940
+ * the worker keeps its own - method, path, status and how long it took - and the
941
+ * three admin routes read it back.
942
+ */
943
+ const handle = async (request, url) => {
944
+ const started = Date.now()
945
+ const response = await routeRequest(request, url)
946
+ logEntry(
947
+ `${request.method} ${url.pathname} ${response.status} ${
948
+ Date.now() - started
949
+ }ms`
950
+ )
951
+ return response
952
+ }
953
+
409
954
  self.addEventListener('fetch', (event) => {
410
955
  const url = new URL(event.request.url)
411
956
  if (url.origin !== self.location.origin) return
412
- if (!url.pathname.includes('/SASjsApi/')) return
957
+
958
+ // The site's own page is answered by the home route, but a navigation must
959
+ // never be at the API's mercy: if that route throws or answers an error, the
960
+ // request falls back to the static host, which holds the same index.html. A
961
+ // broken API route can cost a client its data; it must not cost the app its
962
+ // page.
963
+ if (url.pathname === '/') {
964
+ event.respondWith(
965
+ (async () => {
966
+ try {
967
+ const response = await handle(event.request, url)
968
+ if (response && response.status < 400) return response
969
+ } catch (e) {
970
+ /* fall through to the static host */
971
+ }
972
+ return fetch(event.request)
973
+ })()
974
+ )
975
+ return
976
+ }
977
+
978
+ // The API's own paths are answered here, including one the table does not
979
+ // have - falling through to the static host would put the SPA's index.html
980
+ // behind a typo in a caller's URL.
981
+ if (!/^\/(SASjsApi|SASLogon)(\/|$)/i.test(url.pathname)) return
413
982
  event.respondWith(handle(event.request, url))
414
983
  })
415
984