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:
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"autoexec"
|
||||
]
|
||||
}
|
||||
14
README.md
14
README.md
@@ -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`).
|
||||
|
||||
@@ -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",
|
||||
|
||||
92
src/controllers/Execution.ts
Normal file
92
src/controllers/Execution.ts
Normal 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
127
src/controllers/Session.ts
Normal 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
|
||||
}
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './sas'
|
||||
export * from './deploy'
|
||||
export * from './sasjsExecutor'
|
||||
export * from './sasjsDrive'
|
||||
export * from './Session'
|
||||
export * from './Execution'
|
||||
|
||||
@@ -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
19
src/prod-server.ts
Normal 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}`
|
||||
)
|
||||
})
|
||||
@@ -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
5
src/types/Process.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare namespace NodeJS {
|
||||
export interface Process {
|
||||
sessionController?: import('../controllers/Session').SessionController
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MacroVars } from '@sasjs/utils'
|
||||
|
||||
export interface ExecutionQuery {
|
||||
_program: string
|
||||
macroVars?: MacroVars
|
||||
8
src/types/Session.ts
Normal file
8
src/types/Session.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Session {
|
||||
id: string
|
||||
ready: boolean
|
||||
creationTimeStamp: string
|
||||
deathTimeStamp: string
|
||||
path: string
|
||||
inUse: boolean
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './file'
|
||||
export * from './sleep'
|
||||
|
||||
3
src/utils/sleep.ts
Normal file
3
src/utils/sleep.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const sleep = async (delay: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
@@ -7,5 +7,8 @@
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"ts-node": {
|
||||
"files": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user