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/README.md b/README.md index 94b3e31..213ec77 100644 --- a/README.md +++ b/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`). diff --git a/package.json b/package.json index 72f5d82..b8562865 100644 --- a/package.json +++ b/package.json @@ -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", 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 d08995e..cfcf1ed 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,4 +1,5 @@ -export * from './sas' export * from './deploy' export * from './sasjsExecutor' export * from './sasjsDrive' +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/prod-server.ts b/src/prod-server.ts new file mode 100644 index 0000000..e652e39 --- /dev/null +++ b/src/prod-server.ts @@ -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}` + ) +}) diff --git a/src/routes/index.ts b/src/routes/index.ts index 4ed9d9a..f9b522a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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(`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) => { @@ -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 { 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 } }