Compare commits

...
19 Commits
Author SHA1 Message Date
semantic-release-bot bb23bac1c0 chore(release): 0.39.8 [skip ci]
## [0.39.8](https://github.com/sasjs/server/compare/v0.39.7...v0.39.8) (2026-07-15)

### Bug Fixes

* **api:** return 200 with embedded log on SAS session failure instead of 400 ([8506950](https://github.com/sasjs/server/commit/850695024f4b7102982c197ec64b5bb45e7d5f90))
2026-07-15 13:17:59 +00:00
Allan BoweandGitHub bf0ddc952f Merge pull request #392 from sasjs/issue-388-fix
fix(api): return 200 with embedded log on SAS session failure instead…
2026-07-15 14:14:42 +01:00
YuryShkoda 850695024f fix(api): return 200 with embedded log on SAS session failure instead of 400
A failed session (e.g. SAS %abort;, or a non-zero JS/PY/R exit) is a
normal outcome of running arbitrary user code, not a request-shape or
server problem. The #388 fix stopped processProgram() from hanging
forever on a failed SAS session, but did so by throwing a
SessionExecutionError, which surfaced as an HTTP 400 with a bespoke
JSON shape - breaking Studio's log tab, which only renders on 2xx.

Align the SAS branch with the pre-existing JS/PY/R behaviour: resolve
instead of throwing, and let Execution.ts fold session.failureReason
into the log the same way it already does for a debug-mode run. This
removes the now-dead SessionExecutionError class and its try/catch
wrapper entirely.

Update the diagrams in api/docs/diagrams/ to match.
2026-07-15 13:59:01 +03:00
semantic-release-bot 9c3a1086b6 chore(release): 0.39.7 [skip ci]
## [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))
2026-07-14 13:53:13 +00:00
Yury ShkodaandGitHub 05768890a2 Merge pull request #366 from sasjs/cpr-fix
Fix API scripts
2026-07-14 16:50:34 +03:00
YuryShkoda 386185d1a0 Merge remote-tracking branch 'origin/main' into cpr-fix
# Conflicts:
#	api/package-lock.json
#	api/package.json
#	api/public/swagger.yaml
2026-07-14 16:28:10 +03:00
Yury ShkodaandGitHub 681f0123ad Merge pull request #387 from sasjs/fix/pin-axios-version
chore: pin axios versions
2026-07-14 15:45:40 +03:00
YuryShkoda daa5274fb0 Merge remote-tracking branch 'origin/main' into fix/pin-axios-version 2026-07-14 15:29:26 +03:00
semantic-release-bot 50fa4320cd chore(release): 0.39.6 [skip ci]
## [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))
2026-07-14 09:05:49 +00:00
Allan BoweandGitHub 40b9fa8735 Merge pull request #391 from sasjs/issue-390
Issue 390
2026-07-14 10:02:31 +01:00
YuryShkoda fd31fe94ea Merge remote-tracking branch 'origin/main' into issue-390
# Conflicts:
#	api/src/controllers/internal/spec/Execution.spec.ts
2026-07-14 11:58:24 +03:00
YuryShkoda dec5191491 fix(api): isolate drive.spec.ts's files folder from the real drive
drive.spec.ts already isolates itself into a unique, timestamped
tmpFolder for getSasjsRootFolder()/getUploadsFolder(), cleaned up via
afterAll(() => deleteFolder(tmpFolder)). But getFilesFolder() - what
nearly every test in this file actually writes to - resolves through
a separate, unmocked function (getSasjsDriveFolder()/process.driveLoc),
so every run left real files/folders (e.g. 'level1', 'my/path/...')
behind in the shared api/sasjs_root/drive/files, causing later runs
to fail: folder-listing tests saw stale entries, and file-creation
tests got 409 Conflict against files a previous run already created.

Mock getFilesFolder() the same way getUploadsFolder() already is, so
it resolves inside the same isolated tmpFolder and gets cleaned up
by the existing afterAll. Verified by running the suite twice in a
row from a clean slate: both runs pass, and the real sasjs_root/drive
is never touched.
2026-07-14 11:32:01 +03:00
YuryShkoda 4858245372 fix(api): stop overwriting a failed JS/PY/R session back to completed
For JS/PY/R, processProgram sets session.state = failed (with
failureReason) itself when the interpreter process exits non-zero,
without throwing. ExecutionController.executeProgram then
unconditionally set session.state = completed right after
processProgram returned, silently overwriting that - so anything
downstream inspecting session.state (e.g. scheduleSessionDestroy's
expiresAfterMins branch) would see a crashed session mis-reported as
successful.

Guard the assignment so a failed state is never overwritten. No
effect on SAS, which sets state independently via its own spawned
process lifecycle.

Added Execution.spec.ts covering both the failure case (state stays
failed) and the success case (state still becomes completed), to
guard against regressing in either direction.
2026-07-14 11:29:30 +03:00
mulahasanovic 4f75fd290f chore: pin axios versions 2026-04-01 20:55:50 +02:00
Yury Shkoda 66232aefd2 chore(lint): bumped prettier and fixed lint issues 2023-09-21 17:58:47 +03:00
Yury Shkoda bf35791655 chore(lint): fixed Session.ts 2023-09-21 17:52:28 +03:00
Yury Shkoda 2dc11630e4 chore(api): regenerated swagger.yaml 2023-09-21 17:45:52 +03:00
Yury Shkoda 1473925896 fix(jsonwebtoken): bumped version to avoid vulnerability 2023-09-21 17:45:21 +03:00
Yury Shkoda b472f1bd61 chore(scripts): used cpr package to improve coping 2023-09-21 17:44:31 +03:00
18 changed files with 296 additions and 143 deletions
+22
View File
@@ -1,3 +1,25 @@
## [0.39.8](https://github.com/sasjs/server/compare/v0.39.7...v0.39.8) (2026-07-15)
### Bug Fixes
* **api:** return 200 with embedded log on SAS session failure instead of 400 ([8506950](https://github.com/sasjs/server/commit/850695024f4b7102982c197ec64b5bb45e7d5f90))
## [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) ## [0.39.5](https://github.com/sasjs/server/compare/v0.39.4...v0.39.5) (2026-07-14)
+3 -2
View File
@@ -30,8 +30,9 @@ fresh per request inside `processProgram`.
code into the session and drives it to completion/failure, per runtime. code into the session and drives it to completion/failure, per runtime.
- `api/src/controllers/internal/Execution.ts``ExecutionController`, - `api/src/controllers/internal/Execution.ts``ExecutionController`,
the entry point controllers call; acquires a session, calls the entry point controllers call; acquires a session, calls
`processProgram`, reads back `log.log`/`webout.txt`/headers, builds the `processProgram`, reads back `log.log`/`webout.txt`/headers, and builds
HTTP response (or a `SessionExecutionError` on failure). the HTTP response. A failed session (any runtime) is embedded in that
same response, not thrown as a separate error.
- `api/src/controllers/internal/create{SAS,JS,Python,R}Program.ts` - `api/src/controllers/internal/create{SAS,JS,Python,R}Program.ts`
per-runtime code templating (wraps the user's submitted code with per-runtime code templating (wraps the user's submitted code with
boilerplate: variable injection, `_webout` redirection, etc). boilerplate: variable injection, `_webout` redirection, etc).
+23 -18
View File
@@ -8,7 +8,7 @@ fresh interpreter process per request).
```mermaid ```mermaid
flowchart TD 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"] 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"] B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:40-77"]
C --> D{"session already provided?<br/>(e.g. file-upload flow)"} C --> D{"session already provided?<br/>(e.g. file-upload flow)"}
D -- no --> E["getSessionController(runTime).getSession()<br/>Session.ts:59-69"] D -- no --> E["getSessionController(runTime).getSession()<br/>Session.ts:59-69"]
D -- yes --> F["use provided session"] D -- yes --> F["use provided session"]
@@ -18,30 +18,28 @@ flowchart TD
H --> J["pre-warm: if pool has fewer<br/>than 3 pending, fire off more<br/>createSession() calls (not awaited)<br/>Session.ts:66"] 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 I --> J
F --> K F --> K
J --> K["session.state = running<br/>Execution.ts:96"] J --> K["session.state = running<br/>Execution.ts:78"]
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:103-107"] K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:85-89"]
L --> M["processProgram(...)<br/>processProgram.ts:16-27"] L --> M["processProgram(...)<br/>Execution.ts:96-107"]
M --> N{"runTime?"} M --> N{"runTime?"}
N -- SAS --> O["createSASProgram():<br/>wrap user code with _webout<br/>filename, macro vars, autoexec"] 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"] 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"] P --> Q["poll: while state !== completed<br/>AND state !== failed<br/>processProgram.ts:58-63"]
Q -- "state becomes completed" --> R["resolve"] Q --> R["processProgram() resolves either way -<br/>state was set by the session's own<br/>process exit handler in Session.ts,<br/>see sas-execution-handshake.md"]
Q -- "state becomes failed" --> S["throw Error(session.failureReason)"]
N -- "JS / PY / R" --> T["createJSProgram / createPythonProgram /<br/>createRProgram: wrap user code similarly"] 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"] T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:117-124"]
U -- "exit 0" --> R U -- "exit 0" --> Uc["session.state = completed<br/>processProgram.ts:126"]
U -- "exit non-zero" --> S U -- "exit non-zero" --> Uf["session.state = failed<br/>session.failureReason = err.toString()<br/>processProgram.ts:131-133"]
Uc --> R
Uf --> R
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:128-131"] R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:109-113, 121-125"]
V --> W["build httpHeaders + result<br/>return ExecuteReturnRaw<br/>Execution.ts:128-151"] V --> W["guard: if state !== failed,<br/>set state = completed<br/>(don't overwrite a failed session)<br/>Execution.ts:127-137"]
W --> X["Controller: res.send(result)<br/>HTTP 200"] W --> X["build httpHeaders + result;<br/>embed log in result if<br/>isDebugOn(vars) or<br/>session.failureReason is set<br/>Execution.ts:139-164"]
X --> Y["Controller: res.send(result)<br/>HTTP 200<br/>(log embedded if the session failed -<br/>same shape as a successful run)"]
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 ## Notes
@@ -55,9 +53,16 @@ flowchart TD
`failed` either way, and both paths flow through the same log/webout `failed` either way, and both paths flow through the same log/webout
reading logic in `Execution.ts` afterward. The mechanics of *how* that reading logic in `Execution.ts` afterward. The mechanics of *how* that
state gets set differ substantially (see `session-lifecycle.md`). state gets set differ substantially (see `session-lifecycle.md`).
- **A failed session never throws.** `processProgram()` resolves normally
whether the session completed or failed - a failed session (e.g. SAS
`%abort;`, or a non-zero JS/PY/R exit) is a normal outcome of running
arbitrary user code, not a request-shape/server problem. There is no
separate error path: the same `Execution.ts:139-164` logic that embeds
the log for a debug-mode successful run also embeds it when
`session.failureReason` is set, and the controller always responds 200.
- **`includePrintOutput`** (SAS only) additionally appends `output.lst` - **`includePrintOutput`** (SAS only) additionally appends `output.lst`
content to the result when debug mode is on - omitted above for brevity; content to the result when debug mode is on - omitted above for brevity;
see `Execution.ts:135-142`. see `Execution.ts:149-156`.
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown) - **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
call the same `ExecutionController` methods without awaiting them and call the same `ExecutionController` methods without awaiting them and
immediately return `{ sessionId }`; the client polls immediately return `{ sessionId }`; the client polls
+10 -10
View File
@@ -33,9 +33,9 @@ sequenceDiagram
Client->>Ctrl: POST .../execute { code, runTime: sas } Client->>Ctrl: POST .../execute { code, runTime: sas }
Ctrl->>Exec: executeProgram({ program, runTime: SAS, ... }) Ctrl->>Exec: executeProgram({ program, runTime: SAS, ... })
Exec->>Pool: getSession() returns the pending session (Session.ts:59-69) Exec->>Pool: getSession() returns the pending session (Session.ts:59-69)
Exec->>Exec: session.state = running (Execution.ts:96) Exec->>Exec: session.state = running (Execution.ts:78)
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:103-107) Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:85-89)
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:110-121) Exec->>Pool: processProgram(program, session, ...) (Execution.ts:96-107)
Pool->>FS: createSASProgram() wraps user code with macro vars,<br/>_webout filename, autoexec injection (createSASProgram.ts) 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) 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 Note over FS: write-then-rename, not a direct write,<br/>so SAS never reads a partial file
@@ -45,24 +45,24 @@ sequenceDiagram
Note over SAS: -SYSIN has pointed at code.sas from the start,<br/>so SAS now executes ITS CONTENT as the main job 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 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) Pool->>Pool: processProgram poll loop:<br/>while state !== completed AND state !== failed (processProgram.ts:58-63)
alt program reaches EOF normally alt program reaches EOF normally
SAS->>SAS: exits with code 0 SAS->>SAS: exits with code 0
SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152) SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152)
Pool->>Pool: session.state = completed Pool->>Pool: session.state = completed
Pool-->>Exec: processProgram() resolves else program aborts (e.g. %abort, fatal error, license failure, hard STOP)
else program aborts (fatal error, license failure, hard STOP)
SAS->>SAS: exits with non-zero code SAS->>SAS: exits with non-zero code
SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163) SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163)
Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString() Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString()
Pool-->>Exec: processProgram() throws (processProgram.ts:53-55)
end end
deactivate SAS deactivate SAS
Note over Pool,Exec: either way processProgram() just RESOLVES here -<br/>a failed session is a normal outcome of running user<br/>code, not a request-shape/server problem, so it never throws<br/>(processProgram.ts:58-63, same as the JS/PY/R branch below)
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:123, 128-131) Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:109-112, 121-125)
Exec-->>Ctrl: { httpHeaders, result }<br/>or throws SessionExecutionError{ message, log } (Execution.ts:109-126) Note over Exec: if session.failureReason is set, the log is folded into<br/>result the same way isDebugOn(vars) already does for a<br/>successful run - no separate error shape (Execution.ts:158-164)
Ctrl-->>Client: 200 + result<br/>or 400 { status, message, error, log } Exec-->>Ctrl: { httpHeaders, result }
Ctrl-->>Client: 200 + result<br/>(log embedded in result if the session failed)
``` ```
## Why this design ## Why this design
+13 -6
View File
@@ -14,10 +14,10 @@ stateDiagram-v2
initialising --> pending: dummy SYSIN file deleted<br/>by SAS's autoexec<br/>waitForSession(), Session.ts:175-192 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 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 pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:76-78
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:116-120 (fresh process) running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:124-129 (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() running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:130-135<br/>failureReason = err.toString()<br/>neither branch throws - see request-execution-flow.md
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236 completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy() failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
@@ -55,7 +55,7 @@ stateDiagram-v2
| Reader | Location | Watches for | | Reader | Location | Watches for |
|---|---|---| |---|---|---|
| `waitForSession` | `Session.ts:175-192` | `failed` (breaks early) or the dummy SYSIN file disappearing (implies session survived init) | | `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`) | | `processProgram` (SAS branch poll loop) | `processProgram.ts:58-63` | `completed` or `failed` - either just stops the loop and returns normally; `failed` does **not** throw. `Execution.ts` reads `session.failureReason` afterward to fold the log into a normal (200) result, same as it already does for JS/PY/R. |
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) | | `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
## Asymmetry between runtimes ## Asymmetry between runtimes
@@ -65,6 +65,13 @@ stateDiagram-v2
`running`/`completed`/`failed` are all driven by that *same* process's `running`/`completed`/`failed` are all driven by that *same* process's
eventual exit. eventual exit.
- **JS/PY/R**: a session is cheap (folder + id, no process). The interpreter - **JS/PY/R**: a session is cheap (folder + id, no process). The interpreter
process is spawned fresh, per request, inside `processProgram.ts:115` and process is spawned fresh, per request, inside `processProgram.ts:124` and
its exit drives `completed`/`failed` directly in the same function - there its exit drives `completed`/`failed` directly in the same function - there
is no separate poll loop for these runtimes. is no separate poll loop for these runtimes (SAS needs one because it's
polling a state change happening in a process spawned earlier, at session
creation; JS/PY/R just `await` the process they spawn right there).
- **Both runtimes resolve `processProgram()` normally on `failed`, they
never throw for it** - a failed session (any runtime) is a normal outcome
of running arbitrary user code, not a request-shape/server problem. See
`request-execution-flow.md` for how `Execution.ts` turns that into a
response.
+36 -6
View File
@@ -49,7 +49,8 @@
"@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": "^1.12.2", "axios": "1.12.2",
"cpr": "^3.0.1",
"csrf": "^3.1.0", "csrf": "^3.1.0",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"http-headers-validation": "^0.0.1", "http-headers-validation": "^0.0.1",
@@ -58,7 +59,7 @@
"nodejs-file-downloader": "4.10.2", "nodejs-file-downloader": "4.10.2",
"nodemon": "^3.0.0", "nodemon": "^3.0.0",
"pkg": "5.6.0", "pkg": "5.6.0",
"prettier": "^3.0.0", "prettier": "^3.0.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"supertest": "^6.1.3", "supertest": "^6.1.3",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
@@ -3588,11 +3589,10 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.2", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.4", "form-data": "^4.0.4",
@@ -4490,6 +4490,36 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/cpr": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/cpr/-/cpr-3.0.1.tgz",
"integrity": "sha512-Xch4PXQ/KC8lJ+KfJ9JI6eG/nmppLrPPWg5Q+vh65Qr9EjuJEubxh/H/Le1TmCZ7+Xv7iJuNRqapyOFZB+wsxA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"graceful-fs": "^4.1.5",
"minimist": "^1.2.0",
"mkdirp": "~0.5.1",
"rimraf": "^2.5.4"
},
"bin": {
"cpr": "bin/cpr"
}
},
"node_modules/cpr/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/create-jest": { "node_modules/create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
+7 -6
View File
@@ -16,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"
@@ -84,7 +84,8 @@
"@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": "^1.12.2", "axios": "1.12.2",
"cpr": "^3.0.1",
"csrf": "^3.1.0", "csrf": "^3.1.0",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"http-headers-validation": "^0.0.1", "http-headers-validation": "^0.0.1",
@@ -93,7 +94,7 @@
"nodejs-file-downloader": "4.10.2", "nodejs-file-downloader": "4.10.2",
"nodemon": "^3.0.0", "nodemon": "^3.0.0",
"pkg": "5.6.0", "pkg": "5.6.0",
"prettier": "^3.0.0", "prettier": "^3.0.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"supertest": "^6.1.3", "supertest": "^6.1.3",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
+1 -2
View File
@@ -114,8 +114,7 @@ const executeCode = async (
code: 400, code: 400,
status: 'failure', status: 'failure',
message: 'Job execution failed.', message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err, error: typeof err === 'object' ? err.toString() : err
log: err?.log
} }
} }
} }
+27 -37
View File
@@ -15,24 +15,6 @@ 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
@@ -106,24 +88,23 @@ export class ExecutionController {
preProgramVariables?.httpHeaders.join('\n') ?? '' preProgramVariables?.httpHeaders.join('\n') ?? ''
) )
try { // A failed session (e.g. SAS via %abort;, or a non-zero JS/PY/R exit)
await processProgram( // is a normal outcome of running arbitrary user code, not a
program, // request-shape/server problem - processProgram resolves rather than
preProgramVariables, // throwing in that case, and the session.failureReason check below
vars, // folds the log into the same result shape a successful run returns.
session, await processProgram(
weboutPath, program,
headersPath, preProgramVariables,
tokenFile, vars,
runTime, session,
logPath, weboutPath,
otherArgs headersPath,
) tokenFile,
} catch (err: any) { runTime,
const log = (await fileExists(logPath)) ? await readFile(logPath) : '' logPath,
otherArgs
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))
@@ -144,7 +125,16 @@ export class ExecutionController {
: '' : ''
// it should be deleted by scheduleSessionDestroy // it should be deleted by scheduleSessionDestroy
session.state = SessionState.completed //
// 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 = [] const resultParts = []
+11 -6
View File
@@ -48,12 +48,17 @@ export const processProgram = async (
await createFile(codePath + '.bkp', program) await createFile(codePath + '.bkp', program)
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. A failed session (e.g. from
while (session.state !== SessionState.completed) { // %abort;) is not a request-shape/server problem - it's a normal
if (session.state === SessionState.failed) { // outcome of running arbitrary user code, same as a SAS ERROR: in the
throw new Error(session.failureReason || 'SAS session failed') // log without %abort;. So we just stop polling rather than throwing;
} // Execution.ts already knows how to turn session.failureReason into a
// 200 response with the log embedded, matching how JS/PY/R (the else
// branch below) has always handled a failed session.
while (
session.state !== SessionState.completed &&
session.state !== SessionState.failed
) {
await delay(50) await delay(50)
} }
} else { } else {
@@ -2,7 +2,7 @@ import path from 'path'
import os from 'os' import os from 'os'
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils' import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
import * as ProcessProgramModule from '../processProgram' import * as ProcessProgramModule from '../processProgram'
import { ExecutionController, SessionExecutionError } from '../Execution' import { ExecutionController } from '../Execution'
import { Session, SessionState, PreProgramVars } from '../../../types' import { Session, SessionState, PreProgramVars } from '../../../types'
import { RunTimeType } from '../../../utils' import { RunTimeType } from '../../../utils'
@@ -14,7 +14,7 @@ const preProgramVariables: PreProgramVars = {
httpHeaders: [] httpHeaders: []
} }
describe('ExecutionController.executeProgram (SAS failure path)', () => { describe('ExecutionController.executeProgram', () => {
let session: Session let session: Session
beforeEach(() => { beforeEach(() => {
@@ -33,33 +33,94 @@ describe('ExecutionController.executeProgram (SAS failure path)', () => {
await deleteFolder(session.path) await deleteFolder(session.path)
}) })
it('throws a SessionExecutionError carrying the complete log when the session fails', async () => { // Regression coverage: for JS/PY/R, processProgram sets session.state to
const logPath = path.join(session.path, 'log.log') // `failed` itself (without throwing) on a non-zero interpreter exit.
const logContent = // ExecutionController.executeProgram used to unconditionally overwrite
'NOTE: SAS session\nERROR: SAS session terminated. See log for details.\n' // 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'
})
await createFile(logPath, logContent) const controller = new ExecutionController()
jest await controller.executeProgram({
.spyOn(ProcessProgramModule, 'processProgram') program: 'throw new Error("boom")',
.mockImplementation(async () => { preProgramVariables,
throw new Error('ERROR: SAS session terminated. See log for details.') vars: {},
session,
runTime: RunTimeType.JS
}) })
const controller = new ExecutionController() expect(session.state).toBe(SessionState.failed)
const resultPromise = controller.executeProgram({
program: '%abort;',
preProgramVariables,
vars: {},
session,
runTime: RunTimeType.SAS
}) })
await expect(resultPromise).rejects.toBeInstanceOf(SessionExecutionError) it('still marks a genuinely successful session as completed', async () => {
await expect(resultPromise).rejects.toMatchObject({ jest
log: logContent, .spyOn(ProcessProgramModule, 'processProgram')
message: expect.stringContaining('SAS session terminated') .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)
})
})
// A failed SAS session (e.g. from %abort;) is a normal outcome of
// running arbitrary user code, not a request-shape/server problem - the
// same way a plain SAS ERROR: in the log (without %abort;) already
// returns 200 with the log embedded. This mirrors the JS/PY/R failure
// path above: resolve normally, don't throw, and let the existing
// session.failureReason check below produce the same result shape as a
// successful run.
describe('SAS failure path', () => {
it('returns the complete log embedded in a normal result instead of throwing, 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 () => {
// mirrors the real SAS branch after the fix: sets
// state/failureReason and resolves, exactly like the JS/PY/R
// branch already does - it does not throw
session.state = SessionState.failed
session.failureReason =
'ERROR: SAS session terminated. See log for details.'
})
const controller = new ExecutionController()
const { result } = await controller.executeProgram({
program: '%abort;',
preProgramVariables,
vars: {},
session,
runTime: RunTimeType.SAS
})
expect(session.state).toBe(SessionState.failed)
expect(result).toEqual(expect.stringContaining(logContent))
}) })
}) })
}) })
@@ -65,7 +65,13 @@ describe('processProgram (SAS runtime)', () => {
await deleteFolder(session.path) await deleteFolder(session.path)
}) })
it('rejects instead of hanging when the session fails (e.g. %abort;)', async () => { it('resolves instead of hanging when the session fails (e.g. %abort;)', async () => {
// mirrors the JS/PY/R branch below: a session failure is a normal
// outcome of running arbitrary user code (like a SAS ERROR: in the
// log without %abort;), not a server-side/request-shape problem - so
// processProgram must not throw here, just stop polling. Execution.ts
// is responsible for turning session.failureReason into a 200 response
// with the log embedded, the same way it already does for JS/PY/R.
setTimeout(() => { setTimeout(() => {
session.state = SessionState.failed session.state = SessionState.failed
session.failureReason = session.failureReason =
@@ -84,7 +90,7 @@ describe('processProgram (SAS runtime)', () => {
RunTimeType.SAS, RunTimeType.SAS,
logPath logPath
) )
).rejects.toThrow(/SAS session terminated/) ).resolves.toBeUndefined()
}, 3000) }, 3000)
it('resolves without throwing when the session completes normally', async () => { it('resolves without throwing when the session completes normally', async () => {
+1 -2
View File
@@ -166,8 +166,7 @@ const execute = async (
code: 400, code: 400,
status: 'failure', status: 'failure',
message: 'Job execution failed.', message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err, error: typeof err === 'object' ? err.toString() : err
log: err?.log
} }
} }
} }
+13 -7
View File
@@ -104,20 +104,26 @@ describe('code', () => {
) )
}, 30000) }, 30000)
it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => { // A failed SAS session (e.g. %abort;) is a normal outcome of running
// arbitrary user code, not a request-shape/server problem - the HTTP
// request itself was fine, so this must respond exactly like a
// successful run (200, same body shape, no hang), with the log simply
// reflecting what happened. Regression test for #388's follow-up: the
// original #388 fix stopped the hang but over-corrected into a 400
// with a bespoke error shape, which broke Studio's log-tab rendering.
it('returns 200 with the same shape as a successful run when the SAS session fails (%abort;), not a hang or an error shape', async () => {
const response = await request(app) const response = await request(app)
.post('/SASjsApi/code/execute') .post('/SASjsApi/code/execute')
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send({ code: '%abort;', runTime: 'sas' }) .send({ code: '%abort;', runTime: 'sas' })
.expect(400) .expect(200)
expect(response.body).toMatchObject({ expect(response.text).toEqual(
status: 'failure',
message: 'Job execution failed.'
})
expect(response.body.log).toEqual(
expect.stringContaining('mock SAS execution') expect.stringContaining('mock SAS execution')
) )
expect(response.text).toEqual(
expect.stringContaining('SAS session terminated')
)
}, 30000) }, 30000)
}) })
}) })
+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,
+10 -2
View File
@@ -93,11 +93,19 @@ if (!readResult.ok) {
const code = readResult.value const code = readResult.value
// real SAS writes errors/aborts directly into the log file itself, not
// just to stderr - mirror that so tests asserting on log content (what
// the API actually returns to the caller) are meaningful
const isAbort = code.includes('%abort;')
const logContent = isAbort
? `NOTE: mock SAS execution\n${code}\nERROR: SAS session terminated. See log for details.\n`
: `NOTE: mock SAS execution\n${code}\n`
if (logPath) { if (logPath) {
retry(() => fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`)) retry(() => fs.writeFileSync(logPath, logContent))
} }
if (code.includes('%abort;')) { if (isAbort) {
process.stderr.write('ERROR: SAS session terminated. See log for details.\n') process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
process.exit(1) process.exit(1)
} }
+13 -11
View File
@@ -20,7 +20,7 @@
"@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": "^1.12.2", "axios": "1.12.2",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
@@ -56,7 +56,7 @@
"html-webpack-plugin": "5.5.0", "html-webpack-plugin": "5.5.0",
"monaco-editor-webpack-plugin": "^7.0.1", "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",
@@ -4334,7 +4334,6 @@
"version": "1.12.2", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.4", "form-data": "^4.0.4",
@@ -9713,15 +9712,18 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "2.4.1", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==", "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
"dev": true, "dev": true,
"bin": { "bin": {
"prettier": "bin-prettier.js" "prettier": "bin/prettier.cjs"
}, },
"engines": { "engines": {
"node": ">=10.13.0" "node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/pretty-error": { "node_modules/pretty-error": {
@@ -19127,9 +19129,9 @@
"dev": true "dev": true
}, },
"prettier": { "prettier": {
"version": "2.4.1", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==", "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
"dev": true "dev": true
}, },
"pretty-error": { "pretty-error": {
+5 -3
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,7 +21,7 @@
"@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": "^1.12.2", "axios": "1.12.2",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
@@ -55,7 +57,7 @@
"html-webpack-plugin": "5.5.0", "html-webpack-plugin": "5.5.0",
"monaco-editor-webpack-plugin": "^7.0.1", "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",