@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.
@@ -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>;