1
0
mirror of https://github.com/sasjs/server.git synced 2026-07-23 21:25:29 +00:00

Merge remote-tracking branch 'origin/main' into issue-361

This commit is contained in:
YuryShkoda
2026-07-14 16:55:46 +03:00
53 changed files with 14067 additions and 24743 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+3 -3
View File
@@ -5,7 +5,7 @@ on:
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
@@ -28,7 +28,7 @@ jobs:
run: npm run lint-web run: npm run lint-web
build-api: build-api:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
@@ -66,7 +66,7 @@ jobs:
CI: true CI: true
build-web: build-web:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
+2 -2
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
@@ -56,4 +56,4 @@ jobs:
- name: Release - name: Release
run: | run: |
GITHUB_TOKEN=${{ secrets.GH_TOKEN }} semantic-release GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} semantic-release
+1
View File
@@ -0,0 +1 @@
ignore-scripts=true
+1 -3
View File
@@ -1,5 +1,3 @@
{ {
"cSpell.words": [ "cSpell.words": ["autoexec", "initialising"]
"autoexec"
]
} }
+134
View File
@@ -1,3 +1,137 @@
## [0.39.7](https://github.com/sasjs/server/compare/v0.39.6...v0.39.7) (2026-07-14)
### Bug Fixes
* **jsonwebtoken:** bumped version to avoid vulnerability ([1473925](https://github.com/sasjs/server/commit/1473925896db0e4472c9ef5ba64955527a1be2de))
## [0.39.6](https://github.com/sasjs/server/compare/v0.39.5...v0.39.6) (2026-07-14)
### Bug Fixes
* **api:** isolate drive.spec.ts's files folder from the real drive ([dec5191](https://github.com/sasjs/server/commit/dec51914911c10b66b43ebe95f77b0af63e2d03e))
* **api:** stop overwriting a failed JS/PY/R session back to completed ([4858245](https://github.com/sasjs/server/commit/4858245372fa19929b6e59c28148f5d004239c9d))
## [0.39.5](https://github.com/sasjs/server/compare/v0.39.4...v0.39.5) (2026-07-14)
### Bug Fixes
* **api:** harden mock SAS executable and timeouts for CI ([67fce47](https://github.com/sasjs/server/commit/67fce475a3c53b3c20919902edb45dce809dffb9))
* **api:** return prompt error response instead of hanging when a SAS session fails ([f84af4a](https://github.com/sasjs/server/commit/f84af4ac0639625fee4508e764f0eed07d7b4e8a))
* fix code.spec.ts failing on a fresh checkout (CI) ([0af6b63](https://github.com/sasjs/server/commit/0af6b63ffa770bfa2b77505d74778be517f8d631))
## [0.39.4](https://github.com/sasjs/server/compare/v0.39.3...v0.39.4) (2025-12-21)
### Bug Fixes
* **deps:** bump dependencies and resolve audit issues ([4f78202](https://github.com/sasjs/server/commit/4f782025dbcdfcbae6ca1fabb42ce1bc385e0162))
## [0.39.3](https://github.com/sasjs/server/compare/v0.39.2...v0.39.3) (2025-11-25)
### Bug Fixes
* (deps) bump @sasjs/core to 4.59.7 ([ab96653](https://github.com/sasjs/server/commit/ab966535642d08d4e8e984007b98c8fdffbe30f7))
* (deps) rerun npm i to sync ([225f381](https://github.com/sasjs/server/commit/225f381bdf8ad5aa2af8d75648df1dd5175e12e0))
## [0.39.2](https://github.com/sasjs/server/compare/v0.39.1...v0.39.2) (2025-09-25)
### Bug Fixes
* addressing test fail ([e51b204](https://github.com/sasjs/server/commit/e51b20421adc1598ea267c79b1fb4dbc085f97b9))
* packages missmatch ([379ea60](https://github.com/sasjs/server/commit/379ea604bcb5686b5299fae6a32f759c45b275ea))
* type libs ([6d123c3](https://github.com/sasjs/server/commit/6d123c3e23628c1d703eaa13142c77f0da970a55))
* typescript errors ([631e956](https://github.com/sasjs/server/commit/631e95604b64b1a96f2abade659348618f3b00b2))
* typescript errors ([198cd79](https://github.com/sasjs/server/commit/198cd79354254511c21ac1acfbf7b6bcfdab2af7))
## [0.39.1](https://github.com/sasjs/server/compare/v0.39.0...v0.39.1) (2025-03-13)
### Bug Fixes
* extra bit of sleep for file recognition ([f4768bf](https://github.com/sasjs/server/commit/f4768bffd3dbb2fe243966572ba74002024d96e1)), closes [#381](https://github.com/sasjs/server/issues/381)
# [0.39.0](https://github.com/sasjs/server/compare/v0.38.0...v0.39.0) (2024-10-31)
### Bug Fixes
* **api:** fixed condition in processProgram ([48a9a4d](https://github.com/sasjs/server/commit/48a9a4dd0e31f84209635382be4ec4bb2c3a9c0c))
### Features
* **api:** added session state endpoint ([6b6546c](https://github.com/sasjs/server/commit/6b6546c7ad0833347f8dc4cdba6ad19132f7aaef))
# [0.38.0](https://github.com/sasjs/server/compare/v0.37.0...v0.38.0) (2024-10-30)
### Features
* **api:** enabled query params in stp/trigger endpoint ([5cda9cd](https://github.com/sasjs/server/commit/5cda9cd5d8623b7ea2ecd989d7808f47ec866672))
# [0.37.0](https://github.com/sasjs/server/compare/v0.36.0...v0.37.0) (2024-10-29)
### Features
* **stp:** added trigger endpoint ([b0723f1](https://github.com/sasjs/server/commit/b0723f14448d60ffce4f2175cf8a73fc4d4dd0ee))
# [0.36.0](https://github.com/sasjs/server/compare/v0.35.4...v0.36.0) (2024-10-29)
### Features
* **code:** added code/trigger API endpoint ([ffcf193](https://github.com/sasjs/server/commit/ffcf193b87d811b166d79af74013776a253b50b0))
## [0.35.4](https://github.com/sasjs/server/compare/v0.35.3...v0.35.4) (2024-01-15)
### Bug Fixes
* **api:** fixed env issue in MacOS executable ([73d965d](https://github.com/sasjs/server/commit/73d965daf54b16c0921e4b18d11a1e6f8650884d))
## [0.35.3](https://github.com/sasjs/server/compare/v0.35.2...v0.35.3) (2023-11-07)
### Bug Fixes
* enable embedded LFs in JS STP vars ([7e8cbbf](https://github.com/sasjs/server/commit/7e8cbbf377b27a7f5dd9af0bc6605c01f302f5d9))
## [0.35.2](https://github.com/sasjs/server/compare/v0.35.1...v0.35.2) (2023-08-07)
### Bug Fixes
* add _debug as optional query param in swagger apis for GET stp/execute ([9586dbb](https://github.com/sasjs/server/commit/9586dbb2d0d6611061c9efdfb84030144f62c2ee))
## [0.35.1](https://github.com/sasjs/server/compare/v0.35.0...v0.35.1) (2023-07-25)
### Bug Fixes
* **log-separator:** log separator should always wrap log ([8940f4d](https://github.com/sasjs/server/commit/8940f4dc47abae2036b4fcdeb772c31a0ca07cca))
# [0.35.0](https://github.com/sasjs/server/compare/v0.34.2...v0.35.0) (2023-05-03)
### Bug Fixes
* **editor:** fixed log/webout/print tabs ([d2de9dc](https://github.com/sasjs/server/commit/d2de9dc13ef2e980286dd03cca5e22cea443ed0c))
* **execute:** added atribute indicating stp api ([e78f87f](https://github.com/sasjs/server/commit/e78f87f5c00038ea11261dffb525ac8f1024e40b))
* **execute:** fixed adding print output ([9aaffce](https://github.com/sasjs/server/commit/9aaffce82051d81bf39adb69942bb321e9795141))
* **execution:** removed empty webout from response ([6dd2f4f](https://github.com/sasjs/server/commit/6dd2f4f87673336135bc7a6de0d2e143e192c025))
* **webout:** fixed adding empty webout to response payload ([31df72a](https://github.com/sasjs/server/commit/31df72ad88fe2c771d0ef8445d6db9dd147c40c9))
### Features
* **editor:** parse print output in response payload ([eb42683](https://github.com/sasjs/server/commit/eb42683fff701bd5b4d2b68760fe0c3ecad573dd))
## [0.34.2](https://github.com/sasjs/server/compare/v0.34.1...v0.34.2) (2023-05-01) ## [0.34.2](https://github.com/sasjs/server/compare/v0.34.1...v0.34.2) (2023-05-01)
+1 -1
View File
@@ -158,7 +158,7 @@ CORS=
WHITELIST= WHITELIST=
# HELMET Cross Origin Embedder Policy # HELMET Cross Origin Embedder Policy
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true` # Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
# options: [true|false] default: true # options: [true|false] default: true
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`) # Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
HELMET_COEP= HELMET_COEP=
@@ -0,0 +1,38 @@
# Code execution & session diagrams
Mermaid diagrams describing how `@sasjs/server` executes submitted code
(SAS/JS/PY/R) against pooled "sessions". Written for fast context-loading by
an AI coding agent: each diagram is self-contained, node/edge labels carry
the actual file:line references, and prose is kept to the minimum needed to
disambiguate the diagram.
| File | Covers |
|---|---|
| [session-lifecycle.md](session-lifecycle.md) | `SessionState` state machine; how it differs between the SAS runtime and the JS/PY/R runtimes |
| [sas-execution-handshake.md](sas-execution-handshake.md) | The SYSIN/AUTOEXEC file-swap mechanism a SAS session uses to turn one long-lived `sas` process into a single-use execution slot |
| [request-execution-flow.md](request-execution-flow.md) | End-to-end flowchart from HTTP request to response, covering session-pool acquisition and both runtime branches (SAS vs JS/PY/R) |
## Core concept
A "session" is not a request-scoped object. It is a pooled, reusable
execution slot with a filesystem folder (`session.path`) and a state
(`SessionState`). For the SAS runtime specifically, a session also owns one
real OS process, spawned at session-*creation* time and consumed by exactly
one code submission (see `sas-execution-handshake.md`). For JS/PY/R, a
session is just an ID + folder; the actual interpreter process is spawned
fresh per request inside `processProgram`.
## Key source files
- `api/src/controllers/internal/Session.ts` — session pool + lifecycle
(`SessionController`, `SASSessionController`), `SessionState` transitions.
- `api/src/controllers/internal/processProgram.ts` — writes the submitted
code into the session and drives it to completion/failure, per runtime.
- `api/src/controllers/internal/Execution.ts``ExecutionController`,
the entry point controllers call; acquires a session, calls
`processProgram`, reads back `log.log`/`webout.txt`/headers, builds the
HTTP response (or a `SessionExecutionError` on failure).
- `api/src/controllers/internal/create{SAS,JS,Python,R}Program.ts`
per-runtime code templating (wraps the user's submitted code with
boilerplate: variable injection, `_webout` redirection, etc).
- `api/src/types/Session.ts``SessionState` enum and `Session` interface.
@@ -0,0 +1,64 @@
# Request execution flow (end-to-end)
Full path from an inbound HTTP request to the HTTP response, covering
session-pool acquisition and the two structurally different runtime
branches: SAS (reuses a parked long-lived process) vs JS/PY/R (spawns a
fresh interpreter process per request).
```mermaid
flowchart TD
A["HTTP request<br/>POST /SASjsApi/code/execute<br/>or /SASjsApi/stp/execute"] --> B["Controller<br/>code.ts / stp.ts<br/>try/catch wrapper"]
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:58-91"]
C --> D{"session already provided?<br/>(e.g. file-upload flow)"}
D -- no --> E["getSessionController(runTime).getSession()<br/>Session.ts:59-69"]
D -- yes --> F["use provided session"]
E --> G{"pending session<br/>available in pool?"}
G -- yes --> H["reuse it"]
G -- no --> I["createSession()<br/>SAS: spawns a real sas process, see<br/>sas-execution-handshake.md<br/>JS/PY/R: cheap - folder + id only"]
H --> J["pre-warm: if pool has fewer<br/>than 3 pending, fire off more<br/>createSession() calls (not awaited)<br/>Session.ts:66"]
I --> J
F --> K
J --> K["session.state = running<br/>Execution.ts:96"]
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:103-107"]
L --> M["processProgram(...)<br/>processProgram.ts:16-27"]
M --> N{"runTime?"}
N -- SAS --> O["createSASProgram():<br/>wrap user code with _webout<br/>filename, macro vars, autoexec"]
O --> P["write code.sas via .bkp + rename<br/>processProgram.ts:48-49"]
P --> Q["poll: while session.state !== completed<br/>processProgram.ts:52-58"]
Q -- "state becomes completed" --> R["resolve"]
Q -- "state becomes failed" --> S["throw Error(session.failureReason)"]
N -- "JS / PY / R" --> T["createJSProgram / createPythonProgram /<br/>createRProgram: wrap user code similarly"]
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:108-134"]
U -- "exit 0" --> R
U -- "exit non-zero" --> S
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:128-131"]
V --> W["build httpHeaders + result<br/>return ExecuteReturnRaw<br/>Execution.ts:128-151"]
W --> X["Controller: res.send(result)<br/>HTTP 200"]
S --> Y["catch in Execution.ts:109-126:<br/>read whatever log exists,<br/>throw SessionExecutionError(message, log)"]
Y --> Z["Controller catch block<br/>rethrow { code: 400, status: 'failure',<br/>message, error, log }"]
Z --> AA["res.status(err.code).send(err)<br/>HTTP 400 with complete log"]
```
## Notes
- **Session pool is per-runtime.** SAS sessions and JS/PY/R sessions live in
separate controllers/pools (`process.sasSessionController` vs
`process.sessionController`, `Session.ts:239-253`) - a pending SAS session
is never handed out for a JS request or vice versa.
- **The SAS branch and the JS/PY/R branch converge** on the same
success/failure signal shape: `session.state` becomes `completed` or
`failed` either way, and both paths flow through the same log/webout
reading logic in `Execution.ts` afterward. The mechanics of *how* that
state gets set differ substantially (see `session-lifecycle.md`).
- **`includePrintOutput`** (SAS only) additionally appends `output.lst`
content to the result when debug mode is on - omitted above for brevity;
see `Execution.ts:135-142`.
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
call the same `ExecutionController` methods without awaiting them and
immediately return `{ sessionId }`; the client polls
`GET /SASjsApi/session/{sessionId}/state` separately.
@@ -0,0 +1,81 @@
# SAS session execution handshake
How one long-lived `sas` process is turned into a single-use execution slot
via a SYSIN file-swap trick. This is the mechanism specific to
`RunTimeType.SAS`; JS/PY/R sessions spawn a fresh interpreter process per
request instead (see `request-execution-flow.md`).
```mermaid
sequenceDiagram
participant Client as HTTP client
participant Ctrl as Controller<br/>(code.ts / stp.ts)
participant Exec as ExecutionController<br/>(Execution.ts)
participant Pool as SASSessionController<br/>(Session.ts)
participant FS as Filesystem<br/>(session.path)
participant SAS as spawned "sas" process
Note over Pool,SAS: SESSION CREATION - happens ahead of any request,<br/>pool pre-warms up to 3 sessions (Session.ts:59-69)
Pool->>FS: createFile(code.sas, "") - empty dummy SYSIN (Session.ts:117-118)
Pool->>FS: readFile(sysInitCompiledPath) - compiled system-init<br/>macros, produced by `npm run compileSysInit` (Session.ts:105)
Note over Pool: throws if this file doesn't exist yet -<br/>session creation never reaches execFile below
Pool->>FS: createFile(autoexec.sas, autoExecContent) (Session.ts:108-114)
Pool->>SAS: execFile(sasLoc, -SYSIN code.sas -AUTOEXEC autoexec.sas -LOG log.log ...) (Session.ts:127-147)
activate SAS
Note right of SAS: process stays active (this box)<br/>from spawn until it finally exits below
SAS->>FS: autoexec step 1: delete code.sas, the dummy SYSIN (Session.ts:257-261)
Pool->>FS: waitForSession() polls fileExists(code.sas) (Session.ts:175-192)
FS-->>Pool: code.sas no longer exists
Pool->>Pool: session.state = pending (Session.ts:190)
SAS->>SAS: autoexec step 2: busy-wait for code.sas to reappear,<br/>up to 15 minutes (Session.ts:263-274)
Note over SAS: process is now idle, parked mid-autoexec,<br/>waiting on the filesystem.<br/>Counts as "pending" in the pool.
Note over Client,SAS: REQUEST ARRIVES - reuses the parked process above
Client->>Ctrl: POST .../execute { code, runTime: sas }
Ctrl->>Exec: executeProgram({ program, runTime: SAS, ... })
Exec->>Pool: getSession() returns the pending session (Session.ts:59-69)
Exec->>Exec: session.state = running (Execution.ts:96)
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:103-107)
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:110-121)
Pool->>FS: createSASProgram() wraps user code with macro vars,<br/>_webout filename, autoexec injection (createSASProgram.ts)
Pool->>FS: write code.sas.bkp, then rename to code.sas (processProgram.ts:48-49)
Note over FS: write-then-rename, not a direct write,<br/>so SAS never reads a partial file
FS-->>SAS: code.sas exists again
SAS->>SAS: autoexec step 2 loop exits, sleeps 0.01s, autoexec ends (Session.ts:267-271)
Note over SAS: -SYSIN has pointed at code.sas from the start,<br/>so SAS now executes ITS CONTENT as the main job
SAS->>FS: writes log.log, output.lst, webout.txt while running
Pool->>Pool: processProgram poll loop:<br/>while session.state !== completed (processProgram.ts:52-58)
alt program reaches EOF normally
SAS->>SAS: exits with code 0
SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152)
Pool->>Pool: session.state = completed
Pool-->>Exec: processProgram() resolves
else program aborts (fatal error, license failure, hard STOP)
SAS->>SAS: exits with non-zero code
SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163)
Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString()
Pool-->>Exec: processProgram() throws (processProgram.ts:53-55)
end
deactivate SAS
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:123, 128-131)
Exec-->>Ctrl: { httpHeaders, result }<br/>or throws SessionExecutionError{ message, log } (Execution.ts:109-126)
Ctrl-->>Client: 200 + result<br/>or 400 { status, message, error, log }
```
## Why this design
- SAS has meaningful startup cost (loading the engine, running system init
macros). Spawning a fresh `sas` process per request would pay that cost
every time. Instead, a process is spawned once and parked, ready to run
exactly one job the moment code shows up at a path it's already watching.
- The pool (`SessionController.getSession`, `Session.ts:59-69`) keeps up to
3 such parked processes ready, so most requests get an instant handoff
instead of waiting through the ~15-minute autoexec spin-wait or a cold
start.
- A session is single-use: once its process consumes the real SYSIN content
and exits (success or failure), that process is gone. The session object
itself is deleted later by `scheduleSessionDestroy` (`Session.ts:204-236`),
not reused for a second job.
+70
View File
@@ -0,0 +1,70 @@
# Session lifecycle (`SessionState`)
Enum defined in `api/src/types/Session.ts`. A `Session` is `{ id, state,
path, creationTimeStamp, deathTimeStamp, expiresAfterMins?, failureReason? }`.
Sessions live in an in-memory array on a per-runtime singleton controller
(`process.sasSessionController` for SAS, `process.sessionController` shared
by JS/PY/R — see `getSessionController()` in `Session.ts:239-253`).
```mermaid
stateDiagram-v2
[*] --> initialising: SAS runtime<br/>SASSessionController.createSession()<br/>Session.ts:77-95
[*] --> pending: JS/PY/R runtime, no process yet<br/>SessionController.createSession()<br/>Session.ts:30-57
initialising --> pending: dummy SYSIN file deleted<br/>by SAS's autoexec<br/>waitForSession(), Session.ts:175-192
initialising --> failed: spawned SAS process exits<br/>before handshake completes<br/>Session.ts:153-163, 184-188
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:94-96
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:116-120 (fresh process)
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:121-131<br/>failureReason = err.toString()
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
note right of initialising
SAS only. The real sas
executable is already
running, spin-waiting
inside its AUTOEXEC for
real code to arrive.
See sas-execution-handshake.md
end note
note right of pending
Session sits in the pool,
reusable. Pre-warmed up to
3 pending sessions per
runtime (getSession(),
Session.ts:59-69)
end note
note right of running
SAS: no new process spawned
here, request just feeds
code to the already-running
process.
JS/PY/R: a brand new
interpreter process is
spawned right now.
end note
```
## Consumers of `state`
| Reader | Location | Watches for |
|---|---|---|
| `waitForSession` | `Session.ts:175-192` | `failed` (breaks early) or the dummy SYSIN file disappearing (implies session survived init) |
| `processProgram` (SAS branch poll loop) | `processProgram.ts:52-58` | `completed` (success exit) or `failed` (throws, carrying `session.failureReason`) |
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
## Asymmetry between runtimes
- **SAS**: one OS process per session, spawned once at `createSession()`
time and reused for exactly one job (see `sas-execution-handshake.md`).
`running`/`completed`/`failed` are all driven by that *same* process's
eventual exit.
- **JS/PY/R**: a session is cheap (folder + id, no process). The interpreter
process is spawned fresh, per request, inside `processProgram.ts:115` and
its exit drives `completed`/`failed` directly in the same function - there
is no separate poll loop for these runtimes.
+6117 -14455
View File
File diff suppressed because it is too large Load Diff
+25 -26
View File
@@ -6,11 +6,9 @@
"scripts": { "scripts": {
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros", "initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros",
"prestart": "npm run initial", "prestart": "npm run initial",
"prebuild": "npm run initial",
"start": "NODE_ENV=development nodemon ./src/server.ts", "start": "NODE_ENV=development nodemon ./src/server.ts",
"start:prod": "node ./build/src/server.js", "start:prod": "node ./build/src/server.js",
"build": "rimraf build && tsc", "build": "npm run initial && rimraf build && tsc && npm run copy:files",
"postbuild": "npm run copy:files",
"swagger": "tsoa spec", "swagger": "tsoa spec",
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true", "prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage", "test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
@@ -18,10 +16,10 @@
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", "lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"exe": "npm run build && pkg .", "exe": "npm run build && pkg .",
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy", "copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
"public:copy": "cp -r ./public/ ./build/public/", "public:copy": "cpr ./public/ ./build/public/",
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/", "sasjsbuild:copy": "cpr ./sasjsbuild/ ./build/sasjsbuild/",
"sas:copy": "cp -r ./sas/ ./build/sas/", "sas:copy": "cpr ./sas/ ./build/sas/",
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/", "web:copy": "rimraf web && mkdir web && cpr ../web/build/ ./web/build/",
"compileSysInit": "ts-node ./scripts/compileSysInit.ts", "compileSysInit": "ts-node ./scripts/compileSysInit.ts",
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts", "copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
"downloadMacros": "ts-node ./scripts/downloadMacros.ts" "downloadMacros": "ts-node ./scripts/downloadMacros.ts"
@@ -48,25 +46,25 @@
}, },
"author": "4GL Ltd", "author": "4GL Ltd",
"dependencies": { "dependencies": {
"@sasjs/core": "^4.40.1", "@sasjs/core": "^4.59.10",
"@sasjs/utils": "3.2.0", "@sasjs/utils": "^3.5.6",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0", "connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.17.1", "express": "^4.21.2",
"express-session": "^1.17.2", "express-session": "^1.18.2",
"helmet": "^5.0.2", "helmet": "^5.0.2",
"joi": "^17.4.2", "joi": "^17.4.2",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^9.0.3",
"ldapjs": "2.3.3", "ldapjs": "2.3.3",
"mongoose": "^6.0.12", "mongoose": "^6.13.8",
"morgan": "^1.10.0", "morgan": "^1.10.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"rate-limiter-flexible": "2.4.1", "rate-limiter-flexible": "2.4.1",
"rotating-file-stream": "^3.0.4", "rotating-file-stream": "^3.0.4",
"swagger-ui-express": "4.3.0", "swagger-ui-express": "4.3.0",
"unzipper": "^0.10.11", "unzipper": "^0.12.3",
"url": "^0.10.3" "url": "^0.10.3"
}, },
"devDependencies": { "devDependencies": {
@@ -76,32 +74,33 @@
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/express": "^4.17.12", "@types/express": "^4.17.12",
"@types/express-session": "^1.17.4", "@types/express-session": "^1.17.4",
"@types/jest": "^26.0.24", "@types/jest": "^29.5.0",
"@types/jsonwebtoken": "^8.5.5", "@types/jsonwebtoken": "^8.5.5",
"@types/ldapjs": "^2.2.4", "@types/ldapjs": "^2.2.4",
"@types/morgan": "^1.9.3", "@types/morgan": "^1.9.3",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^15.12.2", "@types/node": "^20.0.0",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@types/swagger-ui-express": "^4.1.3", "@types/swagger-ui-express": "^4.1.3",
"@types/unzipper": "^0.10.5", "@types/unzipper": "^0.10.5",
"adm-zip": "^0.5.9", "adm-zip": "^0.5.9",
"axios": "0.27.2", "axios": "1.12.2",
"cpr": "^3.0.1",
"csrf": "^3.1.0", "csrf": "^3.1.0",
"dotenv": "^10.0.0", "dotenv": "^16.0.1",
"http-headers-validation": "^0.0.1", "http-headers-validation": "^0.0.1",
"jest": "^27.0.6", "jest": "^29.7.0",
"mongodb-memory-server": "8.11.4", "mongodb-memory-server": "8.11.4",
"nodejs-file-downloader": "4.10.2", "nodejs-file-downloader": "4.10.2",
"nodemon": "^2.0.7", "nodemon": "^3.0.0",
"pkg": "5.6.0", "pkg": "5.6.0",
"prettier": "^2.3.1", "prettier": "^3.0.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"supertest": "^6.1.3", "supertest": "^6.1.3",
"ts-jest": "^27.0.3", "ts-jest": "^29.1.0",
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"tsoa": "3.14.1", "tsoa": "3.14.1",
"typescript": "^4.3.2" "typescript": "^5.0.0"
}, },
"nodemonConfig": { "nodemonConfig": {
"ignore": [ "ignore": [
+155 -6
View File
@@ -97,17 +97,47 @@ components:
properties: properties:
code: code:
type: string type: string
description: 'Code of program' description: 'The code to be executed'
example: '* Code HERE;' example: '* Your Code HERE;'
runTime: runTime:
$ref: '#/components/schemas/RunTimeType' $ref: '#/components/schemas/RunTimeType'
description: 'runtime for program' description: 'The runtime for the code - eg SAS, JS, PY or R'
example: js example: js
required: required:
- code - code
- runTime - runTime
type: object type: object
additionalProperties: false additionalProperties: false
TriggerCodeResponse:
properties:
sessionId:
type: string
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
example: 20241028074744-54132-1730101664824
required:
- sessionId
type: object
additionalProperties: false
TriggerCodePayload:
properties:
code:
type: string
description: 'The code to be executed'
example: '* Your Code HERE;'
runTime:
$ref: '#/components/schemas/RunTimeType'
description: 'The runtime for the code - eg SAS, JS, PY or R'
example: sas
expiresAfterMins:
type: number
format: double
description: "Amount of minutes after the completion of the job when the session must be\ndestroyed."
example: 15
required:
- code
- runTime
type: object
additionalProperties: false
MemberType.folder: MemberType.folder:
enum: enum:
- folder - folder
@@ -559,6 +589,14 @@ components:
- id - id
type: object type: object
additionalProperties: false additionalProperties: false
SessionState:
enum:
- initialising
- pending
- running
- completed
- failed
type: string
ExecutePostRequestPayload: ExecutePostRequestPayload:
properties: properties:
_program: _program:
@@ -567,6 +605,16 @@ components:
example: /Public/somefolder/some.file example: /Public/somefolder/some.file
type: object type: object
additionalProperties: false additionalProperties: false
TriggerProgramResponse:
properties:
sessionId:
type: string
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
example: 20241028074744-54132-1730101664824
required:
- sessionId
type: object
additionalProperties: false
LoginPayload: LoginPayload:
properties: properties:
username: username:
@@ -796,7 +844,7 @@ paths:
- {type: string} - {type: string}
- {type: string, format: byte} - {type: string, format: byte}
description: 'Execute Code on the Specified Runtime' description: 'Execute Code on the Specified Runtime'
summary: 'Run Code and Return Webout Content and Log' summary: "Run Code and Return Webout Content, Log and Print output\nThe order of returned parts of the payload is:\n1. Webout (if present)\n2. Logs UUID (used as separator)\n3. Log\n4. Logs UUID (used as separator)\n5. Print (if present and if the runtime is SAS)\nPlease see"
tags: tags:
- Code - Code
security: security:
@@ -809,6 +857,30 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ExecuteCodePayload' $ref: '#/components/schemas/ExecuteCodePayload'
/SASjsApi/code/trigger:
post:
operationId: TriggerCode
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/TriggerCodeResponse'
description: 'Trigger Code on the Specified Runtime'
summary: 'Triggers code and returns SessionId immediately - does not wait for job completion'
tags:
- Code
security:
-
bearerAuth: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TriggerCodePayload'
/SASjsApi/drive/deploy: /SASjsApi/drive/deploy:
post: post:
operationId: Deploy operationId: Deploy
@@ -1764,6 +1836,30 @@ paths:
- -
bearerAuth: [] bearerAuth: []
parameters: [] parameters: []
'/SASjsApi/session/{sessionId}/state':
get:
operationId: SessionState
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/SessionState'
description: "The polling endpoint is currently implemented for single-server deployments only.<br>\nLoad balanced / grid topologies will be supported in a future release.<br>\nIf your site requires this, please reach out to SASjs Support."
summary: 'Get session state (initialising, pending, running, completed, failed).'
tags:
- Session
security:
-
bearerAuth: []
parameters:
-
in: path
name: sessionId
required: true
schema:
type: string
/SASjsApi/stp/execute: /SASjsApi/stp/execute:
get: get:
operationId: ExecuteGetRequest operationId: ExecuteGetRequest
@@ -1776,7 +1872,7 @@ paths:
anyOf: anyOf:
- {type: string} - {type: string}
- {type: string, format: byte} - {type: string, format: byte}
description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms" description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts additional URL parameters (converted to session variables)\nand file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
summary: 'Execute a Stored Program, returns _webout and (optionally) log.' summary: 'Execute a Stored Program, returns _webout and (optionally) log.'
tags: tags:
- STP - STP
@@ -1785,13 +1881,22 @@ paths:
bearerAuth: [] bearerAuth: []
parameters: parameters:
- -
description: 'Location of code in SASjs Drive' description: 'Location of Stored Program in SASjs Drive.'
in: query in: query
name: _program name: _program
required: true required: true
schema: schema:
type: string type: string
example: /Projects/myApp/some/program example: /Projects/myApp/some/program
-
description: 'Optional query param for setting debug mode (returns the session log in the response body).'
in: query
name: _debug
required: false
schema:
format: double
type: number
example: 131
post: post:
operationId: ExecutePostRequest operationId: ExecutePostRequest
responses: responses:
@@ -1825,6 +1930,50 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ExecutePostRequestPayload' $ref: '#/components/schemas/ExecutePostRequestPayload'
/SASjsApi/stp/trigger:
post:
operationId: TriggerProgram
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/TriggerProgramResponse'
description: 'Trigger Program on the Specified Runtime.'
summary: 'Triggers program and returns SessionId immediately - does not wait for program completion.'
tags:
- STP
security:
-
bearerAuth: []
parameters:
-
description: 'Location of code in SASjs Drive.'
in: query
name: _program
required: true
schema:
type: string
example: /Projects/myApp/some/program
-
description: 'Optional query param for setting debug mode.'
in: query
name: _debug
required: false
schema:
format: double
type: number
example: 131
-
description: 'Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.'
in: query
name: expiresAfterMins
required: false
schema:
format: double
type: number
example: 15
/: /:
get: get:
operationId: Home operationId: Home
+3 -2
View File
@@ -234,9 +234,10 @@ const verifyAuthCode = async (
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => { jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
if (err) return resolve(undefined) if (err) return resolve(undefined)
const payload = data as InfoJWT
const clientInfo: InfoJWT = { const clientInfo: InfoJWT = {
clientId: data?.clientId, clientId: payload?.clientId,
userId: data?.userId userId: payload?.userId
} }
if (clientInfo.clientId === clientId) { if (clientInfo.clientId === clientId) {
return resolve(clientInfo) return resolve(clientInfo)
+104 -7
View File
@@ -1,34 +1,71 @@
import express from 'express' import express from 'express'
import { Request, Security, Route, Tags, Post, Body } from 'tsoa' import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
import { ExecutionController } from './internal' import { ExecutionController, getSessionController } from './internal'
import { import {
getPreProgramVariables, getPreProgramVariables,
getUserAutoExec, getUserAutoExec,
ModeType, ModeType,
parseLogToArray,
RunTimeType RunTimeType
} from '../utils' } from '../utils'
interface ExecuteCodePayload { interface ExecuteCodePayload {
/** /**
* Code of program * The code to be executed
* @example "* Code HERE;" * @example "* Your Code HERE;"
*/ */
code: string code: string
/** /**
* runtime for program * The runtime for the code - eg SAS, JS, PY or R
* @example "js" * @example "js"
*/ */
runTime: RunTimeType runTime: RunTimeType
} }
interface TriggerCodePayload {
/**
* The code to be executed
* @example "* Your Code HERE;"
*/
code: string
/**
* The runtime for the code - eg SAS, JS, PY or R
* @example "sas"
*/
runTime: RunTimeType
/**
* Amount of minutes after the completion of the job when the session must be
* destroyed.
* @example 15
*/
expiresAfterMins?: number
}
interface TriggerCodeResponse {
/**
* `sessionId` is the ID of the session and the name of the temporary folder
* used to store code outputs.<br><br>
* For SAS, this would be the location of the SASWORK folder.<br><br>
* `sessionId` can be used to poll session state using the
* GET /SASjsApi/session/{sessionId}/state endpoint.
* @example "20241028074744-54132-1730101664824"
*/
sessionId: string
}
@Security('bearerAuth') @Security('bearerAuth')
@Route('SASjsApi/code') @Route('SASjsApi/code')
@Tags('Code') @Tags('Code')
export class CodeController { export class CodeController {
/** /**
* Execute Code on the Specified Runtime * Execute Code on the Specified Runtime
* @summary Run Code and Return Webout Content and Log * @summary Run Code and Return Webout Content, Log and Print output
* The order of returned parts of the payload is:
* 1. Webout (if present)
* 2. Logs UUID (used as separator)
* 3. Log
* 4. Logs UUID (used as separator)
* 5. Print (if present and if the runtime is SAS)
* Please see @sasjs/server/api/src/controllers/internal/Execution.ts for more information
*/ */
@Post('/execute') @Post('/execute')
public async executeCode( public async executeCode(
@@ -37,6 +74,18 @@ export class CodeController {
): Promise<string | Buffer> { ): Promise<string | Buffer> {
return executeCode(request, body) return executeCode(request, body)
} }
/**
* Trigger Code on the Specified Runtime
* @summary Triggers code and returns SessionId immediately - does not wait for job completion
*/
@Post('/trigger')
public async triggerCode(
@Request() request: express.Request,
@Body() body: TriggerCodePayload
): Promise<TriggerCodeResponse> {
return triggerCode(request, body)
}
} }
const executeCode = async ( const executeCode = async (
@@ -55,10 +104,58 @@ const executeCode = async (
preProgramVariables: getPreProgramVariables(req), preProgramVariables: getPreProgramVariables(req),
vars: { ...req.query, _debug: 131 }, vars: { ...req.query, _debug: 131 },
otherArgs: { userAutoExec }, otherArgs: { userAutoExec },
runTime: runTime runTime: runTime,
includePrintOutput: true
}) })
return result return result
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err,
log: err?.log
}
}
}
const triggerCode = async (
req: express.Request,
{ code, runTime, expiresAfterMins }: TriggerCodePayload
): Promise<TriggerCodeResponse> => {
const { user } = req
const userAutoExec =
process.env.MODE === ModeType.Server
? user?.autoExec
: await getUserAutoExec()
// get session controller based on runTime
const sessionController = getSessionController(runTime)
// get session
const session = await sessionController.getSession()
// add expiresAfterMins to session if provided
if (expiresAfterMins) {
// expiresAfterMins.used is set initially to false
session.expiresAfterMins = { mins: expiresAfterMins, used: false }
}
try {
// call executeProgram method of ExecutionController without awaiting
new ExecutionController().executeProgram({
program: code,
preProgramVariables: getPreProgramVariables(req),
vars: { ...req.query, _debug: 131 },
otherArgs: { userAutoExec },
runTime: runTime,
includePrintOutput: true,
session // session is provided
})
// return session id
return { sessionId: session.id }
} catch (err: any) { } catch (err: any) {
throw { throw {
code: 400, code: 400,
+60 -8
View File
@@ -2,7 +2,7 @@ import path from 'path'
import fs from 'fs' import fs from 'fs'
import { getSessionController, processProgram } from './' import { getSessionController, processProgram } from './'
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils' import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
import { PreProgramVars, Session, TreeNode } from '../../types' import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
import { import {
extractHeaders, extractHeaders,
getFilesFolder, getFilesFolder,
@@ -15,6 +15,24 @@ export interface ExecutionVars {
[key: string]: string | number | undefined [key: string]: string | number | undefined
} }
// Thrown when the session itself fails (e.g. SAS exits abnormally via
// %abort;). Carries the complete log - by the time this is thrown, the
// session's process has already exited, so the log file it wrote is final,
// not a partial/truncated snapshot.
export class SessionExecutionError extends Error {
constructor(
message: string,
public log?: string
) {
super(message)
// required for `instanceof` to work when compiling to ES5, since the
// default __extends helper does not preserve the prototype chain for
// classes extending built-ins like Error
Object.setPrototypeOf(this, SessionExecutionError.prototype)
}
}
export interface ExecuteReturnRaw { export interface ExecuteReturnRaw {
httpHeaders: HTTPHeaders httpHeaders: HTTPHeaders
result: string | Buffer result: string | Buffer
@@ -33,6 +51,7 @@ interface ExecuteFileParams {
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> { interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
program: string program: string
includePrintOutput?: boolean
} }
export class ExecutionController { export class ExecutionController {
@@ -67,18 +86,17 @@ export class ExecutionController {
otherArgs, otherArgs,
session: sessionByFileUpload, session: sessionByFileUpload,
runTime, runTime,
forceStringResult forceStringResult,
includePrintOutput
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> { }: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
const sessionController = getSessionController(runTime) const sessionController = getSessionController(runTime)
const session = const session =
sessionByFileUpload ?? (await sessionController.getSession()) sessionByFileUpload ?? (await sessionController.getSession())
session.inUse = true session.state = SessionState.running
session.consumed = true
const logPath = path.join(session.path, 'log.log') const logPath = path.join(session.path, 'log.log')
const headersPath = path.join(session.path, 'stpsrv_header.txt') const headersPath = path.join(session.path, 'stpsrv_header.txt')
const weboutPath = path.join(session.path, 'webout.txt') const weboutPath = path.join(session.path, 'webout.txt')
const tokenFile = path.join(session.path, 'reqHeaders.txt') const tokenFile = path.join(session.path, 'reqHeaders.txt')
@@ -88,6 +106,7 @@ export class ExecutionController {
preProgramVariables?.httpHeaders.join('\n') ?? '' preProgramVariables?.httpHeaders.join('\n') ?? ''
) )
try {
await processProgram( await processProgram(
program, program,
preProgramVariables, preProgramVariables,
@@ -100,6 +119,11 @@ export class ExecutionController {
logPath, logPath,
otherArgs otherArgs
) )
} catch (err: any) {
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
throw new SessionExecutionError(err.message, log)
}
const log = (await fileExists(logPath)) ? await readFile(logPath) : '' const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
const headersContent = (await fileExists(headersPath)) const headersContent = (await fileExists(headersPath))
@@ -120,13 +144,41 @@ export class ExecutionController {
: '' : ''
// it should be deleted by scheduleSessionDestroy // it should be deleted by scheduleSessionDestroy
session.inUse = false //
// Guarded: for JS/PY/R, processProgram sets state to `failed` itself
// (without throwing) when the interpreter process exits non-zero - if
// we unconditionally set `completed` here we'd silently overwrite that,
// and anything downstream inspecting session.state (e.g.
// scheduleSessionDestroy's expiresAfterMins branch in Session.ts) would
// see a crashed session mis-reported as successful.
if ((session.state as SessionState) !== SessionState.failed) {
session.state = SessionState.completed
}
const resultParts = []
// INFO: webout can be a Buffer, that is why it's length should be checked to determine if it is empty
if (webout && webout.length !== 0) resultParts.push(webout)
// INFO: log separator wraps the log from the beginning and the end
resultParts.push(process.logsUUID)
resultParts.push(log)
resultParts.push(process.logsUUID)
if (includePrintOutput && runTime === RunTimeType.SAS) {
const printOutputPath = path.join(session.path, 'output.lst')
const printOutput = (await fileExists(printOutputPath))
? await readFile(printOutputPath)
: ''
if (printOutput) resultParts.push(printOutput)
}
return { return {
httpHeaders, httpHeaders,
result: result:
isDebugOn(vars) || session.crashed isDebugOn(vars) || session.failureReason
? `${webout}\n${process.logsUUID}\n${log}` ? resultParts.join(`\n`)
: webout : webout
} }
} }
@@ -2,11 +2,8 @@ import { Request, RequestHandler } from 'express'
import multer from 'multer' import multer from 'multer'
import { uuidv4 } from '@sasjs/utils' import { uuidv4 } from '@sasjs/utils'
import { getSessionController } from '.' import { getSessionController } from '.'
import { import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils'
executeProgramRawValidation, import { SessionState } from '../../types'
getRunTimeAndFilePath,
RunTimeType
} from '../../utils'
export class FileUploadController { export class FileUploadController {
private storage = multer.diskStorage({ private storage = multer.diskStorage({
@@ -56,9 +53,8 @@ export class FileUploadController {
} }
const session = await sessionController.getSession() const session = await sessionController.getSession()
// marking consumed true, so that it's not available // change session state to 'running', so that it's not available for any other request
// as readySession for any other request session.state = SessionState.running
session.consumed = true
req.sasjsSession = session req.sasjsSession = session
+67 -28
View File
@@ -1,5 +1,5 @@
import path from 'path' import path from 'path'
import { Session } from '../../types' import { Session, SessionState } from '../../types'
import { promisify } from 'util' import { promisify } from 'util'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { import {
@@ -14,8 +14,7 @@ import {
createFile, createFile,
fileExists, fileExists,
generateTimestamp, generateTimestamp,
readFile, readFile
isWindows
} from '@sasjs/utils' } from '@sasjs/utils'
const execFilePromise = promisify(execFile) const execFilePromise = promisify(execFile)
@@ -24,7 +23,9 @@ export class SessionController {
protected sessions: Session[] = [] protected sessions: Session[] = []
protected getReadySessions = (): Session[] => protected getReadySessions = (): Session[] =>
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed) this.sessions.filter(
(session: Session) => session.state === SessionState.pending
)
protected async createSession(): Promise<Session> { protected async createSession(): Promise<Session> {
const sessionId = generateUniqueFileName(generateTimestamp()) const sessionId = generateUniqueFileName(generateTimestamp())
@@ -40,19 +41,18 @@ export class SessionController {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: true, state: SessionState.pending,
inUse: true,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder
} }
const headersPath = path.join(session.path, 'stpsrv_header.txt') const headersPath = path.join(session.path, 'stpsrv_header.txt')
await createFile(headersPath, 'content-type: text/html; charset=utf-8') await createFile(headersPath, 'content-type: text/html; charset=utf-8')
this.sessions.push(session) this.sessions.push(session)
return session return session
} }
@@ -67,6 +67,10 @@ export class SessionController {
return session return session
} }
public getSessionById(id: string) {
return this.sessions.find((session) => session.id === id)
}
} }
export class SASSessionController extends SessionController { export class SASSessionController extends SessionController {
@@ -84,10 +88,7 @@ export class SASSessionController extends SessionController {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: false, state: SessionState.initialising,
inUse: false,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder
@@ -145,13 +146,20 @@ ${autoExecContent}`
process.sasLoc!.endsWith('sas.exe') ? session.path : '' process.sasLoc!.endsWith('sas.exe') ? session.path : ''
]) ])
.then(() => { .then(() => {
session.completed = true session.state = SessionState.completed
process.logger.info('session completed', session) process.logger.info('session completed', session)
}) })
.catch((err) => { .catch((err) => {
session.completed = true session.state = SessionState.failed
session.crashed = err.toString()
process.logger.error('session crashed', session.id, session.crashed) session.failureReason = err.toString()
process.logger.error(
'session crashed',
session.id,
session.failureReason
)
}) })
// we have a triggered session - add to array // we have a triggered session - add to array
@@ -168,15 +176,19 @@ ${autoExecContent}`
const codeFilePath = path.join(session.path, 'code.sas') const codeFilePath = path.join(session.path, 'code.sas')
// TODO: don't wait forever // TODO: don't wait forever
while ((await fileExists(codeFilePath)) && !session.crashed) {} while (
(await fileExists(codeFilePath)) &&
session.state !== SessionState.failed
) {}
if (session.crashed) if (session.state === SessionState.failed) {
process.logger.error( process.logger.error(
'session crashed! while waiting to be ready', 'session crashed! while waiting to be ready',
session.crashed session.failureReason
) )
} else {
session.ready = true session.state = SessionState.pending
}
} }
private async deleteSession(session: Session) { private async deleteSession(session: Session) {
@@ -190,17 +202,37 @@ ${autoExecContent}`
} }
private scheduleSessionDestroy(session: Session) { private scheduleSessionDestroy(session: Session) {
setTimeout(async () => { setTimeout(
if (session.inUse) { async () => {
if (session.state === SessionState.running) {
// adding 10 more minutes // adding 10 more minutes
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000 const newDeathTimeStamp =
parseInt(session.deathTimeStamp) + 10 * 60 * 1000
session.deathTimeStamp = newDeathTimeStamp.toString() session.deathTimeStamp = newDeathTimeStamp.toString()
this.scheduleSessionDestroy(session)
} else {
const { expiresAfterMins } = session
// delay session destroy if expiresAfterMins present
if (expiresAfterMins && session.state !== SessionState.completed) {
// calculate session death time using expiresAfterMins
const newDeathTimeStamp =
parseInt(session.deathTimeStamp) +
expiresAfterMins.mins * 60 * 1000
session.deathTimeStamp = newDeathTimeStamp.toString()
// set expiresAfterMins to true to avoid using it again
session.expiresAfterMins!.used = true
this.scheduleSessionDestroy(session) this.scheduleSessionDestroy(session)
} else { } else {
await this.deleteSession(session) await this.deleteSession(session)
} }
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100) }
},
parseInt(session.deathTimeStamp) - new Date().getTime() - 100
)
} }
} }
@@ -228,9 +260,16 @@ data _null_;
rc=filename(fname,getoption('SYSIN') ); rc=filename(fname,getoption('SYSIN') );
if rc = 0 and fexist(fname) then rc=fdelete(fname); if rc = 0 and fexist(fname) then rc=fdelete(fname);
rc=filename(fname); rc=filename(fname);
/* now wait for the real SYSIN */ /* now wait for the real SYSIN (location of code.sas) */
slept=0; slept=0;fname='';
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) ); do until (slept>(60*15));
rc=filename(fname,getoption('SYSIN'));
if rc = 0 and fexist(fname) then do;
putlog fname=;
rc=filename(fname);
rc=sleep(0.01,1); /* wait just a little more */
stop;
end;
slept=slept+sleep(0.01,1); slept=slept+sleep(0.01,1);
end; end;
stop; stop;
@@ -15,7 +15,7 @@ export const createJSProgram = async (
) => { ) => {
const varStatments = Object.keys(vars).reduce( const varStatments = Object.keys(vars).reduce(
(computed: string, key: string) => (computed: string, key: string) =>
`${computed}const ${key} = '${vars[key]}';\n`, `${computed}const ${key} = \`${vars[key]}\`;\n`,
'' ''
) )
+17 -6
View File
@@ -3,7 +3,7 @@ import { WriteStream, createWriteStream } from 'fs'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { once } from 'stream' import { once } from 'stream'
import { createFile, moveFile } from '@sasjs/utils' import { createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, Session } from '../../types' import { PreProgramVars, Session, SessionState } from '../../types'
import { RunTimeType } from '../../utils' import { RunTimeType } from '../../utils'
import { import {
ExecutionVars, ExecutionVars,
@@ -49,7 +49,11 @@ export const processProgram = async (
await moveFile(codePath + '.bkp', codePath) await moveFile(codePath + '.bkp', codePath)
// we now need to poll the session status // we now need to poll the session status
while (!session.completed) { while (session.state !== SessionState.completed) {
if (session.state === SessionState.failed) {
throw new Error(session.failureReason || 'SAS session failed')
}
await delay(50) await delay(50)
} }
} else { } else {
@@ -114,13 +118,20 @@ export const processProgram = async (
await execFilePromise(executablePath, [codePath], writeStream) await execFilePromise(executablePath, [codePath], writeStream)
.then(() => { .then(() => {
session.completed = true session.state = SessionState.completed
process.logger.info('session completed', session) process.logger.info('session completed', session)
}) })
.catch((err) => { .catch((err) => {
session.completed = true session.state = SessionState.failed
session.crashed = err.toString()
process.logger.error('session crashed', session.id, session.crashed) session.failureReason = err.toString()
process.logger.error(
'session crashed',
session.id,
session.failureReason
)
}) })
// copy the code file to log and end write stream // copy the code file to log and end write stream
@@ -0,0 +1,117 @@
import path from 'path'
import os from 'os'
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
import * as ProcessProgramModule from '../processProgram'
import { ExecutionController, SessionExecutionError } from '../Execution'
import { Session, SessionState, PreProgramVars } from '../../../types'
import { RunTimeType } from '../../../utils'
const preProgramVariables: PreProgramVars = {
username: 'testUser',
userId: 1,
displayName: 'Test User',
serverUrl: 'http://localhost:5000',
httpHeaders: []
}
describe('ExecutionController.executeProgram', () => {
let session: Session
beforeEach(() => {
const sessionId = `test-session-${generateTimestamp()}`
session = {
id: sessionId,
state: SessionState.pending,
creationTimeStamp: '0',
deathTimeStamp: '0',
path: path.join(os.tmpdir(), sessionId)
}
})
afterEach(async () => {
jest.restoreAllMocks()
await deleteFolder(session.path)
})
// Regression coverage: for JS/PY/R, processProgram sets session.state to
// `failed` itself (without throwing) on a non-zero interpreter exit.
// ExecutionController.executeProgram used to unconditionally overwrite
// that back to `completed` right after processProgram returned, silently
// losing the failure for anything downstream that inspects session.state.
describe('JS/PY/R failure path', () => {
it('does not overwrite a failed session state back to completed', async () => {
// mirrors processProgram's real JS/PY/R branch on a non-zero exit:
// it sets state/failureReason itself and resolves - it does not throw
jest
.spyOn(ProcessProgramModule, 'processProgram')
.mockImplementation(async () => {
session.state = SessionState.failed
session.failureReason = 'Error: process exited with code 1'
})
const controller = new ExecutionController()
await controller.executeProgram({
program: 'throw new Error("boom")',
preProgramVariables,
vars: {},
session,
runTime: RunTimeType.JS
})
expect(session.state).toBe(SessionState.failed)
})
it('still marks a genuinely successful session as completed', async () => {
jest
.spyOn(ProcessProgramModule, 'processProgram')
.mockImplementation(async () => {
session.state = SessionState.completed
})
const controller = new ExecutionController()
await controller.executeProgram({
program: 'console.log("hello")',
preProgramVariables,
vars: {},
session,
runTime: RunTimeType.JS
})
expect(session.state).toBe(SessionState.completed)
})
})
describe('SAS failure path', () => {
it('throws a SessionExecutionError carrying the complete log when the session fails', async () => {
const logPath = path.join(session.path, 'log.log')
const logContent =
'NOTE: SAS session\nERROR: SAS session terminated. See log for details.\n'
await createFile(logPath, logContent)
jest
.spyOn(ProcessProgramModule, 'processProgram')
.mockImplementation(async () => {
throw new Error('ERROR: SAS session terminated. See log for details.')
})
const controller = new ExecutionController()
const resultPromise = controller.executeProgram({
program: '%abort;',
preProgramVariables,
vars: {},
session,
runTime: RunTimeType.SAS
})
await expect(resultPromise).rejects.toBeInstanceOf(SessionExecutionError)
await expect(resultPromise).rejects.toMatchObject({
log: logContent,
message: expect.stringContaining('SAS session terminated')
})
})
})
})
@@ -0,0 +1,109 @@
import path from 'path'
import os from 'os'
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
import { processProgram } from '../processProgram'
import { Session, SessionState, PreProgramVars } from '../../../types'
import {
RunTimeType,
getSessionsFolder,
generateUniqueFileName
} from '../../../utils'
const preProgramVariables: PreProgramVars = {
username: 'testUser',
userId: 1,
displayName: 'Test User',
serverUrl: 'http://localhost:5000',
httpHeaders: []
}
const makeSession = (): Session => {
const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(getSessionsFolder(), sessionId)
const creationTimeStamp = sessionId.split('-').pop() as string
const deathTimeStamp = (
parseInt(creationTimeStamp) +
15 * 60 * 1000 -
1000
).toString()
return {
id: sessionId,
state: SessionState.running,
creationTimeStamp,
deathTimeStamp,
path: sessionFolder
}
}
describe('processProgram (SAS runtime)', () => {
let session: Session
let logPath: string
let weboutPath: string
let headersPath: string
let tokenFile: string
beforeAll(() => {
const root = path.join(
os.tmpdir(),
`sasjs-processProgram-spec-${generateTimestamp()}`
)
process.sasjsRoot = root
process.driveLoc = path.join(root, 'drive')
})
beforeEach(async () => {
session = makeSession()
logPath = path.join(session.path, 'log.log')
weboutPath = path.join(session.path, 'webout.txt')
headersPath = path.join(session.path, 'stpsrv_header.txt')
tokenFile = path.join(session.path, 'reqHeaders.txt')
await createFile(weboutPath, '')
})
afterEach(async () => {
await deleteFolder(session.path)
})
it('rejects instead of hanging when the session fails (e.g. %abort;)', async () => {
setTimeout(() => {
session.state = SessionState.failed
session.failureReason =
'ERROR: SAS session terminated. See log for details.'
}, 100)
await expect(
processProgram(
'%abort;',
preProgramVariables,
{},
session,
weboutPath,
headersPath,
tokenFile,
RunTimeType.SAS,
logPath
)
).rejects.toThrow(/SAS session terminated/)
}, 3000)
it('resolves without throwing when the session completes normally', async () => {
setTimeout(() => {
session.state = SessionState.completed
}, 100)
await expect(
processProgram(
'%put hello world;',
preProgramVariables,
{},
session,
weboutPath,
headersPath,
tokenFile,
RunTimeType.SAS,
logPath
)
).resolves.toBeUndefined()
}, 3000)
})
+34
View File
@@ -1,6 +1,8 @@
import express from 'express' import express from 'express'
import { Request, Security, Route, Tags, Example, Get } from 'tsoa' import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
import { UserResponse } from './user' import { UserResponse } from './user'
import { getSessionController } from './internal'
import { SessionState } from '../types'
interface SessionResponse extends Omit<UserResponse, 'uid'> { interface SessionResponse extends Omit<UserResponse, 'uid'> {
id: string id: string
@@ -28,6 +30,18 @@ export class SessionController {
): Promise<SessionResponse> { ): Promise<SessionResponse> {
return session(request) return session(request)
} }
/**
* The polling endpoint is currently implemented for single-server deployments only.<br>
* Load balanced / grid topologies will be supported in a future release.<br>
* If your site requires this, please reach out to SASjs Support.
* @summary Get session state (initialising, pending, running, completed, failed).
* @example completed
*/
@Get('/:sessionId/state')
public async sessionState(sessionId: string): Promise<SessionState> {
return sessionState(sessionId)
}
} }
const session = (req: express.Request) => ({ const session = (req: express.Request) => ({
@@ -37,3 +51,23 @@ const session = (req: express.Request) => ({
isAdmin: req.user!.isAdmin, isAdmin: req.user!.isAdmin,
needsToUpdatePassword: req.user!.needsToUpdatePassword needsToUpdatePassword: req.user!.needsToUpdatePassword
}) })
const sessionState = (sessionId: string): SessionState => {
for (let runTime of process.runTimes) {
// get session controller for each available runTime
const sessionController = getSessionController(runTime)
// get session by sessionId
const session = sessionController.getSessionById(sessionId)
// return session state if session was found
if (session) {
return session.state
}
}
throw {
code: 404,
message: `Session with ID '${sessionId}' was not found.`
}
}
+120 -7
View File
@@ -1,10 +1,12 @@
import express from 'express' import express from 'express'
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa' import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
import { ExecutionController, ExecutionVars } from './internal' import {
ExecutionController,
ExecutionVars,
getSessionController
} from './internal'
import { import {
getPreProgramVariables, getPreProgramVariables,
HTTPHeaders,
LogLine,
makeFilesNamesMap, makeFilesNamesMap,
getRunTimeAndFilePath getRunTimeAndFilePath
} from '../utils' } from '../utils'
@@ -18,6 +20,36 @@ interface ExecutePostRequestPayload {
_program?: string _program?: string
} }
interface TriggerProgramPayload {
/**
* Location of SAS program.
* @example "/Public/somefolder/some.file"
*/
_program: string
/**
* Amount of minutes after the completion of the program when the session must be
* destroyed.
* @example 15
*/
expiresAfterMins?: number
/**
* Query param for setting debug mode.
*/
_debug?: number
}
interface TriggerProgramResponse {
/**
* `sessionId` is the ID of the session and the name of the temporary folder
* used to store program outputs.<br><br>
* For SAS, this would be the location of the SASWORK folder.<br><br>
* `sessionId` can be used to poll session state using the
* GET /SASjsApi/session/{sessionId}/state endpoint.
* @example "20241028074744-54132-1730101664824"
*/
sessionId: string
}
@Security('bearerAuth') @Security('bearerAuth')
@Route('SASjsApi/stp') @Route('SASjsApi/stp')
@Tags('STP') @Tags('STP')
@@ -25,20 +57,31 @@ export class STPController {
/** /**
* Trigger a Stored Program using the _program URL parameter. * Trigger a Stored Program using the _program URL parameter.
* *
* Accepts URL parameters and file uploads. For more details, see docs: * Accepts additional URL parameters (converted to session variables)
* and file uploads. For more details, see docs:
* *
* https://server.sasjs.io/storedprograms * https://server.sasjs.io/storedprograms
* *
* @summary Execute a Stored Program, returns _webout and (optionally) log. * @summary Execute a Stored Program, returns _webout and (optionally) log.
* @param _program Location of code in SASjs Drive * @param _program Location of Stored Program in SASjs Drive.
* @param _debug Optional query param for setting debug mode (returns the session log in the response body).
* @example _program "/Projects/myApp/some/program" * @example _program "/Projects/myApp/some/program"
* @example _debug 131
*/ */
@Get('/execute') @Get('/execute')
public async executeGetRequest( public async executeGetRequest(
@Request() request: express.Request, @Request() request: express.Request,
@Query() _program: string @Query() _program: string,
@Query() _debug?: number
): Promise<string | Buffer> { ): Promise<string | Buffer> {
const vars = request.query as ExecutionVars let vars = request.query as ExecutionVars
if (_debug) {
vars = {
...vars,
_debug
}
}
return execute(request, _program, vars) return execute(request, _program, vars)
} }
@@ -69,6 +112,26 @@ export class STPController {
return execute(request, program!, vars, otherArgs) return execute(request, program!, vars, otherArgs)
} }
/**
* Trigger Program on the Specified Runtime.
* @summary Triggers program and returns SessionId immediately - does not wait for program completion.
* @param _program Location of code in SASjs Drive.
* @param expiresAfterMins Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.
* @param _debug Optional query param for setting debug mode.
* @example _program "/Projects/myApp/some/program"
* @example _debug 131
* @example expiresAfterMins 15
*/
@Post('/trigger')
public async triggerProgram(
@Request() request: express.Request,
@Query() _program: string,
@Query() _debug?: number,
@Query() expiresAfterMins?: number
): Promise<TriggerProgramResponse> {
return triggerProgram(request, { _program, _debug, expiresAfterMins })
}
} }
const execute = async ( const execute = async (
@@ -98,6 +161,56 @@ const execute = async (
} }
return result return result
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err,
log: err?.log
}
}
}
const triggerProgram = async (
req: express.Request,
{ _program, _debug, expiresAfterMins }: TriggerProgramPayload
): Promise<TriggerProgramResponse> => {
try {
// put _program query param into vars object
const vars: { [key: string]: string | number } = { _program }
// if present add _debug query param to vars object
if (_debug) {
vars._debug = _debug
}
// get code path and runTime
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
// get session controller based on runTime
const sessionController = getSessionController(runTime)
// get session
const session = await sessionController.getSession()
// add expiresAfterMins to session if provided
if (expiresAfterMins) {
// expiresAfterMins.used is set initially to false
session.expiresAfterMins = { mins: expiresAfterMins, used: false }
}
// call executeFile method of ExecutionController without awaiting
new ExecutionController().executeFile({
programPath: codePath,
runTime,
preProgramVariables: getPreProgramVariables(req),
vars,
session
})
// return session id
return { sessionId: session.id }
} catch (err: any) { } catch (err: any) {
throw { throw {
code: 400, code: 400,
+1 -1
View File
@@ -279,7 +279,7 @@ const getUser = async (
username: user.username, username: user.username,
isActive: user.isActive, isActive: user.isActive,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
autoExec: getAutoExec ? user.autoExec ?? '' : undefined, autoExec: getAutoExec ? (user.autoExec ?? '') : undefined,
groups: user.groups groups: user.groups
} }
} }
+5 -2
View File
@@ -106,7 +106,10 @@ const login = async (
const rateLimiter = RateLimiter.getInstance() const rateLimiter = RateLimiter.getInstance()
if (!validPass) { if (!validPass) {
const retrySecs = await rateLimiter.consume(req.ip, user?.username) const retrySecs = await rateLimiter.consume(
req.ip || 'unknown',
user?.username
)
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs) if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
} }
@@ -114,7 +117,7 @@ const login = async (
if (!validPass) throw errors.invalidPassword if (!validPass) throw errors.invalidPassword
// Reset on successful authorization // Reset on successful authorization
rateLimiter.resetOnSuccess(req.ip, user.username) rateLimiter.resetOnSuccess(req.ip || 'unknown', user.username)
req.session.loggedIn = true req.session.loggedIn = true
req.session.user = { req.session.user = {
+3 -3
View File
@@ -37,10 +37,10 @@ export const authenticateAccessToken: RequestHandler = async (
if (user.isActive) { if (user.isActive) {
req.user = user req.user = user
return csrfProtection(req, res, nextFunction) return csrfProtection(req, res, nextFunction)
} else return res.sendStatus(401) } else return res.status(401).send('Unauthorized')
} }
} }
return res.sendStatus(401) return res.status(401).send('Unauthorized')
} }
await authenticateToken( await authenticateToken(
@@ -118,6 +118,6 @@ const authenticateToken = async (
return next() return next()
} }
res.sendStatus(401) res.status(401).send('Unauthorized')
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import { convertSecondsToHms } from '@sasjs/utils'
import { RateLimiter } from '../utils' import { RateLimiter } from '../utils'
export const bruteForceProtection: RequestHandler = async (req, res, next) => { export const bruteForceProtection: RequestHandler = async (req, res, next) => {
const ip = req.ip const ip = req.ip || 'unknown'
const username = req.body.username const username = req.body.username
const rateLimiter = RateLimiter.getInstance() const rateLimiter = RateLimiter.getInstance()
+1 -1
View File
@@ -84,7 +84,7 @@ groupSchema.post('save', function (group: IGroup, next: Function) {
}) })
// pre remove hook to remove all references of group from users // pre remove hook to remove all references of group from users
groupSchema.pre('remove', async function () { groupSchema.pre('remove', async function (this: IGroupDocument) {
const userIds = this.users const userIds = this.users
await Promise.all( await Promise.all(
userIds.map(async (userId) => { userIds.map(async (userId) => {
+19 -1
View File
@@ -1,5 +1,5 @@
import express from 'express' import express from 'express'
import { runCodeValidation } from '../../utils' import { runCodeValidation, triggerCodeValidation } from '../../utils'
import { CodeController } from '../../controllers/' import { CodeController } from '../../controllers/'
const runRouter = express.Router() const runRouter = express.Router()
@@ -28,4 +28,22 @@ runRouter.post('/execute', async (req, res) => {
} }
}) })
runRouter.post('/trigger', async (req, res) => {
const { error, value: body } = triggerCodeValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.triggerCode(req, body)
res.status(200)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
export default runRouter export default runRouter
+22 -1
View File
@@ -1,16 +1,37 @@
import express from 'express' import express from 'express'
import { SessionController } from '../../controllers' import { SessionController } from '../../controllers'
import { sessionIdValidation } from '../../utils'
const sessionRouter = express.Router() const sessionRouter = express.Router()
sessionRouter.get('/', async (req, res) => {
const controller = new SessionController() const controller = new SessionController()
sessionRouter.get('/', async (req, res) => {
try { try {
const response = await controller.session(req) const response = await controller.session(req)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())
} }
}) })
sessionRouter.get('/:sessionId/state', async (req, res) => {
const { error, value: params } = sessionIdValidation(req.params)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.sessionState(params.sessionId)
res.status(200)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
export default sessionRouter export default sessionRouter
+132
View File
@@ -0,0 +1,132 @@
import path from 'path'
import { createFile, fileExists } from '@sasjs/utils'
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import {
UserController,
PermissionController,
PermissionType,
PermissionSettingForRoute,
PrincipalType
} from '../../../controllers/'
import {
generateAccessToken,
saveTokensInDB,
RunTimeType,
sysInitCompiledPath
} from '../../../utils'
// Real, unmocked end-to-end test of the SAS execution pipeline (session
// spawn -> autoexec handshake -> exit code -> HTTP response), using a fake
// "SAS executable" (mockSas.js) in place of a real SAS install. This is the
// regression test for issue #388: SAS code that aborts (%abort;) used to
// hang the request forever instead of returning an error response.
const mockSasPath = path.join(__dirname, 'files', 'mockSas.js')
const clientId = 'codeSpecClientID'
const user = {
displayName: 'Code Spec User',
username: 'codeSpecUsername',
password: '87654321',
isAdmin: false,
isActive: true
}
let app: Express
let accessToken: string
describe('code', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
let userController: UserController
let permissionController: PermissionController
beforeAll(async () => {
// SASSessionController.createSession() unconditionally reads this file
// (Session.ts:105) before it ever spawns the SAS process. It's normally
// produced by `npm run compileSysInit` (part of the `initial`/`build`
// scripts), which `npm test` does not run - so on a fresh checkout
// (e.g. CI, where "Run Unit Tests" happens before "Build Package") it
// doesn't exist yet. Content is irrelevant here since mockSas.js never
// interprets it; it just needs to exist and be readable.
if (!(await fileExists(sysInitCompiledPath))) {
await createFile(sysInitCompiledPath, '')
}
mongoServer = await MongoMemoryServer.create()
process.env.DB_CONNECT = mongoServer.getUri()
process.env.MODE = 'server'
process.env.RUN_TIMES = 'sas'
process.env.SAS_PATH = mockSasPath
const appPromise = (await import('../../../app')).default
app = await appPromise
con = await mongoose.connect(mongoServer.getUri())
userController = new UserController()
permissionController = new PermissionController()
const dbUser = await userController.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id)
await permissionController.createPermission({
path: '/SASjsApi/code/execute',
type: PermissionType.route,
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSettingForRoute.grant
})
process.runTimes = [RunTimeType.SAS]
process.sasLoc = mockSasPath
}, 30000)
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('execute', () => {
it('returns 200 with the mock log when the SAS program completes normally', async () => {
const response = await request(app)
.post('/SASjsApi/code/execute')
.auth(accessToken, { type: 'bearer' })
.send({ code: '%put hello world;', runTime: 'sas' })
.expect(200)
expect(response.text).toEqual(
expect.stringContaining('mock SAS execution')
)
}, 30000)
it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => {
const response = await request(app)
.post('/SASjsApi/code/execute')
.auth(accessToken, { type: 'bearer' })
.send({ code: '%abort;', runTime: 'sas' })
.expect(400)
expect(response.body).toMatchObject({
status: 'failure',
message: 'Job execution failed.'
})
expect(response.body.log).toEqual(
expect.stringContaining('mock SAS execution')
)
}, 30000)
})
})
const generateAndSaveToken = async (userId: number) => {
const accessToken = generateAccessToken({
clientId,
userId
})
await saveTokensInDB(userId, clientId, accessToken, 'refreshToken')
return accessToken
}
+9
View File
@@ -28,6 +28,15 @@ jest
.spyOn(fileUtilModules, 'getUploadsFolder') .spyOn(fileUtilModules, 'getUploadsFolder')
.mockImplementation(() => path.join(tmpFolder, 'uploads')) .mockImplementation(() => path.join(tmpFolder, 'uploads'))
// getFilesFolder() resolves via getSasjsDriveFolder()/process.driveLoc, a
// separate root from getSasjsRootFolder()/process.sasjsRoot above - without
// this, every test in this file that creates content under the drive (e.g.
// 'level1', 'my/path/...') writes into the real, shared sasjs_root/drive
// instead of this run's isolated tmpFolder, leaking state into later runs.
jest
.spyOn(fileUtilModules, 'getFilesFolder')
.mockImplementation(() => path.join(tmpFolder, 'drive', 'files'))
import appPromise from '../../../app' import appPromise from '../../../app'
import { import {
UserController, UserController,
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
// Minimal fake "SAS executable" used by tests in place of a real SAS
// install. It only fulfils the CLI/filesystem handshake contract that
// Session.ts / processProgram.ts rely on:
//
// -SYSIN <path> the file used as a signal channel: it starts out
// containing a dummy (empty) placeholder, we delete it
// to signal "session ready", then wait for it to be
// rewritten with the real submitted program.
// -LOG <path> where we write a fake log so downstream fileExists()/
// readFile() calls have something to find.
//
// It does NOT interpret real SAS syntax. Exit code is the only thing that
// matters to the Node side: 0 mimics a normal SAS termination, non-zero
// mimics an abnormal one (e.g. %abort;).
const fs = require('fs')
const arg = (flag) => {
const idx = process.argv.indexOf(flag)
return idx === -1 ? undefined : process.argv[idx + 1]
}
const sysin = arg('-SYSIN')
const logPath = arg('-LOG')
// Give up waiting for the real program after this long, so an unused
// pre-warmed session (sasjs pools up to 3 ready sessions) doesn't linger
// forever and keep a test process alive. Generous rather than tight: CI
// runners are frequently slower/more contended than a local dev machine
// (shared CPU, coverage instrumentation on the Node side slowing down the
// round trip this process is waiting on), and this cost is only ever paid
// by sessions nothing is actually waiting on - the session actually used
// by a request gets its real code written far sooner than this in practice.
const GIVE_UP_AFTER_MS = 8000
const sleepSync = (ms) => {
const until = Date.now() + ms
while (Date.now() < until) {
/* busy-wait, mirroring the real autoexec's SAS-side sleep() loop */
}
}
// Any of the filesystem calls below can legitimately race against
// processProgram's own write-then-rename (e.g. existsSync sees the file,
// then a rename mid-flight makes the following read miss). Treat that as
// "not ready yet" and retry a few times, rather than letting an uncaught
// exception crash this process with a non-zero exit - which would be
// indistinguishable, to the Node side, from a genuine SAS failure.
const retry = (fn, attempts = 5, delayMs = 20) => {
for (let i = 0; i < attempts; i++) {
try {
return { ok: true, value: fn() }
} catch (err) {
if (i === attempts - 1) return { ok: false, error: err }
sleepSync(delayMs)
}
}
}
// 1. remove the dummy SYSIN, signalling "session ready" to waitForSession()
if (sysin) {
retry(() => {
if (fs.existsSync(sysin)) fs.unlinkSync(sysin)
})
}
// 2. wait for the real program to be written back to the same path
const deadline = Date.now() + GIVE_UP_AFTER_MS
while (!sysin || !fs.existsSync(sysin)) {
if (Date.now() > deadline) process.exit(0)
sleepSync(10)
}
// small settle delay, mirroring the real autoexec's sleep(0.01,1) after
// detecting the file, so a fast-moving rename isn't read mid-write
sleepSync(50)
const readResult = retry(() => fs.readFileSync(sysin, 'utf-8'))
if (!readResult.ok) {
process.stderr.write(
`mockSas.js: failed to read ${sysin}: ${readResult.error}\n`
)
process.exit(1)
}
const code = readResult.value
if (logPath) {
retry(() => fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`))
}
if (code.includes('%abort;')) {
process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
process.exit(1)
}
process.exit(0)
+2 -5
View File
@@ -25,7 +25,7 @@ import {
SASSessionController SASSessionController
} from '../../../controllers/internal' } from '../../../controllers/internal'
import * as ProcessProgramModule from '../../../controllers/internal/processProgram' import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
import { Session } from '../../../types' import { Session, SessionState } from '../../../types'
const clientId = 'someclientID' const clientId = 'someclientID'
@@ -493,10 +493,7 @@ const mockedGetSession = async () => {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: true, state: SessionState.pending,
inUse: true,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder
+4 -1
View File
@@ -277,7 +277,10 @@ const performLogin = async (
.set('x-xsrf-token', csrfToken) .set('x-xsrf-token', csrfToken)
.send(credentials) .send(credentials)
return { authCookies: header['set-cookie'].join() } return {
authCookies:
(header['set-cookie'] as unknown as string[] | undefined)?.join() || ''
}
} }
const extractCSRF = (text: string) => const extractCSRF = (text: string) =>
+33 -2
View File
@@ -1,5 +1,8 @@
import express from 'express' import express from 'express'
import { executeProgramRawValidation } from '../../utils' import {
executeProgramRawValidation,
triggerProgramValidation
} from '../../utils'
import { STPController } from '../../controllers/' import { STPController } from '../../controllers/'
import { FileUploadController } from '../../controllers/internal' import { FileUploadController } from '../../controllers/internal'
@@ -13,7 +16,11 @@ stpRouter.get('/execute', async (req, res) => {
if (error) return res.status(400).send(error.details[0].message) if (error) return res.status(400).send(error.details[0].message)
try { try {
const response = await controller.executeGetRequest(req, query._program) const response = await controller.executeGetRequest(
req,
query._program,
query._debug
)
if (response instanceof Buffer) { if (response instanceof Buffer) {
res.writeHead(200, (req as any).sasHeaders) res.writeHead(200, (req as any).sasHeaders)
@@ -65,4 +72,28 @@ stpRouter.post(
} }
) )
stpRouter.post('/trigger', async (req, res) => {
const { error, value: query } = triggerProgramValidation(req.query)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.triggerProgram(
req,
query._program,
query._debug,
query.expiresAfterMins
)
res.status(200)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
export default stpRouter export default stpRouter
+10 -5
View File
@@ -1,11 +1,16 @@
export enum SessionState {
initialising = 'initialising', // session is initialising and not ready to be used yet
pending = 'pending', // session is ready to be used
running = 'running', // session is in use
completed = 'completed', // session is completed and can be destroyed
failed = 'failed' // session failed
}
export interface Session { export interface Session {
id: string id: string
ready: boolean state: SessionState
creationTimeStamp: string creationTimeStamp: string
deathTimeStamp: string deathTimeStamp: string
path: string path: string
inUse: boolean expiresAfterMins?: { mins: number; used: boolean }
consumed: boolean failureReason?: string
completed: boolean
crashed?: string
} }
+3 -1
View File
@@ -1,5 +1,6 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import User from '../model/User' import User from '../model/User'
import { InfoJWT } from '../types/InfoJWT'
const isValidToken = async ( const isValidToken = async (
token: string, token: string,
@@ -11,7 +12,8 @@ const isValidToken = async (
jwt.verify(token, key, (err, decoded) => { jwt.verify(token, key, (err, decoded) => {
if (err) return reject(false) if (err) return reject(false)
if (decoded?.userId === userId && decoded?.clientId === clientId) { const payload = decoded as InfoJWT
if (payload?.userId === userId && payload?.clientId === clientId) {
return resolve(true) return resolve(true)
} }
+30 -2
View File
@@ -1,9 +1,31 @@
import path from 'path' import path from 'path'
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils' import {
createFolder,
getAbsolutePath,
getRealPath,
fileExists
} from '@sasjs/utils'
import dotenv from 'dotenv'
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.' import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
export const setProcessVariables = async () => { export const setProcessVariables = async () => {
const { execPath } = process
// Check if execPath ends with 'api-macos' to determine executable for MacOS.
// This is needed to fix picking .env file issue in MacOS executable.
if (execPath) {
const envPathSplitted = execPath.split(path.sep)
if (envPathSplitted.pop() === 'api-macos') {
const envPath = path.join(envPathSplitted.join(path.sep), '.env')
// Override environment variables from envPath if file exists
if (await fileExists(envPath)) {
dotenv.config({ path: envPath, override: true })
}
}
}
const { MODE, RUN_TIMES } = process.env const { MODE, RUN_TIMES } = process.env
if (MODE === ModeType.Server) { if (MODE === ModeType.Server) {
@@ -21,6 +43,7 @@ export const setProcessVariables = async () => {
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root') process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive') process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
return return
} }
@@ -41,7 +64,9 @@ export const setProcessVariables = async () => {
const { SASJS_ROOT } = process.env const { SASJS_ROOT } = process.env
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd()) const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
await createFolder(absPath) await createFolder(absPath)
process.sasjsRoot = getRealPath(absPath) process.sasjsRoot = getRealPath(absPath)
const { DRIVE_LOCATION } = process.env const { DRIVE_LOCATION } = process.env
@@ -49,6 +74,7 @@ export const setProcessVariables = async () => {
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'), DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
process.cwd() process.cwd()
) )
await createFolder(absDrivePath) await createFolder(absDrivePath)
process.driveLoc = getRealPath(absDrivePath) process.driveLoc = getRealPath(absDrivePath)
@@ -57,7 +83,9 @@ export const setProcessVariables = async () => {
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'), LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
process.cwd() process.cwd()
) )
await createFolder(absLogsPath) await createFolder(absLogsPath)
process.logsLoc = getRealPath(absLogsPath) process.logsLoc = getRealPath(absLogsPath)
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784' process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
+2 -3
View File
@@ -51,9 +51,8 @@ export const generateFileUploadSasCode = async (
let fileCount = 0 let fileCount = 0
const uploadedFiles: UploadedFiles[] = [] const uploadedFiles: UploadedFiles[] = []
const sasSessionFolderList: string[] = await listFilesInFolder( const sasSessionFolderList: string[] =
sasSessionFolder await listFilesInFolder(sasSessionFolder)
)
sasSessionFolderList.forEach((fileName) => { sasSessionFolderList.forEach((fileName) => {
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount fileCountString = fileCount < 10 ? '00' + fileCount : fileCount
+23 -1
View File
@@ -183,9 +183,31 @@ export const runCodeValidation = (data: any): Joi.ValidationResult =>
runTime: Joi.string().valid(...process.runTimes) runTime: Joi.string().valid(...process.runTimes)
}).validate(data) }).validate(data)
export const triggerCodeValidation = (data: any): Joi.ValidationResult =>
Joi.object({
code: Joi.string().required(),
runTime: Joi.string().valid(...process.runTimes),
expiresAfterMins: Joi.number().greater(0)
}).validate(data)
export const executeProgramRawValidation = (data: any): Joi.ValidationResult => export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
_program: Joi.string().required() _program: Joi.string().required(),
_debug: Joi.number()
}) })
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number())) .pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
.validate(data) .validate(data)
export const triggerProgramValidation = (data: any): Joi.ValidationResult =>
Joi.object({
_program: Joi.string().required(),
_debug: Joi.number(),
expiresAfterMins: Joi.number().greater(0)
})
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
.validate(data)
export const sessionIdValidation = (data: any): Joi.ValidationResult =>
Joi.object({
sessionId: Joi.string().required()
}).validate(data)
+4231 -8629
View File
File diff suppressed because it is too large Load Diff
+1989 -1432
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -4,7 +4,9 @@
"private": true, "private": true,
"scripts": { "scripts": {
"start": "webpack-dev-server --config webpack.dev.ts --hot", "start": "webpack-dev-server --config webpack.dev.ts --hot",
"build": "webpack --config webpack.prod.ts" "build": "webpack --config webpack.prod.ts",
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\""
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.4.1", "@emotion/react": "^11.4.1",
@@ -19,9 +21,8 @@
"@types/jest": "^26.0.24", "@types/jest": "^26.0.24",
"@types/node": "^12.20.28", "@types/node": "^12.20.28",
"@types/react": "^17.0.27", "@types/react": "^17.0.27",
"axios": "^0.24.0", "axios": "1.12.2",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
@@ -54,8 +55,9 @@
"eslint-webpack-plugin": "^3.1.1", "eslint-webpack-plugin": "^3.1.1",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"html-webpack-plugin": "5.5.0", "html-webpack-plugin": "5.5.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"path": "0.12.7", "path": "0.12.7",
"prettier": "^2.4.1", "prettier": "^3.0.3",
"sass": "^1.44.0", "sass": "^1.44.0",
"sass-loader": "^12.3.0", "sass-loader": "^12.3.0",
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
+4 -5
View File
@@ -3,12 +3,11 @@ import Snackbar from '@mui/material/Snackbar'
import MuiAlert, { AlertProps } from '@mui/material/Alert' import MuiAlert, { AlertProps } from '@mui/material/Alert'
import Slide, { SlideProps } from '@mui/material/Slide' import Slide, { SlideProps } from '@mui/material/Slide'
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert( const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
props, function Alert(props, ref) {
ref
) {
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} /> return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
}) }
)
const Transition = (props: SlideProps) => { const Transition = (props: SlideProps) => {
return <Slide {...props} direction="up" /> return <Slide {...props} direction="up" />
+15
View File
@@ -62,6 +62,7 @@ const SASjsEditor = ({
selectedRunTime, selectedRunTime,
showDiff, showDiff,
webout, webout,
printOutput,
Dialog, Dialog,
handleChangeRunTime, handleChangeRunTime,
handleDiffEditorDidMount, handleDiffEditorDidMount,
@@ -153,6 +154,7 @@ const SASjsEditor = ({
> >
<TabList onChange={handleTabChange} centered> <TabList onChange={handleTabChange} centered>
<StyledTab label="Code" value="code" /> <StyledTab label="Code" value="code" />
{log && (
<StyledTab <StyledTab
label={logWithErrorsOrWarnings ? '' : 'log'} label={logWithErrorsOrWarnings ? '' : 'log'}
value="log" value="log"
@@ -169,6 +171,8 @@ const SASjsEditor = ({
if (logWrapper) logWrapper.scrollTop = 0 if (logWrapper) logWrapper.scrollTop = 0
}} }}
/> />
)}
{webout && (
<StyledTab <StyledTab
label={ label={
<Tooltip title="Displays content from the _webout fileref"> <Tooltip title="Displays content from the _webout fileref">
@@ -177,6 +181,8 @@ const SASjsEditor = ({
} }
value="webout" value="webout"
/> />
)}
{printOutput && <StyledTab label="print" value="printOutput" />}
</TabList> </TabList>
</Box> </Box>
@@ -222,11 +228,20 @@ const SASjsEditor = ({
<LogComponent log={log} selectedRunTime={selectedRunTime} /> <LogComponent log={log} selectedRunTime={selectedRunTime} />
)} )}
</StyledTabPanel> </StyledTabPanel>
{webout && (
<StyledTabPanel value="webout"> <StyledTabPanel value="webout">
<div> <div>
<pre>{webout}</pre> <pre>{webout}</pre>
</div> </div>
</StyledTabPanel> </StyledTabPanel>
)}
{printOutput && (
<StyledTabPanel value="printOutput">
<div>
<pre>{printOutput}</pre>
</div>
</StyledTabPanel>
)}
</TabContext> </TabContext>
)} )}
<Dialog /> <Dialog />
@@ -7,8 +7,10 @@
border: none; border: none;
outline: none; outline: none;
transition: 0.4s; transition: 0.4s;
box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 1px -1px, box-shadow:
rgba(0, 0, 0, 0.14) 0px 1px 1px 0px, rgba(0, 0, 0, 0.12) 0px 1px 3px 0px; rgba(0, 0, 0, 0.2) 0px 2px 1px -1px,
rgba(0, 0, 0, 0.14) 0px 1px 1px 0px,
rgba(0, 0, 0, 0.12) 0px 1px 3px 0px;
} }
.ChunkDetails { .ChunkDetails {
@@ -39,14 +39,14 @@ const useEditor = ({
const { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } = const { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } =
useSnackbar() useSnackbar()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [prevFileContent, setPrevFileContent] = useStateWithCallback('') const [prevFileContent, setPrevFileContent] = useStateWithCallback('')
const [fileContent, setFileContent] = useState('') const [fileContent, setFileContent] = useState('')
const [log, setLog] = useState<LogObject | string>() const [log, setLog] = useState<LogObject | string>()
const [webout, setWebout] = useState('') const [webout, setWebout] = useState<string>()
const [printOutput, setPrintOutput] = useState<string>()
const [runTimes, setRunTimes] = useState<string[]>([]) const [runTimes, setRunTimes] = useState<string[]>([])
const [selectedRunTime, setSelectedRunTime] = useState<RunTimeType | string>( const [selectedRunTime, setSelectedRunTime] = useState<RunTimeType>(
'' RunTimeType.SAS
) )
const [selectedFileExtension, setSelectedFileExtension] = useState('') const [selectedFileExtension, setSelectedFileExtension] = useState('')
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false) const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
@@ -169,25 +169,30 @@ const useEditor = ({
), ),
runTime: selectedRunTime runTime: selectedRunTime
}) })
.then((res: any) => { .then((res: { data: string }) => {
if (selectedRunTime === RunTimeType.SAS) { // INFO: the order of payload parts is set in @sasjs/server/api/src/controllers/internal/Execution.ts
const { errors, warnings, logLines } = parseErrorsAndWarnings( const resDataSplitted = res.data.split(SASJS_LOGS_SEPARATOR)
res.data.split(SASJS_LOGS_SEPARATOR)[1] const webout = resDataSplitted[0]
) const log = resDataSplitted[1]
const printOutput = resDataSplitted[2]
const log: LogObject = { if (selectedRunTime === RunTimeType.SAS) {
const { errors, warnings, logLines } = parseErrorsAndWarnings(log)
const logObject: LogObject = {
body: logLines.join(`\n`), body: logLines.join(`\n`),
errors, errors,
warnings, warnings,
linesCount: logLines.length linesCount: logLines.length
} }
setLog(log) setLog(logObject)
} else { } else {
setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '') setLog(log)
} }
setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '') setWebout(webout)
setPrintOutput(printOutput)
setTab('log') setTab('log')
// Scroll to bottom of log // Scroll to bottom of log
@@ -335,6 +340,7 @@ const useEditor = ({
selectedRunTime, selectedRunTime,
showDiff, showDiff,
webout, webout,
printOutput,
Dialog, Dialog,
handleChangeRunTime, handleChangeRunTime,
handleDiffEditorDidMount, handleDiffEditorDidMount,
+5 -5
View File
@@ -1,15 +1,15 @@
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family:
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
sans-serif; 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family:
monospace; source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
} }
.container { .container {
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />