@sasjs/cli 6.0.0 → 6.2.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/build.js +20 -10
- package/build/commands/build/internal/mockSite/assets/intercept.js +172 -0
- package/build/commands/build/internal/mockSite/assets/md5.js +75 -0
- package/build/commands/build/internal/mockSite/assets/persist.js +222 -0
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +1230 -0
- package/build/commands/build/internal/mockSite/assets/sw.js +317 -0
- package/build/commands/build/internal/mockSite/mockSite.d.ts +38 -0
- package/build/commands/build/internal/mockSite/mockSite.js +475 -0
- package/build/commands/build/internal/mockSite/spec/intercept.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/intercept.spec.js +267 -0
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +497 -0
- package/build/commands/build/internal/mockSite/spec/persist.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/persist.spec.js +476 -0
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +802 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +716 -0
- package/build/commands/index.d.ts +1 -0
- package/build/commands/index.js +3 -1
- package/package.json +8 -7
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-worker host for the browser build.
|
|
3
|
+
*
|
|
4
|
+
* Running the backend here (rather than inside the page) means every request is
|
|
5
|
+
* a real network-layer request: DevTools > Network lists it as "(from
|
|
6
|
+
* ServiceWorker)", with the request payload and the response body inspectable.
|
|
7
|
+
*
|
|
8
|
+
* Storage is owned by the page (host/persist.js) because a service worker can
|
|
9
|
+
* be terminated between requests, and some browsers do not share IndexedDB
|
|
10
|
+
* between the page and its worker. The page hands in the drive at boot and
|
|
11
|
+
* receives the changed files back after every run.
|
|
12
|
+
*
|
|
13
|
+
* TRUST MODEL: this is a single-user, no-auth host. It answers every /SASjsApi/
|
|
14
|
+
* path on the site's origin, runs whatever program the request names, and runs
|
|
15
|
+
* the code a code/execute request carries - the same contract a SASjs server
|
|
16
|
+
* offers, minus the server. Nothing here is a boundary: a page at this origin
|
|
17
|
+
* can already do all of it, because a visitor's own browser is the client. So
|
|
18
|
+
* a site holding anything private, or one served from a shared origin, belongs
|
|
19
|
+
* behind a real server rather than a static host.
|
|
20
|
+
*/
|
|
21
|
+
importScripts('/host/runtime-core.js')
|
|
22
|
+
|
|
23
|
+
const R = self.SASjsMockRuntime
|
|
24
|
+
const LOGS_SEPARATOR = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
|
25
|
+
|
|
26
|
+
self.addEventListener('install', () => self.skipWaiting())
|
|
27
|
+
self.addEventListener('activate', (event) =>
|
|
28
|
+
event.waitUntil(self.clients.claim())
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const json = (obj, status) =>
|
|
32
|
+
new Response(JSON.stringify(obj, null, 2), {
|
|
33
|
+
status: status || 200,
|
|
34
|
+
headers: { 'content-type': 'application/json' }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const text = (body, status) =>
|
|
38
|
+
new Response(body, {
|
|
39
|
+
status: status || 200,
|
|
40
|
+
headers: { 'content-type': 'text/plain' }
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A byte body travels as base64. The runtime encodes it, and refuses a drive
|
|
45
|
+
* entry that will not decode before it hands the value over, so this is a plain
|
|
46
|
+
* decode - the corrupt-entry error lives in one place, runtime-core.js.
|
|
47
|
+
*/
|
|
48
|
+
const toBytes = (base64) => {
|
|
49
|
+
const binary = atob(String(base64 || '').replace(/\s+/g, ''))
|
|
50
|
+
const out = new Uint8Array(binary.length)
|
|
51
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------ webout headers
|
|
56
|
+
// A service declares the response it wants by writing `Name: value` lines to
|
|
57
|
+
// the file the preamble hands it as _SASJS_WEBOUT_HEADERS - the same channel
|
|
58
|
+
// %mp_streamfile uses on the SAS side, and the one the server reads. Only the
|
|
59
|
+
// two headers that change how a reply is delivered are applied: the content
|
|
60
|
+
// type, and the disposition that makes the browser save it under the name the
|
|
61
|
+
// service chose. Everything else (cache-control, cookies, ...) has no meaning
|
|
62
|
+
// on a static host.
|
|
63
|
+
const RESPONSE_HEADERS = ['content-type', 'content-disposition']
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parses what the service wrote. A header value is a byte string, so a value up
|
|
67
|
+
* to U+00FF is passed through as written - the same bytes node writes when the
|
|
68
|
+
* server sets the same header, which is what the mock has to match. HTAB is
|
|
69
|
+
* allowed inside a field value by HTTP and the server passes it through, so it
|
|
70
|
+
* is part of the set.
|
|
71
|
+
*
|
|
72
|
+
* 0x7F and 0x80-0x9F are outside it: the browser's Headers implementation strips
|
|
73
|
+
* those bytes from a value without complaining, which would save a download
|
|
74
|
+
* under a name with characters missing, so such a value is refused instead and
|
|
75
|
+
* the reply keeps its default delivery. CR, LF and NUL are refused for the same
|
|
76
|
+
* reason - they make the Response constructor throw.
|
|
77
|
+
*
|
|
78
|
+
* A filename outside ASCII belongs in RFC 6266 form
|
|
79
|
+
* (`filename*=UTF-8''r%C3%A9sum%C3%A9.csv`) - raw non-ASCII bytes in a header
|
|
80
|
+
* are not portable, on this host or on the server.
|
|
81
|
+
*/
|
|
82
|
+
const HEADER_VALUE = /^[\x09\x20-\x7E\xA0-\xFF]+$/
|
|
83
|
+
|
|
84
|
+
const parseHeaders = (content) => {
|
|
85
|
+
const headers = {}
|
|
86
|
+
String(content || '')
|
|
87
|
+
.split('\n')
|
|
88
|
+
.map((line) => line.trim())
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.forEach((line) => {
|
|
91
|
+
const separator = line.indexOf(':')
|
|
92
|
+
if (separator < 1) return
|
|
93
|
+
const name = line.slice(0, separator).trim().toLowerCase()
|
|
94
|
+
const value = line.slice(separator + 1).trim()
|
|
95
|
+
if (!RESPONSE_HEADERS.includes(name)) return
|
|
96
|
+
// the last line for a name wins, and a value that cannot be sent removes
|
|
97
|
+
// the header - the rule the server applies in extractHeaders
|
|
98
|
+
if (HEADER_VALUE.test(value)) headers[name] = value
|
|
99
|
+
else delete headers[name]
|
|
100
|
+
})
|
|
101
|
+
return headers
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The reply a run produces: the webout, in the shape and under the name the
|
|
106
|
+
* service asked for. A debug reply is the webout framed with the log, so it
|
|
107
|
+
* stays text - honouring a download there would put the frame in the file.
|
|
108
|
+
*/
|
|
109
|
+
const weboutResponse = (result, debug) => {
|
|
110
|
+
if (debug)
|
|
111
|
+
return text(
|
|
112
|
+
[result.webout, LOGS_SEPARATOR, result.log, LOGS_SEPARATOR].join('\n')
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const headers = parseHeaders(result.headers)
|
|
116
|
+
if (result.binary) {
|
|
117
|
+
if (!headers['content-type'])
|
|
118
|
+
headers['content-type'] = 'application/octet-stream'
|
|
119
|
+
return new Response(toBytes(result.webout), { status: 200, headers })
|
|
120
|
+
}
|
|
121
|
+
return new Response(result.webout, { status: 200, headers })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const broadcast = async (message) => {
|
|
125
|
+
const clients = await self.clients.matchAll({ includeUncontrolled: true })
|
|
126
|
+
clients.forEach((client) => client.postMessage(message))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const persistChanges = async () => {
|
|
130
|
+
const files = R.takeDirty()
|
|
131
|
+
if (Object.keys(files).length)
|
|
132
|
+
await broadcast({ type: 'sasjs-persist', files })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const readPayload = async (request) => {
|
|
136
|
+
if (request.method === 'GET' || request.method === 'HEAD')
|
|
137
|
+
return { params: {}, tables: [] }
|
|
138
|
+
const type = request.headers.get('content-type') || ''
|
|
139
|
+
if (!type.includes('application/json')) {
|
|
140
|
+
try {
|
|
141
|
+
const form = await request.formData()
|
|
142
|
+
const params = {}
|
|
143
|
+
const tables = []
|
|
144
|
+
for (const [k, v] of form.entries()) {
|
|
145
|
+
if (typeof v === 'string') params[k] = v
|
|
146
|
+
else
|
|
147
|
+
tables.push({
|
|
148
|
+
name: k,
|
|
149
|
+
filename: v.name || k + '.csv',
|
|
150
|
+
csv: await v.text()
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
return { params, tables }
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return { params: {}, tables: [] }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const body = await request.json()
|
|
159
|
+
return { params: body.params || {}, tables: body.tables || [], json: body }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const handle = async (request, url) => {
|
|
163
|
+
const path = url.pathname.replace(/\/+$/, '')
|
|
164
|
+
const query = {}
|
|
165
|
+
for (const [k, v] of url.searchParams.entries()) query[k] = v
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await R.ensureReady()
|
|
169
|
+
|
|
170
|
+
if (path.endsWith('/SASjsApi/info')) {
|
|
171
|
+
return json({
|
|
172
|
+
mode: 'desktop',
|
|
173
|
+
cors: 'disabled',
|
|
174
|
+
whiteList: [],
|
|
175
|
+
protocol: 'browser',
|
|
176
|
+
runTimes: ['js']
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (path.endsWith('/SASjsApi/drive/folder')) {
|
|
181
|
+
return json(await R.list(query._folderPath || ''))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (path.endsWith('/SASjsApi/drive/filetree')) {
|
|
185
|
+
return json(R.tree())
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (path.endsWith('/SASjsApi/drive/file') && request.method === 'GET') {
|
|
189
|
+
const file = R.read(query._filePath || '')
|
|
190
|
+
return file.binary
|
|
191
|
+
? new Response(toBytes(file.contents), {
|
|
192
|
+
headers: { 'content-type': 'application/octet-stream' }
|
|
193
|
+
})
|
|
194
|
+
: text(file.contents)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
path.endsWith('/SASjsApi/stp/execute') ||
|
|
199
|
+
path.endsWith('/SASjsApi/code/execute')
|
|
200
|
+
) {
|
|
201
|
+
const payload = await readPayload(request)
|
|
202
|
+
const merged = Object.assign({}, query, payload.params)
|
|
203
|
+
const program = merged._program
|
|
204
|
+
const code =
|
|
205
|
+
payload.json && payload.json.code ? payload.json.code : undefined
|
|
206
|
+
if (!program && !code)
|
|
207
|
+
return json({ message: '_program is required' }, 400)
|
|
208
|
+
|
|
209
|
+
const result = R.run({
|
|
210
|
+
program,
|
|
211
|
+
code,
|
|
212
|
+
params: merged,
|
|
213
|
+
tables: payload.tables
|
|
214
|
+
})
|
|
215
|
+
await persistChanges()
|
|
216
|
+
if (result.error) {
|
|
217
|
+
return json(
|
|
218
|
+
{
|
|
219
|
+
status: 'failure',
|
|
220
|
+
message: 'Job execution failed.',
|
|
221
|
+
error: result.error
|
|
222
|
+
},
|
|
223
|
+
500
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
const debug = String(merged._debug || '').length > 0
|
|
227
|
+
return weboutResponse(result, debug)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (path.includes('/SASjsApi/session')) {
|
|
231
|
+
return json({ state: 'completed' })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return json(
|
|
235
|
+
{ message: 'Not implemented by the browser host: ' + path },
|
|
236
|
+
404
|
|
237
|
+
)
|
|
238
|
+
} catch (e) {
|
|
239
|
+
const message = (e && e.stack) || String(e)
|
|
240
|
+
broadcast({ type: 'sasjs-host-error', path, message })
|
|
241
|
+
return json(
|
|
242
|
+
{ message: (e && e.message) || String(e) },
|
|
243
|
+
(e && e.status) || 500
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
self.addEventListener('fetch', (event) => {
|
|
249
|
+
const url = new URL(event.request.url)
|
|
250
|
+
if (url.origin !== self.location.origin) return
|
|
251
|
+
if (!url.pathname.includes('/SASjsApi/')) return
|
|
252
|
+
event.respondWith(handle(event.request, url))
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// ------------------------------------------------------------- host messages
|
|
256
|
+
self.addEventListener('message', (event) => {
|
|
257
|
+
const data = event.data || {}
|
|
258
|
+
const reply = (message) => event.source && event.source.postMessage(message)
|
|
259
|
+
|
|
260
|
+
switch (data.type) {
|
|
261
|
+
case 'sasjs-load':
|
|
262
|
+
event.waitUntil(
|
|
263
|
+
R.load(data.files || {}).then(async () => {
|
|
264
|
+
await persistChanges()
|
|
265
|
+
reply({
|
|
266
|
+
type: 'sasjs-load-ack',
|
|
267
|
+
boot: R.bootInfo(),
|
|
268
|
+
stats: R.stats()
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
)
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
case 'sasjs-seed':
|
|
275
|
+
event.waitUntil(
|
|
276
|
+
R.seed().then(async () => {
|
|
277
|
+
await persistChanges()
|
|
278
|
+
reply({
|
|
279
|
+
type: 'sasjs-seed-ack',
|
|
280
|
+
boot: R.bootInfo(),
|
|
281
|
+
stats: R.stats()
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
)
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
case 'sasjs-reset':
|
|
288
|
+
event.waitUntil(
|
|
289
|
+
R.reset().then(async () => {
|
|
290
|
+
await persistChanges()
|
|
291
|
+
reply({ type: 'sasjs-reset-ack', boot: R.bootInfo() })
|
|
292
|
+
})
|
|
293
|
+
)
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
case 'sasjs-snapshot':
|
|
297
|
+
reply({ type: 'sasjs-snapshot', files: R.dumpAll() })
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
case 'sasjs-stats':
|
|
301
|
+
event.waitUntil(
|
|
302
|
+
R.ensureReady().then(() => {
|
|
303
|
+
try {
|
|
304
|
+
reply({ type: 'sasjs-stats', boot: R.bootInfo(), stats: R.stats() })
|
|
305
|
+
} catch (e) {
|
|
306
|
+
// a corrupt entry fails the whole total, so the page is answered
|
|
307
|
+
// with the reason instead of waiting out its own timeout
|
|
308
|
+
reply({
|
|
309
|
+
type: 'sasjs-stats',
|
|
310
|
+
error: (e && e.message) || String(e)
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
)
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Target } from '@sasjs/utils';
|
|
2
|
+
export interface MockSiteResult {
|
|
3
|
+
outputFolder: string;
|
|
4
|
+
appLoc: string;
|
|
5
|
+
/** Drive entries written into the seed (programs plus any other content). */
|
|
6
|
+
driveEntries: number;
|
|
7
|
+
/** Executable .js programs found under services, jobs or tests. */
|
|
8
|
+
jsPrograms: number;
|
|
9
|
+
webFileCount: number;
|
|
10
|
+
/** True when a captured drive snapshot was shipped with the site. */
|
|
11
|
+
snapshot: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The bounds on what the browser drive seeds. A byte entry is carried as base64
|
|
15
|
+
* in vfs-seed.json and held in the worker's memory, so a large artefact - an
|
|
16
|
+
* export, a media file, a vendored archive - makes the generated site
|
|
17
|
+
* unloadable: one file over the per-file bound, or enough files to pass the
|
|
18
|
+
* total, is left off the drive and named in a warning, as every non-text file
|
|
19
|
+
* was when the drive carried text only. Text has no bound: a program folder is
|
|
20
|
+
* text by nature and the drive has always carried it.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MAX_BINARY_SEED_BYTES: number;
|
|
23
|
+
export declare const MAX_SEED_BYTES: number;
|
|
24
|
+
/**
|
|
25
|
+
* Builds a static site that runs the project's JS services (its mocks) in the
|
|
26
|
+
* browser, so a frontend can be tested with no server and no SAS.
|
|
27
|
+
*
|
|
28
|
+
* The output is an ordinary folder of static files: serve it over http(s) and
|
|
29
|
+
* the app behaves as though it were talking to a single-user SASjs server. The
|
|
30
|
+
* work happens in a service worker, so every request is still a real request -
|
|
31
|
+
* visible in DevTools > Network, marked "(from ServiceWorker)" - and the drive
|
|
32
|
+
* it writes to is persisted in IndexedDB, so refreshes keep their state.
|
|
33
|
+
*
|
|
34
|
+
* Everything comes from the target: appLoc, streamConfig.mockWebFolder for the
|
|
35
|
+
* output, streamConfig.webSourcePath for the built app, and the compile output
|
|
36
|
+
* for the drive.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildMockSite(target: Target): Promise<MockSiteResult>;
|