diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9a770f0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "autoexec" + ] +} diff --git a/src/controllers/Execution.ts b/src/controllers/Execution.ts new file mode 100644 index 0000000..04f835f --- /dev/null +++ b/src/controllers/Execution.ts @@ -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 = ` +${webout} +
+

SAS Log

+
${log}
+
+` + } + + session.inUse = false + + sessionController.deleteSession(session) + + return Promise.resolve(webout) + } +} diff --git a/src/controllers/Session.ts b/src/controllers/Session.ts new file mode 100644 index 0000000..3e9af19 --- /dev/null +++ b/src/controllers/Session.ts @@ -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 +} diff --git a/src/controllers/deploy.ts b/src/controllers/deploy.ts index 3df7c43..2c1f1db 100644 --- a/src/controllers/deploy.ts +++ b/src/controllers/deploy.ts @@ -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[] = [] diff --git a/src/controllers/index.ts b/src/controllers/index.ts index e90f306..2990104 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,2 +1,3 @@ -export * from './sas' export * from './deploy' +export * from './Session' +export * from './Execution' diff --git a/src/controllers/sas.ts b/src/controllers/sas.ts deleted file mode 100644 index afdd4dc..0000000 --- a/src/controllers/sas.ts +++ /dev/null @@ -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 => { - 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 = ` -${webout} -
-

SAS Log

-
${log}
-
-` - } - - return Promise.resolve(webout) - } else { - return Promise.resolve({ - log: log - }) - } -} diff --git a/src/routes/index.ts b/src/routes/index.ts index bd27d0a..1fc3bc9 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,23 +1,14 @@ import express from 'express' -import { processSas, createFileTree, getTreeExample } from '../controllers' +import { createFileTree, getTreeExample } from '../controllers' import { ExecutionResult, isRequestQuery, isFileTree } from '../types' +import path from 'path' +import { getTmpFilesFolderPath } from '../utils' +import { ExecutionController } from '../controllers' 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(`Executed!
-

Log is located:

${result.logPath}
-

Log:

`) +router.get('/', async (_, res) => { + res.status(200).send('Welcome to @sasjs/server API') }) router.post('/deploy', async (req, res) => { @@ -54,20 +45,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 { diff --git a/src/types/sas.ts b/src/types/Execution.ts similarity index 100% rename from src/types/sas.ts rename to src/types/Execution.ts diff --git a/src/types/fileTree.ts b/src/types/FileTree.ts similarity index 100% rename from src/types/fileTree.ts rename to src/types/FileTree.ts diff --git a/src/types/Process.d.ts b/src/types/Process.d.ts new file mode 100644 index 0000000..50313f7 --- /dev/null +++ b/src/types/Process.d.ts @@ -0,0 +1,5 @@ +declare namespace NodeJS { + export interface Process { + sessionController?: import('../controllers/Session').SessionController + } +} diff --git a/src/types/request.ts b/src/types/Request.ts similarity index 99% rename from src/types/request.ts rename to src/types/Request.ts index 26c872f..6bc7e2b 100644 --- a/src/types/request.ts +++ b/src/types/Request.ts @@ -1,4 +1,5 @@ import { MacroVars } from '@sasjs/utils' + export interface ExecutionQuery { _program: string macroVars?: MacroVars diff --git a/src/types/Session.ts b/src/types/Session.ts new file mode 100644 index 0000000..5484311 --- /dev/null +++ b/src/types/Session.ts @@ -0,0 +1,8 @@ +export interface Session { + id: string + ready: boolean + creationTimeStamp: string + deathTimeStamp: string + path: string + inUse: boolean +} diff --git a/src/types/index.ts b/src/types/index.ts index 7897c28..2377b59 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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' diff --git a/src/utils/file.ts b/src/utils/file.ts index bd3f759..fe09816 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -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('') diff --git a/src/utils/index.ts b/src/utils/index.ts index d0a70d5..31581eb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from './file' +export * from './sleep' diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts new file mode 100644 index 0000000..61d7522 --- /dev/null +++ b/src/utils/sleep.ts @@ -0,0 +1,3 @@ +export const sleep = async (delay: number) => { + await new Promise((resolve) => setTimeout(resolve, delay)) +} diff --git a/tsconfig.json b/tsconfig.json index 50262e5..391d326 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,8 @@ "esModuleInterop": true, "strict": true, "resolveJsonModule": true + }, + "ts-node": { + "files": true } }