Skip to content

Commit 68ca9e2

Browse files
committed
feat: 🎸 async executors on be
1 parent a8d9f84 commit 68ca9e2

23 files changed

+403
-2
lines changed

‎apps/cli-daemon/src/app/app.module.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@ import { Global, Module } from '@nestjs/common';
22

33
import { AppController } from './app.controller';
44
import { AppService } from './app.service';
5+
import { ExecutorsModule } from './executors/executors.module';
56
import { GeneratorsModule } from './generators/generators.module';
67
import { SessionService } from './session/session.service';
78
import { WorkspaceModule } from './workspace/workspace.module';
89
import { WorkspaceManagerModule } from './workspace-manager/workspace-manager.module';
910

1011
@Global()
1112
@Module({
12-
imports: [WorkspaceModule, GeneratorsModule, WorkspaceManagerModule],
13+
imports: [
14+
WorkspaceModule,
15+
GeneratorsModule,
16+
WorkspaceManagerModule,
17+
ExecutorsModule,
18+
],
1319
controllers: [AppController],
1420
providers: [AppService, SessionService],
1521
exports: [SessionService],
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ChildProcessWithoutNullStreams } from 'node:child_process';
2+
3+
import { TaskStatus } from '@angular-cli-gui/shared/data';
4+
import { Logger } from '@nestjs/common';
5+
6+
import { StringBuffer } from './string-buffer';
7+
import { spawnedProcessCommandLine } from './utils';
8+
9+
const STD_OUT_BUFFER_SIZE = 100;
10+
const STD_ERR_BUFFER_SIZE = 20;
11+
12+
export class ChildProcess {
13+
private readonly logger = new Logger(
14+
`Child process "${spawnedProcessCommandLine(this.spawnedProcess)}"`
15+
);
16+
private readonly stdOutBuffer = new StringBuffer(STD_OUT_BUFFER_SIZE);
17+
private readonly stdErrBuffer = new StringBuffer(STD_ERR_BUFFER_SIZE);
18+
private exitCode?: number | null;
19+
20+
get status(): TaskStatus {
21+
return {
22+
stdOut: this.stdOutBuffer.data,
23+
stdErr: this.stdErrBuffer.data,
24+
isRunning: this.exitCode === undefined,
25+
exitCode: this.exitCode,
26+
};
27+
}
28+
29+
constructor(readonly spawnedProcess: ChildProcessWithoutNullStreams) {
30+
this.logger.verbose(`Spawned with PID: ${spawnedProcess.pid}`);
31+
spawnedProcess.stdout.on('data', (data: Buffer) =>
32+
this.stdOutBuffer.add(data)
33+
);
34+
spawnedProcess.stderr.on('data', (data: Buffer) =>
35+
this.stdErrBuffer.add(data)
36+
);
37+
spawnedProcess.on('close', (code) => {
38+
this.exitCode = code;
39+
this.logger.verbose(`Terminated with code ${code}`);
40+
});
41+
}
42+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ChildProcessWithoutNullStreams } from 'node:child_process';
2+
3+
import { ChildProcess } from './child-process';
4+
5+
export class ChildProcesses extends Map<number, ChildProcess> {
6+
add(spawnedProcess: ChildProcessWithoutNullStreams): void {
7+
if (!spawnedProcess.pid) {
8+
return;
9+
}
10+
11+
this.set(spawnedProcess.pid, new ChildProcess(spawnedProcess));
12+
}
13+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
3+
import { createExecutorsServiceMock } from '../../../testing';
4+
5+
import { ExecutorsController } from './executors.controller';
6+
import { ExecutorsService } from './executors.service';
7+
8+
describe('ExecutorsController', () => {
9+
let controller: ExecutorsController;
10+
11+
beforeEach(async () => {
12+
const module: TestingModule = await Test.createTestingModule({
13+
controllers: [ExecutorsController],
14+
providers: [
15+
{ provide: ExecutorsService, useValue: createExecutorsServiceMock() },
16+
],
17+
}).compile();
18+
19+
controller = module.get<ExecutorsController>(ExecutorsController);
20+
});
21+
22+
it('should be defined', () => {
23+
expect(controller).toBeDefined();
24+
});
25+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
Command,
3+
KillDto,
4+
RunTaskResponse,
5+
Task,
6+
TaskStatus,
7+
} from '@angular-cli-gui/shared/data';
8+
import {
9+
BadRequestException,
10+
Body,
11+
Controller,
12+
Delete,
13+
Get,
14+
Post,
15+
Query,
16+
} from '@nestjs/common';
17+
18+
import { ExecutorsService } from './executors.service';
19+
20+
@Controller('executors')
21+
export class ExecutorsController {
22+
constructor(private readonly executorsService: ExecutorsService) {}
23+
24+
@Post()
25+
async run(@Body() commandDto: Command): Promise<RunTaskResponse> {
26+
const command: string = commandDto.command;
27+
const args = this.readArgsFromDto(commandDto);
28+
const pid = await this.executorsService.execAsync([command].concat(args));
29+
30+
return { pid };
31+
}
32+
33+
@Get('status')
34+
getTaskStatus(@Query('pid') pid: number): TaskStatus {
35+
if (!pid) {
36+
throw new BadRequestException('PID was not provided');
37+
}
38+
39+
return this.executorsService.readTaskStatus(pid);
40+
}
41+
42+
@Delete()
43+
killTask(@Body() killDto: KillDto): void {
44+
this.executorsService.killTask(killDto.pid);
45+
}
46+
47+
private readArgsFromDto(commandDto: Command): string[] {
48+
const args: string[] = [];
49+
50+
for (const key in commandDto) {
51+
if (key === 'command') {
52+
continue;
53+
}
54+
55+
args.push(`--${key}`, commandDto[key]?.toString());
56+
}
57+
58+
return args;
59+
}
60+
61+
@Get()
62+
readAllTasks(): Task[] {
63+
return this.executorsService.readAllTasks();
64+
}
65+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { ExecutorsController } from './executors.controller';
4+
import { ExecutorsService } from './executors.service';
5+
6+
@Module({
7+
controllers: [ExecutorsController],
8+
providers: [ExecutorsService],
9+
exports: [ExecutorsService],
10+
})
11+
export class ExecutorsModule {}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
3+
import { createSessionServiceMock } from '../../../testing';
4+
import { SessionService } from '../session/session.service';
5+
6+
import { ExecutorsService } from './executors.service';
7+
8+
describe('ExecutorsService', () => {
9+
let service: ExecutorsService;
10+
11+
beforeEach(async () => {
12+
const module: TestingModule = await Test.createTestingModule({
13+
providers: [
14+
ExecutorsService,
15+
{ provide: SessionService, useValue: createSessionServiceMock() },
16+
],
17+
}).compile();
18+
19+
service = module.get<ExecutorsService>(ExecutorsService);
20+
});
21+
22+
it('should be defined', () => {
23+
expect(service).toBeDefined();
24+
});
25+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {
2+
ChildProcessWithoutNullStreams,
3+
execSync,
4+
spawn,
5+
} from 'node:child_process';
6+
import { resolve as pathResolve } from 'node:path';
7+
import process from 'node:process';
8+
9+
import { Task, TaskStatus } from '@angular-cli-gui/shared/data';
10+
import {
11+
Global,
12+
Injectable,
13+
InternalServerErrorException,
14+
Logger,
15+
NotFoundException,
16+
} from '@nestjs/common';
17+
18+
import { SessionService } from '../session/session.service';
19+
20+
import { ChildProcesses } from './child-processes';
21+
import { spawnedProcessCommandLine } from './utils';
22+
23+
const NG = 'npx ng';
24+
25+
@Global()
26+
@Injectable()
27+
export class ExecutorsService {
28+
private readonly logger = new Logger(ExecutorsService.name);
29+
private childProcesses = new ChildProcesses();
30+
31+
constructor(private readonly sessionService: SessionService) {
32+
process.on('SIGINT', () => this.destroy());
33+
process.on('SIGTERM', () => this.destroy());
34+
}
35+
36+
async execAsync(args: string[]): Promise<number> {
37+
try {
38+
const childProcess = await this.childProcessSpawn(args);
39+
this.childProcesses.add(childProcess);
40+
41+
return childProcess.pid as number;
42+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
43+
} catch (err: any) {
44+
this.logger.error(err.message);
45+
throw new InternalServerErrorException(err.message);
46+
}
47+
}
48+
49+
readTaskStatus(pid: number): TaskStatus {
50+
const childProcess = this.childProcesses.get(pid);
51+
52+
if (!childProcess) {
53+
throw new NotFoundException(`Process with pid: ${pid} was not found`);
54+
}
55+
56+
return childProcess.status;
57+
}
58+
59+
killTask(pid: number): void {
60+
const childProcess = this.childProcesses.get(pid);
61+
62+
if (!childProcess) {
63+
throw new NotFoundException(`Process with pid: ${pid} was not found`);
64+
}
65+
66+
this.kill(pid);
67+
}
68+
69+
readAllTasks(): Task[] {
70+
const tasks: Task[] = [];
71+
72+
for (const [pid, childProcess] of this.childProcesses.entries()) {
73+
const { isRunning, exitCode } = childProcess.status;
74+
const commandLine = spawnedProcessCommandLine(
75+
childProcess.spawnedProcess
76+
);
77+
tasks.push({ pid, isRunning, exitCode, commandLine });
78+
}
79+
80+
return tasks;
81+
}
82+
83+
private destroy(): void {
84+
this.logger.log('Application destroy hook');
85+
}
86+
87+
private childProcessSpawn(
88+
args: string[]
89+
): Promise<ChildProcessWithoutNullStreams> {
90+
return new Promise((resolve, reject) => {
91+
const childProcess = spawn(NG, args, {
92+
cwd: pathResolve(this.sessionService.cwd),
93+
env: process.env,
94+
shell: true,
95+
});
96+
97+
childProcess.on('error', (err) => reject(err));
98+
99+
if (childProcess.pid) {
100+
resolve(childProcess);
101+
}
102+
});
103+
}
104+
105+
private kill(pid: number): void {
106+
switch (process.platform) {
107+
case 'win32':
108+
execSync(`taskkill /pid ${pid} /T /F`);
109+
break;
110+
case 'darwin':
111+
throw new Error('Not yet implemented');
112+
break;
113+
default:
114+
throw new Error('Not yet implemented');
115+
}
116+
}
117+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { TerminalRecord } from '@angular-cli-gui/shared/data';
2+
3+
import { stringToArray } from '../utils';
4+
5+
export class StringBuffer {
6+
private _data: TerminalRecord[] = [];
7+
private counter = 1;
8+
9+
get data(): TerminalRecord[] {
10+
return this._data.slice(0);
11+
}
12+
13+
constructor(private readonly bufferSize?: number) {}
14+
15+
add(data: Buffer): void {
16+
const dataStrings = stringToArray(data.toString('utf8'));
17+
18+
this._data = this._data.concat(
19+
dataStrings.map((s) => ({
20+
id: this.counter++,
21+
timestamp: Date.now(),
22+
content: s,
23+
}))
24+
);
25+
26+
if (this.bufferSize && this._data.length > this.bufferSize) {
27+
this._data.splice(0, this._data.length - this.bufferSize);
28+
}
29+
}
30+
31+
clear(): void {
32+
this._data = [];
33+
}
34+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './spawned-process-cmd-line';

0 commit comments

Comments
 (0)