@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.
- package/build/commands/build/internal/mockSite/assets/sw.js +742 -173
- package/build/commands/build/internal/mockSite/mockSite.d.ts +10 -0
- package/build/commands/build/internal/mockSite/mockSite.js +208 -32
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +102 -6
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +661 -34
- package/package.json +2 -1
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* server rather than a static
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
// The
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
183
|
+
const artifactOf = (result, debug) => {
|
|
136
184
|
if (debug)
|
|
137
|
-
return
|
|
138
|
-
[
|
|
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
|
-
|
|
209
|
+
contents = toBytes(result.webout)
|
|
152
210
|
}
|
|
153
|
-
return
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
*
|
|
673
|
+
* The members of a zip archive, as the deploy route's tree.
|
|
277
674
|
*
|
|
278
|
-
* The transport
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
301
|
-
|
|
700
|
+
const count = u16(eocd + 10)
|
|
701
|
+
let offset = u32(eocd + 16)
|
|
302
702
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
898
|
+
const answer = await API.dispatch(SERVICES, routeId, apiRequest)
|
|
899
|
+
if (request.method !== 'GET') await persistChanges()
|
|
388
900
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
|