@sasjs/cli 6.14.0 → 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.
- package/build/commands/build/internal/mockSite/assets/sw.js +509 -174
- package/build/commands/build/internal/mockSite/mockSite.js +32 -8
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +78 -5
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +532 -56
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
170
|
+
const artifactOf = (result, debug) => {
|
|
136
171
|
if (debug)
|
|
137
|
-
return
|
|
138
|
-
[
|
|
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
|
-
|
|
196
|
+
contents = toBytes(result.webout)
|
|
152
197
|
}
|
|
153
|
-
return
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
710
|
+
const answer = await API.dispatch(SERVICES, routeId, apiRequest)
|
|
711
|
+
if (request.method !== 'GET') await persistChanges()
|
|
388
712
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
|