mirror of
https://github.com/sasjs/server.git
synced 2025-12-15 21:14:35 +00:00
Merge pull request #22 from sasjs/session
Add session controller and execution controller
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
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 { createFolder, createFile, asyncForEach } from '@sasjs/utils'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
|
// REFACTOR: export FileTreeCpntroller
|
||||||
export const createFileTree = async (
|
export const createFileTree = async (
|
||||||
members: [FolderMember, ServiceMember],
|
members: [FolderMember, ServiceMember],
|
||||||
parentFolders: string[] = []
|
parentFolders: string[] = []
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './sas'
|
|
||||||
export * from './deploy'
|
export * from './deploy'
|
||||||
|
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,14 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { processSas, createFileTree, getTreeExample } from '../controllers'
|
import { createFileTree, getTreeExample } from '../controllers'
|
||||||
import { ExecutionResult, isRequestQuery, isFileTree } from '../types'
|
import { ExecutionResult, isRequestQuery, isFileTree } from '../types'
|
||||||
|
import path from 'path'
|
||||||
|
import { getTmpFilesFolderPath } from '../utils'
|
||||||
|
import { ExecutionController } from '../controllers'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (_, res) => {
|
||||||
const query = req.query
|
res.status(200).send('Welcome to @sasjs/server API')
|
||||||
|
|
||||||
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.post('/deploy', async (req, res) => {
|
router.post('/deploy', async (req, res) => {
|
||||||
@@ -54,20 +45,21 @@ router.get('/SASjsExecutor', async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.get('/SASjsExecutor/do', 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)) {
|
if (isRequestQuery(req.query)) {
|
||||||
await processSas({ ...req.query })
|
const sasCodePath = path
|
||||||
.then((result) => {
|
.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)
|
res.status(200).send(result)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err: {} | string) => {
|
||||||
res.status(400).send({
|
res.status(400).send({
|
||||||
status: 'failure',
|
status: 'failure',
|
||||||
message: 'Job execution failed.',
|
message: 'Job execution failed.',
|
||||||
...err
|
...(typeof err === 'object' ? err : { details: err })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} 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'
|
import { MacroVars } from '@sasjs/utils'
|
||||||
|
|
||||||
export interface ExecutionQuery {
|
export interface ExecutionQuery {
|
||||||
_program: string
|
_program: string
|
||||||
macroVars?: MacroVars
|
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'
|
// TODO: uppercase types
|
||||||
export * from './request'
|
export * from './Execution'
|
||||||
export * from './fileTree'
|
export * from './Request'
|
||||||
|
export * from './FileTree'
|
||||||
|
export * from './Session'
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ export const getTmpLogFolderPath = () => path.join(getTmpFolderPath(), 'logs')
|
|||||||
export const getTmpWeboutFolderPath = () =>
|
export const getTmpWeboutFolderPath = () =>
|
||||||
path.join(getTmpFolderPath(), 'webouts')
|
path.join(getTmpFolderPath(), 'webouts')
|
||||||
|
|
||||||
|
export const getTmpSessionsFolderPath = () =>
|
||||||
|
path.join(getTmpFolderPath(), 'sessions')
|
||||||
|
|
||||||
export const generateUniqueFileName = (fileName: string, extension = '') =>
|
export const generateUniqueFileName = (fileName: string, extension = '') =>
|
||||||
[
|
[
|
||||||
fileName,
|
fileName,
|
||||||
'-',
|
'-',
|
||||||
Math.round(Math.random() * 100000),
|
Math.round(Math.random() * 100000),
|
||||||
'-',
|
'-',
|
||||||
generateTimestamp(),
|
new Date().getTime(),
|
||||||
extension
|
extension
|
||||||
].join('')
|
].join('')
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './file'
|
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,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"files": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user