@iskra-bun/process-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @iskra-bun/process-kit
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Initial public release.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @iskra-bun/process-kit
2
+
3
+ Gestion de procesos externos (scripts de Python, binarios) para Iskra. Soporta modos daemon, oneshot y stdio, con ciclo de vida y validacion de configuracion.
4
+
5
+ ## Instalacion
6
+
7
+ ```bash
8
+ bun add @iskra-bun/process-kit @iskra-bun/core
9
+ ```
10
+
11
+ ## Uso rapido
12
+
13
+ ```typescript
14
+ import { App } from '@iskra-bun/core'
15
+ import { ProcessManager } from '@iskra-bun/process-kit'
16
+
17
+ const app = new App({ name: 'mi-app' })
18
+ app.register(new ProcessManager({ command: 'python', args: ['worker.py'], mode: 'daemon' }))
19
+
20
+ await app.start()
21
+ ```
22
+
23
+ ## Documentacion
24
+
25
+ Guia completa: [docs/process-kit.md](../../docs/process-kit.md)
26
+
27
+ ## Licencia
28
+
29
+ AGPL-3.0-or-later
@@ -0,0 +1,19 @@
1
+ import { Driver, App } from '@iskra-bun/core';
2
+
3
+ declare class ProcessManager implements Driver {
4
+ name: string;
5
+ private app;
6
+ private processes;
7
+ private stopping;
8
+ init(app: App): void;
9
+ start(): Promise<void>;
10
+ private readStdOut;
11
+ private processLine;
12
+ private handleExit;
13
+ private spawnProcess;
14
+ private readStdErr;
15
+ send(name: string, data: any): Promise<void>;
16
+ stop(): Promise<void>;
17
+ }
18
+
19
+ export { ProcessManager };
package/dist/index.js ADDED
@@ -0,0 +1,152 @@
1
+ // src/spawner.ts
2
+ var ProcessManager = class {
3
+ name = "ProcessManager";
4
+ app = null;
5
+ processes = /* @__PURE__ */ new Map();
6
+ stopping = false;
7
+ init(app) {
8
+ this.app = app;
9
+ }
10
+ async start() {
11
+ if (!this.app) return;
12
+ const processConfigs = this.app.config.processes;
13
+ if (!processConfigs) {
14
+ this.app.logger.debug("No processes configured.");
15
+ return;
16
+ }
17
+ this.app.logger.info(`Starting ${Object.keys(processConfigs).length} processes...`);
18
+ for (const [name, config] of Object.entries(processConfigs)) {
19
+ this.spawnProcess(name, config);
20
+ }
21
+ }
22
+ async readStdOut(name, stream) {
23
+ const reader = stream.getReader();
24
+ const decoder = new TextDecoder();
25
+ let buffer = "";
26
+ try {
27
+ while (true) {
28
+ const { done, value } = await reader.read();
29
+ if (done) break;
30
+ buffer += decoder.decode(value, { stream: true });
31
+ const lines = buffer.split("\n");
32
+ buffer = lines.pop() || "";
33
+ for (const line of lines) {
34
+ if (!line.trim()) continue;
35
+ this.processLine(name, line);
36
+ }
37
+ }
38
+ } catch (err) {
39
+ }
40
+ }
41
+ processLine(name, line) {
42
+ try {
43
+ const json = JSON.parse(line);
44
+ this.app?.emit("process:message", { name, message: json });
45
+ } catch {
46
+ this.app?.emit("process:log", { name, text: line });
47
+ }
48
+ }
49
+ handleExit(name, exitCode, signalCode) {
50
+ if (this.stopping) return;
51
+ const procInfo = this.processes.get(name);
52
+ if (!procInfo) return;
53
+ this.app?.logger.warn(`Process ${name} exited with code ${exitCode}`);
54
+ this.processes.delete(name);
55
+ if (procInfo.config.restartOnCrash) {
56
+ const maxRestarts = procInfo.config.maxRestarts ?? 10;
57
+ const cooldown = procInfo.config.restartCooldown ?? 6e4;
58
+ const uptime = Date.now() - procInfo.startedAt;
59
+ const restarts = uptime > cooldown ? 1 : procInfo.restarts + 1;
60
+ if (restarts > maxRestarts) {
61
+ this.app?.logger.error(`Process ${name} exceeded max restarts (${maxRestarts}). Not restarting.`);
62
+ this.app?.emit("process:max-restarts", { name, restarts: procInfo.restarts, maxRestarts });
63
+ return;
64
+ }
65
+ this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts})`);
66
+ setTimeout(() => {
67
+ this.spawnProcess(name, procInfo.config, restarts);
68
+ }, 1e3);
69
+ }
70
+ }
71
+ // Updated spawn signature to track restarts
72
+ spawnProcess(name, config, restarts = 0) {
73
+ if (this.stopping || !this.app) return;
74
+ this.app.logger.info(`Spawning process: ${name} (${config.command} ${config.args?.join(" ") || ""})`);
75
+ try {
76
+ const proc = Bun.spawn(
77
+ [config.command, ...config.args || []],
78
+ {
79
+ env: { ...process.env, ...config.env },
80
+ stdout: config.mode === "stdio" ? "pipe" : "inherit",
81
+ stderr: config.mode === "stdio" ? "pipe" : "inherit",
82
+ stdin: config.mode === "stdio" ? "pipe" : "ignore",
83
+ onExit: (proc2, exitCode, signalCode, error) => {
84
+ this.handleExit(name, exitCode || 0, signalCode || 0);
85
+ }
86
+ }
87
+ );
88
+ this.processes.set(name, {
89
+ process: proc,
90
+ config,
91
+ name,
92
+ restarts,
93
+ startedAt: Date.now()
94
+ });
95
+ if (config.mode === "stdio") {
96
+ if (proc.stdout) this.readStdOut(name, proc.stdout);
97
+ if (proc.stderr) this.readStdErr(name, proc.stderr);
98
+ }
99
+ } catch (err) {
100
+ this.app.logger.error({ err }, `Failed to spawn process: ${name}`);
101
+ }
102
+ }
103
+ async readStdErr(name, stream) {
104
+ const reader = stream.getReader();
105
+ const decoder = new TextDecoder();
106
+ try {
107
+ while (true) {
108
+ const { done, value } = await reader.read();
109
+ if (done) break;
110
+ const text = decoder.decode(value, { stream: true });
111
+ if (text.trim()) {
112
+ this.app?.emit("process:error", { name, text });
113
+ }
114
+ }
115
+ } catch (err) {
116
+ }
117
+ }
118
+ // Send data to process stdin
119
+ async send(name, data) {
120
+ const procInfo = this.processes.get(name);
121
+ if (!procInfo) {
122
+ this.app?.logger.warn(`Cannot send message to non-existent process: ${name}`);
123
+ return;
124
+ }
125
+ if (procInfo.config.mode !== "stdio" || !procInfo.process.stdin) {
126
+ this.app?.logger.warn(`Cannot send message to process ${name} (mode is not stdio or stdin is closed)`);
127
+ return;
128
+ }
129
+ const stdin = procInfo.process.stdin;
130
+ try {
131
+ const message = typeof data === "string" ? data : JSON.stringify(data);
132
+ stdin.write(message + "\n");
133
+ stdin.flush();
134
+ } catch (err) {
135
+ this.app?.logger.error({ err }, `Failed to write to process ${name}`);
136
+ }
137
+ }
138
+ async stop() {
139
+ this.stopping = true;
140
+ this.app?.logger.info("Stopping all processes...");
141
+ for (const [name, info] of this.processes) {
142
+ if (!info.process.killed) {
143
+ info.process.kill();
144
+ }
145
+ }
146
+ this.processes.clear();
147
+ }
148
+ };
149
+ export {
150
+ ProcessManager
151
+ };
152
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/spawner.ts"],"sourcesContent":["import type { App, Driver, ProcessConfig } from '@iskra-bun/core';\nimport { Subprocess } from 'bun';\n\ninterface RunningProcess {\n process: Subprocess;\n config: ProcessConfig;\n name: string;\n restarts: number;\n startedAt: number;\n}\n\nexport class ProcessManager implements Driver {\n name = 'ProcessManager';\n private app: App | null = null;\n private processes: Map<string, RunningProcess> = new Map();\n private stopping = false;\n\n init(app: App) {\n this.app = app;\n }\n\n async start() {\n if (!this.app) return;\n const processConfigs = this.app.config.processes;\n\n if (!processConfigs) {\n this.app.logger.debug('No processes configured.');\n return;\n }\n\n this.app.logger.info(`Starting ${Object.keys(processConfigs).length} processes...`);\n\n for (const [name, config] of Object.entries(processConfigs)) {\n this.spawnProcess(name, config);\n }\n }\n\n private async readStdOut(name: string, stream: ReadableStream) {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n\n const lines = buffer.split('\\n');\n // Keep the last chunk if it's not a complete line (doesn't end with \\n)\n buffer = lines.pop() || '';\n\n for (const line of lines) {\n if (!line.trim()) continue;\n this.processLine(name, line);\n }\n }\n } catch (err) {\n // Stream closed or error\n }\n }\n\n private processLine(name: string, line: string) {\n try {\n // Try to parse as JSON Event\n const json = JSON.parse(line);\n this.app?.emit('process:message', { name, message: json });\n } catch {\n // Fallback to raw log\n this.app?.emit('process:log', { name, text: line });\n }\n }\n\n private handleExit(name: string, exitCode: number, signalCode: number) {\n if (this.stopping) return;\n\n const procInfo = this.processes.get(name);\n if (!procInfo) return;\n\n this.app?.logger.warn(`Process ${name} exited with code ${exitCode}`);\n\n // Remove from map so we don't try to kill it again on stop()\n this.processes.delete(name);\n\n if (procInfo.config.restartOnCrash) {\n const maxRestarts = procInfo.config.maxRestarts ?? 10;\n const cooldown = procInfo.config.restartCooldown ?? 60000;\n const uptime = Date.now() - procInfo.startedAt;\n\n // If the process ran longer than the cooldown, consider it stable and reset the counter\n const restarts = uptime > cooldown ? 1 : procInfo.restarts + 1;\n\n if (restarts > maxRestarts) {\n this.app?.logger.error(`Process ${name} exceeded max restarts (${maxRestarts}). Not restarting.`);\n this.app?.emit('process:max-restarts', { name, restarts: procInfo.restarts, maxRestarts });\n return;\n }\n\n this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts})`);\n\n setTimeout(() => {\n this.spawnProcess(name, procInfo.config, restarts);\n }, 1000);\n }\n }\n\n // Updated spawn signature to track restarts\n private spawnProcess(name: string, config: ProcessConfig, restarts = 0) {\n if (this.stopping || !this.app) return;\n\n this.app.logger.info(`Spawning process: ${name} (${config.command} ${config.args?.join(' ') || ''})`);\n\n try {\n const proc = Bun.spawn(\n [config.command, ...(config.args || [])],\n {\n env: { ...process.env, ...config.env },\n stdout: config.mode === 'stdio' ? 'pipe' : 'inherit',\n stderr: config.mode === 'stdio' ? 'pipe' : 'inherit',\n stdin: config.mode === 'stdio' ? 'pipe' : 'ignore',\n onExit: (proc, exitCode, signalCode, error) => {\n this.handleExit(name, exitCode || 0, signalCode || 0);\n }\n }\n );\n\n this.processes.set(name, {\n process: proc,\n config,\n name,\n restarts,\n startedAt: Date.now(),\n });\n\n if (config.mode === 'stdio') {\n if (proc.stdout) this.readStdOut(name, proc.stdout);\n if (proc.stderr) this.readStdErr(name, proc.stderr);\n }\n\n } catch (err) {\n this.app.logger.error({ err }, `Failed to spawn process: ${name}`);\n }\n }\n\n private async readStdErr(name: string, stream: ReadableStream) {\n const reader = stream.getReader();\n const decoder = new TextDecoder();\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n const text = decoder.decode(value, { stream: true });\n if (text.trim()) {\n this.app?.emit('process:error', { name, text });\n }\n }\n } catch (err) {\n // Stream closed\n }\n }\n\n // Send data to process stdin\n async send(name: string, data: any) {\n const procInfo = this.processes.get(name);\n if (!procInfo) {\n this.app?.logger.warn(`Cannot send message to non-existent process: ${name}`);\n return;\n }\n\n if (procInfo.config.mode !== 'stdio' || !procInfo.process.stdin) {\n this.app?.logger.warn(`Cannot send message to process ${name} (mode is not stdio or stdin is closed)`);\n return;\n }\n\n // Bun's Subprocess.stdin is a FileSink\n const stdin = procInfo.process.stdin as any;\n\n try {\n // If data is object, stringify it and add newline\n const message = typeof data === 'string' ? data : JSON.stringify(data);\n stdin.write(message + '\\n');\n stdin.flush();\n } catch (err) {\n this.app?.logger.error({ err }, `Failed to write to process ${name}`);\n }\n }\n\n async stop() {\n this.stopping = true;\n this.app?.logger.info('Stopping all processes...');\n\n for (const [name, info] of this.processes) {\n if (!info.process.killed) {\n info.process.kill();\n }\n }\n this.processes.clear();\n }\n}\n"],"mappings":"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","names":["proc"]}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@iskra-bun/process-kit",
3
+ "version": "0.1.0",
4
+ "description": "Gestion de procesos externos (Python, binarios) para Iskra.",
5
+ "keywords": [
6
+ "iskra",
7
+ "bun",
8
+ "typescript",
9
+ "process",
10
+ "python",
11
+ "ipc"
12
+ ],
13
+ "author": "Joan Lascano",
14
+ "license": "AGPL-3.0-or-later",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/fearful/iskra.git",
18
+ "directory": "packages/process-kit"
19
+ },
20
+ "homepage": "https://github.com/fearful/iskra/tree/main/packages/process-kit#readme",
21
+ "bugs": "https://github.com/fearful/iskra/issues",
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "source": "./src/index.ts",
29
+ "bun": "./src/index.ts",
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src",
38
+ "README.md",
39
+ "CHANGELOG.md"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "test": "bun test",
46
+ "build": "tsup --config ../../tsup.config.ts"
47
+ },
48
+ "dependencies": {
49
+ "@iskra-bun/core": "0.1.0",
50
+ "zod": "^3.24.1"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.10.2"
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { ProcessManager } from './spawner';
package/src/spawner.ts ADDED
@@ -0,0 +1,201 @@
1
+ import type { App, Driver, ProcessConfig } from '@iskra-bun/core';
2
+ import { Subprocess } from 'bun';
3
+
4
+ interface RunningProcess {
5
+ process: Subprocess;
6
+ config: ProcessConfig;
7
+ name: string;
8
+ restarts: number;
9
+ startedAt: number;
10
+ }
11
+
12
+ export class ProcessManager implements Driver {
13
+ name = 'ProcessManager';
14
+ private app: App | null = null;
15
+ private processes: Map<string, RunningProcess> = new Map();
16
+ private stopping = false;
17
+
18
+ init(app: App) {
19
+ this.app = app;
20
+ }
21
+
22
+ async start() {
23
+ if (!this.app) return;
24
+ const processConfigs = this.app.config.processes;
25
+
26
+ if (!processConfigs) {
27
+ this.app.logger.debug('No processes configured.');
28
+ return;
29
+ }
30
+
31
+ this.app.logger.info(`Starting ${Object.keys(processConfigs).length} processes...`);
32
+
33
+ for (const [name, config] of Object.entries(processConfigs)) {
34
+ this.spawnProcess(name, config);
35
+ }
36
+ }
37
+
38
+ private async readStdOut(name: string, stream: ReadableStream) {
39
+ const reader = stream.getReader();
40
+ const decoder = new TextDecoder();
41
+ let buffer = '';
42
+
43
+ try {
44
+ while (true) {
45
+ const { done, value } = await reader.read();
46
+ if (done) break;
47
+
48
+ buffer += decoder.decode(value, { stream: true });
49
+
50
+ const lines = buffer.split('\n');
51
+ // Keep the last chunk if it's not a complete line (doesn't end with \n)
52
+ buffer = lines.pop() || '';
53
+
54
+ for (const line of lines) {
55
+ if (!line.trim()) continue;
56
+ this.processLine(name, line);
57
+ }
58
+ }
59
+ } catch (err) {
60
+ // Stream closed or error
61
+ }
62
+ }
63
+
64
+ private processLine(name: string, line: string) {
65
+ try {
66
+ // Try to parse as JSON Event
67
+ const json = JSON.parse(line);
68
+ this.app?.emit('process:message', { name, message: json });
69
+ } catch {
70
+ // Fallback to raw log
71
+ this.app?.emit('process:log', { name, text: line });
72
+ }
73
+ }
74
+
75
+ private handleExit(name: string, exitCode: number, signalCode: number) {
76
+ if (this.stopping) return;
77
+
78
+ const procInfo = this.processes.get(name);
79
+ if (!procInfo) return;
80
+
81
+ this.app?.logger.warn(`Process ${name} exited with code ${exitCode}`);
82
+
83
+ // Remove from map so we don't try to kill it again on stop()
84
+ this.processes.delete(name);
85
+
86
+ if (procInfo.config.restartOnCrash) {
87
+ const maxRestarts = procInfo.config.maxRestarts ?? 10;
88
+ const cooldown = procInfo.config.restartCooldown ?? 60000;
89
+ const uptime = Date.now() - procInfo.startedAt;
90
+
91
+ // If the process ran longer than the cooldown, consider it stable and reset the counter
92
+ const restarts = uptime > cooldown ? 1 : procInfo.restarts + 1;
93
+
94
+ if (restarts > maxRestarts) {
95
+ this.app?.logger.error(`Process ${name} exceeded max restarts (${maxRestarts}). Not restarting.`);
96
+ this.app?.emit('process:max-restarts', { name, restarts: procInfo.restarts, maxRestarts });
97
+ return;
98
+ }
99
+
100
+ this.app?.logger.info(`Restarting process: ${name} (Attempt ${restarts}/${maxRestarts})`);
101
+
102
+ setTimeout(() => {
103
+ this.spawnProcess(name, procInfo.config, restarts);
104
+ }, 1000);
105
+ }
106
+ }
107
+
108
+ // Updated spawn signature to track restarts
109
+ private spawnProcess(name: string, config: ProcessConfig, restarts = 0) {
110
+ if (this.stopping || !this.app) return;
111
+
112
+ this.app.logger.info(`Spawning process: ${name} (${config.command} ${config.args?.join(' ') || ''})`);
113
+
114
+ try {
115
+ const proc = Bun.spawn(
116
+ [config.command, ...(config.args || [])],
117
+ {
118
+ env: { ...process.env, ...config.env },
119
+ stdout: config.mode === 'stdio' ? 'pipe' : 'inherit',
120
+ stderr: config.mode === 'stdio' ? 'pipe' : 'inherit',
121
+ stdin: config.mode === 'stdio' ? 'pipe' : 'ignore',
122
+ onExit: (proc, exitCode, signalCode, error) => {
123
+ this.handleExit(name, exitCode || 0, signalCode || 0);
124
+ }
125
+ }
126
+ );
127
+
128
+ this.processes.set(name, {
129
+ process: proc,
130
+ config,
131
+ name,
132
+ restarts,
133
+ startedAt: Date.now(),
134
+ });
135
+
136
+ if (config.mode === 'stdio') {
137
+ if (proc.stdout) this.readStdOut(name, proc.stdout);
138
+ if (proc.stderr) this.readStdErr(name, proc.stderr);
139
+ }
140
+
141
+ } catch (err) {
142
+ this.app.logger.error({ err }, `Failed to spawn process: ${name}`);
143
+ }
144
+ }
145
+
146
+ private async readStdErr(name: string, stream: ReadableStream) {
147
+ const reader = stream.getReader();
148
+ const decoder = new TextDecoder();
149
+
150
+ try {
151
+ while (true) {
152
+ const { done, value } = await reader.read();
153
+ if (done) break;
154
+ const text = decoder.decode(value, { stream: true });
155
+ if (text.trim()) {
156
+ this.app?.emit('process:error', { name, text });
157
+ }
158
+ }
159
+ } catch (err) {
160
+ // Stream closed
161
+ }
162
+ }
163
+
164
+ // Send data to process stdin
165
+ async send(name: string, data: any) {
166
+ const procInfo = this.processes.get(name);
167
+ if (!procInfo) {
168
+ this.app?.logger.warn(`Cannot send message to non-existent process: ${name}`);
169
+ return;
170
+ }
171
+
172
+ if (procInfo.config.mode !== 'stdio' || !procInfo.process.stdin) {
173
+ this.app?.logger.warn(`Cannot send message to process ${name} (mode is not stdio or stdin is closed)`);
174
+ return;
175
+ }
176
+
177
+ // Bun's Subprocess.stdin is a FileSink
178
+ const stdin = procInfo.process.stdin as any;
179
+
180
+ try {
181
+ // If data is object, stringify it and add newline
182
+ const message = typeof data === 'string' ? data : JSON.stringify(data);
183
+ stdin.write(message + '\n');
184
+ stdin.flush();
185
+ } catch (err) {
186
+ this.app?.logger.error({ err }, `Failed to write to process ${name}`);
187
+ }
188
+ }
189
+
190
+ async stop() {
191
+ this.stopping = true;
192
+ this.app?.logger.info('Stopping all processes...');
193
+
194
+ for (const [name, info] of this.processes) {
195
+ if (!info.process.killed) {
196
+ info.process.kill();
197
+ }
198
+ }
199
+ this.processes.clear();
200
+ }
201
+ }