1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-10 11:24:35 +00:00

chore: Merge branch 'master' into homepage-sasjs-executor

This commit is contained in:
2021-10-14 18:38:03 +00:00
20 changed files with 302 additions and 131 deletions

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"cSpell.words": [
"autoexec"
]
}

View File

@@ -1,8 +1,16 @@
# @sasjs/server
# SASjs Server
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or it could even run locally on your desktop. It provides the following functionality:
* Virtual filesystem for storing SAS programs and other content
* Ability to execute Stored Programs from a URL
* Ability to create web apps using simple Desktop SAS
One major benefit of using SASjs Server (alongside other components of the SASjs framework such as the [CLI](https://cli.sasjs.io), [Adapter](https://adapter.sasjs.io) and [Core](https://core.sasjs.io) library) is that the projects you create can be very easily ported to SAS 9 (Stored Process server) or Viya (Job Execution server).
## Configuration
In order to configure `@sasjs/server`, add the following information to the `configuration` section of `package.json`:
Configuration is made in the `configuration` section of `package.json`:
- Provide path to SAS9 executable.
- Provide `@sasjs/server` desired port.
- Provide `SASjsServer` hostname and port (eg `localhost:5000`).

View File

@@ -5,6 +5,7 @@
"main": "./src/server.ts",
"scripts": {
"start": "nodemon ./src/server.ts",
"start:prod": "nodemon ./src/prod-server.ts",
"build": "rimraf build && tsc",
"semantic-release": "semantic-release -d",
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",

View File

@@ -0,0 +1,92 @@
import { getSessionController } from './'
import { readFile, fileExists, createFile } from '@sasjs/utils'
import path from 'path'
import { configuration } from '../../package.json'
import { promisify } from 'util'
import { execFile } from 'child_process'
import { Session } from '../types'
const execFilePromise = promisify(execFile)
export class ExecutionController {
async execute(
program = '',
autoExec?: string,
session?: Session,
vars?: any
) {
if (program) {
if (!(await fileExists(program))) {
throw 'ExecutionController: SAS file does not exist.'
}
program = await readFile(program)
if (vars) {
Object.keys(vars).forEach(
(key: string) => (program = `%let ${key}=${vars[key]};\n${program}`)
)
}
}
const sessionController = getSessionController()
if (!session) {
session = await sessionController.getSession()
session.inUse = true
}
let log = path.join(session.path, 'log.log')
let webout = path.join(session.path, 'webout.txt')
await createFile(webout, '')
program = `filename _webout "${webout}";\n${program}`
const code = path.join(session.path, 'code.sas')
if (!(await fileExists(code))) {
await createFile(code, program)
}
let additionalArgs: string[] = []
if (autoExec) additionalArgs = ['-AUTOEXEC', autoExec]
const { stdout, stderr } = await execFilePromise(configuration.sasPath, [
'-SYSIN',
code,
'-LOG',
log,
'-WORK',
session.path,
...additionalArgs,
process.platform === 'win32' ? '-nosplash' : ''
]).catch((err) => ({ stderr: err, stdout: '' }))
if (await fileExists(log)) log = await readFile(log)
else log = ''
if (stderr) return Promise.reject({ error: stderr, log: log })
if (await fileExists(webout)) webout = await readFile(webout)
else webout = ''
const debug = Object.keys(vars).find(
(key: string) => key.toLowerCase() === '_debug'
)
if (debug && vars[debug] >= 131) {
webout = `<html><body>
${webout}
<div style="text-align:left">
<hr /><h2>SAS Log</h2>
<pre>${log}</pre>
</div>
</body></html>`
}
session.inUse = false
sessionController.deleteSession(session)
return Promise.resolve(webout)
}
}

127
src/controllers/Session.ts Normal file
View File

@@ -0,0 +1,127 @@
import { Session } from '../types'
import { getTmpSessionsFolderPath, generateUniqueFileName } from '../utils'
import {
deleteFolder,
createFile,
fileExists,
deleteFile,
generateTimestamp
} from '@sasjs/utils'
import path from 'path'
import { ExecutionController } from './Execution'
export class SessionController {
private sessions: Session[] = []
private executionController: ExecutionController
constructor() {
this.executionController = new ExecutionController()
}
public async getSession() {
const readySessions = this.sessions.filter((sess: Session) => sess.ready)
const session = readySessions.length
? readySessions[0]
: await this.createSession()
if (readySessions.length < 2) this.createSession()
return session
}
private async createSession() {
const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(await getTmpSessionsFolderPath(), sessionId)
const autoExecContent = `data _null_;
/* remove the dummy SYSIN */
length fname $8;
rc=filename(fname,getoption('SYSIN') );
if rc = 0 and fexist(fname) then rc=fdelete(fname);
rc=filename(fname);
/* now wait for the real SYSIN */
slept=0;
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) );
slept=slept+sleep(0.01,1);
end;
run;
EOL`
const autoExec = path.join(sessionFolder, 'autoexec.sas')
await createFile(autoExec, autoExecContent)
await createFile(path.join(sessionFolder, 'code.sas'), '')
const creationTimeStamp = sessionId.split('-').pop() as string
const session: Session = {
id: sessionId,
ready: false,
creationTimeStamp: creationTimeStamp,
deathTimeStamp: (
parseInt(creationTimeStamp) +
15 * 60 * 1000 -
1000
).toString(),
path: sessionFolder,
inUse: false
}
this.scheduleSessionDestroy(session)
this.executionController.execute('', autoExec, session).catch(() => {})
this.sessions.push(session)
await this.waitForSession(session)
return session
}
public async waitForSession(session: Session) {
if (await fileExists(path.join(session.path, 'code.sas'))) {
while (await fileExists(path.join(session.path, 'code.sas'))) {}
await deleteFile(path.join(session.path, 'log.log'))
session.ready = true
return Promise.resolve(session)
} else {
session.ready = true
return Promise.resolve(session)
}
}
public async deleteSession(session: Session) {
await deleteFolder(session.path)
if (session.ready) {
this.sessions = this.sessions.filter(
(sess: Session) => sess.id !== session.id
)
}
}
private scheduleSessionDestroy(session: Session) {
setTimeout(async () => {
if (session.inUse) {
session.deathTimeStamp = session.deathTimeStamp + 1000 * 10
this.scheduleSessionDestroy(session)
} else {
await this.deleteSession(session)
}
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100)
}
}
export const getSessionController = () => {
if (process.sessionController) return process.sessionController
process.sessionController = new SessionController()
return process.sessionController
}

View File

@@ -3,6 +3,7 @@ import { getTmpFilesFolderPath } from '../utils/file'
import { createFolder, createFile, asyncForEach } from '@sasjs/utils'
import path from 'path'
// REFACTOR: export FileTreeCpntroller
export const createFileTree = async (
members: [FolderMember, ServiceMember],
parentFolders: string[] = []

View File

@@ -1,4 +1,5 @@
export * from './sas'
export * from './deploy'
export * from './sasjsExecutor'
export * from './sasjsDrive'
export * from './Session'
export * from './Execution'

View File

@@ -1,99 +0,0 @@
import { readFile, deleteFile, fileExists, createFile } from '@sasjs/utils'
import path from 'path'
import { ExecutionResult, ExecutionQuery } from '../types'
import {
getTmpFilesFolderPath,
getTmpLogFolderPath,
getTmpWeboutFolderPath,
generateUniqueFileName
} from '../utils'
import { configuration } from '../../package.json'
import { promisify } from 'util'
import { execFile } from 'child_process'
const execFilePromise = promisify(execFile)
export const processSas = async (query: ExecutionQuery): Promise<any> => {
const sasCodePath = path
.join(getTmpFilesFolderPath(), query._program)
.replace(new RegExp('/', 'g'), path.sep)
if (!(await fileExists(sasCodePath))) {
return Promise.reject({ error: 'SAS file does not exist.' })
}
const sasFile: string = sasCodePath.split(path.sep).pop() || 'default'
const sasLogPath = path.join(
getTmpLogFolderPath(),
generateUniqueFileName(sasFile.replace(/\.sas/g, ''), '.log')
)
await createFile(sasLogPath, '')
const sasWeboutPath = path.join(
getTmpWeboutFolderPath(),
generateUniqueFileName(sasFile.replace(/\.sas/g, ''), '.txt')
)
await createFile(sasWeboutPath, '')
let sasCode = await readFile(sasCodePath)
const vars: any = query
Object.keys(query).forEach(
(key: string) => (sasCode = `%let ${key}=${vars[key]};\n${sasCode}`)
)
sasCode = `filename _webout "${sasWeboutPath}";\n${sasCode}`
const tmpSasCodePath = sasCodePath.replace(
sasFile,
generateUniqueFileName(sasFile)
)
await createFile(tmpSasCodePath, sasCode)
const { stdout, stderr } = await execFilePromise(configuration.sasPath, [
'-SYSIN',
tmpSasCodePath,
'-log',
sasLogPath,
process.platform === 'win32' ? '-nosplash' : ''
]).catch((err) => ({ stderr: err, stdout: '' }))
let log = ''
if (sasLogPath && (await fileExists(sasLogPath))) {
log = await readFile(sasLogPath)
}
await deleteFile(sasLogPath)
await deleteFile(tmpSasCodePath)
if (stderr) return Promise.reject({ error: stderr, log: log })
if (await fileExists(sasWeboutPath)) {
let webout = await readFile(sasWeboutPath)
await deleteFile(sasWeboutPath)
const debug = Object.keys(query).find(
(key: string) => key.toLowerCase() === '_debug'
)
if (debug && (query as any)[debug] >= 131) {
webout = `<html><body>
${webout}
<div style="text-align:left">
<hr /><h2>SAS Log</h2>
<pre>${log}</pre>
</div>
</body></html>`
}
return Promise.resolve(webout)
} else {
return Promise.resolve({
log: log
})
}
}

19
src/prod-server.ts Normal file
View File

@@ -0,0 +1,19 @@
import path from 'path'
import { readFileSync } from 'fs'
import * as https from 'https'
import { configuration } from '../package.json'
import app from './app'
const keyPath = path.join('certificates', 'privkey.pem')
const certPath = path.join('certificates', 'fullchain.pem')
const key = readFileSync(keyPath)
const cert = readFileSync(certPath)
const httpsServer = https.createServer({ key, cert }, app)
httpsServer.listen(configuration.sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at https://localhost:${configuration.sasJsPort}`
)
})

View File

@@ -1,30 +1,19 @@
import express from 'express'
import path from 'path'
import {
processSas,
createFileTree,
getTreeExample,
sasjsExecutor,
sasjsDrive
sasjsDrive,
ExecutionController
} from '../controllers'
import { ExecutionResult, isRequestQuery, isFileTree } from '../types'
import { getTmpFilesFolderPath } from '../utils'
const router = express.Router()
router.get('/', async (req, res) => {
const query = req.query
if (!isRequestQuery(query)) {
res.send('Welcome to @sasjs/server API')
return
}
const result: ExecutionResult = await processSas(query)
res.send(`<b>Executed!</b><br>
<p>Log is located:</p> ${result.logPath}<br>
<p>Log:</p> <textarea style="width: 100%; height: 100%">${result.log}</textarea>`)
router.get('/', async (_, res) => {
res.status(200).send('Welcome to @sasjs/server API')
})
router.post('/deploy', async (req, res) => {
@@ -75,20 +64,21 @@ router.get('/SASjsExecutor', async (req, res) => {
})
router.get('/SASjsExecutor/do', async (req, res) => {
const queryEntries = Object.keys(req.query).map((entry: string) =>
entry.toLowerCase()
)
if (isRequestQuery(req.query)) {
await processSas({ ...req.query })
.then((result) => {
const sasCodePath = path
.join(getTmpFilesFolderPath(), req.query._program)
.replace(new RegExp('/', 'g'), path.sep)
await new ExecutionController()
.execute(sasCodePath, undefined, undefined, { ...req.query })
.then((result: {}) => {
res.status(200).send(result)
})
.catch((err) => {
.catch((err: {} | string) => {
res.status(400).send({
status: 'failure',
message: 'Job execution failed.',
...err
...(typeof err === 'object' ? err : { details: err })
})
})
} else {

5
src/types/Process.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace NodeJS {
export interface Process {
sessionController?: import('../controllers/Session').SessionController
}
}

View File

@@ -1,4 +1,5 @@
import { MacroVars } from '@sasjs/utils'
export interface ExecutionQuery {
_program: string
macroVars?: MacroVars

8
src/types/Session.ts Normal file
View File

@@ -0,0 +1,8 @@
export interface Session {
id: string
ready: boolean
creationTimeStamp: string
deathTimeStamp: string
path: string
inUse: boolean
}

View File

@@ -1,3 +1,5 @@
export * from './sas'
export * from './request'
export * from './fileTree'
// TODO: uppercase types
export * from './Execution'
export * from './Request'
export * from './FileTree'
export * from './Session'

View File

@@ -12,12 +12,15 @@ export const getTmpLogFolderPath = () => path.join(getTmpFolderPath(), 'logs')
export const getTmpWeboutFolderPath = () =>
path.join(getTmpFolderPath(), 'webouts')
export const getTmpSessionsFolderPath = () =>
path.join(getTmpFolderPath(), 'sessions')
export const generateUniqueFileName = (fileName: string, extension = '') =>
[
fileName,
'-',
Math.round(Math.random() * 100000),
'-',
generateTimestamp(),
new Date().getTime(),
extension
].join('')

View File

@@ -1 +1,2 @@
export * from './file'
export * from './sleep'

3
src/utils/sleep.ts Normal file
View File

@@ -0,0 +1,3 @@
export const sleep = async (delay: number) => {
await new Promise((resolve) => setTimeout(resolve, delay))
}

View File

@@ -7,5 +7,8 @@
"esModuleInterop": true,
"strict": true,
"resolveJsonModule": true
},
"ts-node": {
"files": true
}
}