@effectionx/process 0.6.1 → 0.7.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/dist/mod.d.ts +3 -0
  3. package/dist/mod.d.ts.map +1 -0
  4. package/{esm → dist}/src/daemon.d.ts +1 -1
  5. package/dist/src/daemon.d.ts.map +1 -0
  6. package/{script → dist}/src/exec/api.d.ts +2 -4
  7. package/dist/src/exec/api.d.ts.map +1 -0
  8. package/{script → dist}/src/exec/error.d.ts +1 -1
  9. package/dist/src/exec/error.d.ts.map +1 -0
  10. package/dist/src/exec/error.js +29 -0
  11. package/dist/src/exec/posix.d.ts +3 -0
  12. package/dist/src/exec/posix.d.ts.map +1 -0
  13. package/{esm → dist}/src/exec/posix.js +12 -13
  14. package/{esm → dist}/src/exec/win32.d.ts +1 -1
  15. package/dist/src/exec/win32.d.ts.map +1 -0
  16. package/{esm → dist}/src/exec/win32.js +20 -13
  17. package/{script → dist}/src/exec.d.ts +3 -3
  18. package/dist/src/exec.d.ts.map +1 -0
  19. package/{esm → dist}/src/exec.js +3 -5
  20. package/dist/src/helpers.d.ts +4 -0
  21. package/dist/src/helpers.d.ts.map +1 -0
  22. package/dist/src/helpers.js +10 -0
  23. package/dist/tsconfig.tsbuildinfo +1 -0
  24. package/mod.ts +2 -0
  25. package/package.json +25 -19
  26. package/src/daemon.ts +34 -0
  27. package/src/exec/api.ts +83 -0
  28. package/src/exec/error.ts +43 -0
  29. package/src/exec/posix.ts +133 -0
  30. package/src/exec/win32.ts +217 -0
  31. package/src/exec.ts +100 -0
  32. package/src/helpers.ts +12 -0
  33. package/test/daemon.test.ts +106 -0
  34. package/test/eventemitter.test.ts +82 -0
  35. package/test/exec.test.ts +481 -0
  36. package/test/fixtures/dump-args.js +13 -0
  37. package/test/fixtures/echo-server.ts +49 -0
  38. package/test/fixtures/hello-world-failed.js +5 -0
  39. package/test/fixtures/hello-world.js +5 -0
  40. package/test/helpers.ts +69 -0
  41. package/test/output-stream.test.ts +73 -0
  42. package/tsconfig.json +20 -0
  43. package/esm/mod.d.ts +0 -3
  44. package/esm/mod.d.ts.map +0 -1
  45. package/esm/package.json +0 -3
  46. package/esm/src/daemon.d.ts.map +0 -1
  47. package/esm/src/eventemitter.d.ts +0 -22
  48. package/esm/src/eventemitter.d.ts.map +0 -1
  49. package/esm/src/eventemitter.js +0 -40
  50. package/esm/src/exec/api.d.ts +0 -70
  51. package/esm/src/exec/api.d.ts.map +0 -1
  52. package/esm/src/exec/error.d.ts +0 -14
  53. package/esm/src/exec/error.d.ts.map +0 -1
  54. package/esm/src/exec/error.js +0 -54
  55. package/esm/src/exec/posix.d.ts +0 -3
  56. package/esm/src/exec/posix.d.ts.map +0 -1
  57. package/esm/src/exec/win32.d.ts.map +0 -1
  58. package/esm/src/exec.d.ts +0 -16
  59. package/esm/src/exec.d.ts.map +0 -1
  60. package/esm/src/helpers.d.ts +0 -12
  61. package/esm/src/helpers.d.ts.map +0 -1
  62. package/esm/src/helpers.js +0 -71
  63. package/script/mod.d.ts +0 -3
  64. package/script/mod.d.ts.map +0 -1
  65. package/script/mod.js +0 -20
  66. package/script/package.json +0 -3
  67. package/script/src/daemon.d.ts +0 -11
  68. package/script/src/daemon.d.ts.map +0 -1
  69. package/script/src/daemon.js +0 -23
  70. package/script/src/eventemitter.d.ts +0 -22
  71. package/script/src/eventemitter.d.ts.map +0 -1
  72. package/script/src/eventemitter.js +0 -44
  73. package/script/src/exec/api.d.ts.map +0 -1
  74. package/script/src/exec/api.js +0 -2
  75. package/script/src/exec/error.d.ts.map +0 -1
  76. package/script/src/exec/error.js +0 -59
  77. package/script/src/exec/posix.d.ts +0 -3
  78. package/script/src/exec/posix.d.ts.map +0 -1
  79. package/script/src/exec/posix.js +0 -117
  80. package/script/src/exec/win32.d.ts +0 -4
  81. package/script/src/exec/win32.d.ts.map +0 -1
  82. package/script/src/exec/win32.js +0 -177
  83. package/script/src/exec.d.ts.map +0 -1
  84. package/script/src/exec.js +0 -92
  85. package/script/src/helpers.d.ts +0 -12
  86. package/script/src/helpers.d.ts.map +0 -1
  87. package/script/src/helpers.js +0 -76
  88. /package/{esm → dist}/mod.js +0 -0
  89. /package/{esm → dist}/src/daemon.js +0 -0
  90. /package/{esm → dist}/src/exec/api.js +0 -0
@@ -0,0 +1,217 @@
1
+ import { platform } from "node:os";
2
+ import { once } from "@effectionx/node/events";
3
+ import { fromReadable } from "@effectionx/node/stream";
4
+ // @ts-types="npm:@types/cross-spawn@6.0.6"
5
+ import { spawn as spawnProcess } from "cross-spawn";
6
+ import { ctrlc } from "ctrlc-windows";
7
+ import {
8
+ Err,
9
+ Ok,
10
+ type Result,
11
+ all,
12
+ createSignal,
13
+ resource,
14
+ spawn,
15
+ withResolvers,
16
+ } from "effection";
17
+ import type { CreateOSProcess, ExitStatus, Writable } from "./api.ts";
18
+ import { ExecError } from "./error.ts";
19
+
20
+ type ProcessResultValue = [number?, string?];
21
+
22
+ function* killTree(pid: number) {
23
+ try {
24
+ const killer = spawnProcess(
25
+ "cmd.exe",
26
+ ["/c", "taskkill", "/PID", String(pid), "/T", "/F"],
27
+ { windowsHide: true, stdio: "ignore" },
28
+ );
29
+ yield* once(killer, "close");
30
+ } catch (_) {
31
+ // best-effort; ignore errors
32
+ }
33
+ }
34
+
35
+ export const createWin32Process: CreateOSProcess = (command, options) => {
36
+ return resource(function* (provide) {
37
+ let processResult = withResolvers<Result<ProcessResultValue>>();
38
+
39
+ // Windows-specific process spawning with different options than POSIX
40
+ let childProcess = spawnProcess(command, options.arguments || [], {
41
+ // We lose exit information and events if this is detached in windows
42
+ // and it opens a window in windows+powershell.
43
+ detached: false,
44
+ // The `shell` option is passed to `cross-spawn` to control whether a shell is used.
45
+ // On Windows, `shell: true` is necessary to run command strings, as it uses
46
+ // `cmd.exe` to parse the command and find executables in the PATH.
47
+ // Using a boolean `true` was previously disabled, causing ENOENT errors for
48
+ // commands that were not a direct path to an executable.
49
+ shell: options.shell || false,
50
+ // With stdio as pipe, windows gets stuck where neither the child nor the
51
+ // parent wants to close the stream, so we call it ourselves in the exit event.
52
+ stdio: "pipe",
53
+ // Hide the child window so that killing it will not block the parent
54
+ // with a Terminate Batch Process (Y/n)
55
+ windowsHide: true,
56
+ env: options.env,
57
+ cwd: options.cwd,
58
+ });
59
+
60
+ let { pid } = childProcess;
61
+
62
+ if (!childProcess.stdout || !childProcess.stderr) {
63
+ throw new Error("stdout and stderr must be available with stdio: pipe");
64
+ }
65
+
66
+ let io = {
67
+ stdout: yield* fromReadable(childProcess.stdout),
68
+ stderr: yield* fromReadable(childProcess.stderr),
69
+ stdoutDone: withResolvers<void>(),
70
+ stderrDone: withResolvers<void>(),
71
+ };
72
+
73
+ const stdout = createSignal<Uint8Array, void>();
74
+ const stderr = createSignal<Uint8Array, void>();
75
+
76
+ yield* spawn(function* () {
77
+ let next = yield* io.stdout.next();
78
+ while (!next.done) {
79
+ stdout.send(next.value);
80
+ next = yield* io.stdout.next();
81
+ }
82
+ stdout.close();
83
+ io.stdoutDone.resolve();
84
+ });
85
+
86
+ yield* spawn(function* () {
87
+ let next = yield* io.stderr.next();
88
+ while (!next.done) {
89
+ stderr.send(next.value);
90
+ next = yield* io.stderr.next();
91
+ }
92
+ stderr.close();
93
+ io.stderrDone.resolve();
94
+ });
95
+
96
+ let stdin: Writable<string> = {
97
+ send(data: string) {
98
+ childProcess.stdin.write(data);
99
+ },
100
+ };
101
+
102
+ yield* spawn(function* trapError() {
103
+ const [error] = yield* once<Error[]>(childProcess, "error");
104
+ processResult.resolve(Err(error));
105
+ });
106
+
107
+ yield* spawn(function* () {
108
+ let value = yield* once<ProcessResultValue>(childProcess, "close");
109
+ yield* all([io.stdoutDone.operation, io.stderrDone.operation]);
110
+ processResult.resolve(Ok(value));
111
+ });
112
+
113
+ function* join() {
114
+ let result = yield* processResult.operation;
115
+ if (result.ok) {
116
+ let [code, signal] = result.value;
117
+ return { command, options, code, signal } as ExitStatus;
118
+ }
119
+ throw result.error;
120
+ }
121
+
122
+ function* expect() {
123
+ let status = yield* join();
124
+ if (status.code !== 0) {
125
+ throw new ExecError(status, command, options);
126
+ }
127
+ return status;
128
+ }
129
+
130
+ // Suppress EPIPE errors on stdin - these occur on Windows when the child
131
+ // process exits before we finish writing to it. This is expected during
132
+ // cleanup when we're killing the process.
133
+ childProcess.stdin.on("error", (err: Error & { code?: string }) => {
134
+ if (err.code !== "EPIPE") {
135
+ throw err;
136
+ }
137
+ });
138
+
139
+ try {
140
+ yield* provide({
141
+ pid: pid as number,
142
+ stdin,
143
+ stdout,
144
+ stderr,
145
+ join,
146
+ expect,
147
+ });
148
+ } finally {
149
+ try {
150
+ // Only try to kill the process if it hasn't exited yet
151
+ if (
152
+ childProcess.exitCode === null &&
153
+ childProcess.signalCode === null
154
+ ) {
155
+ if (typeof childProcess.pid === "undefined") {
156
+ // biome-ignore lint/correctness/noUnsafeFinally: Intentional error for missing PID
157
+ throw new Error("no pid for childProcess");
158
+ }
159
+
160
+ let stdinStream = childProcess.stdin;
161
+
162
+ // Try graceful shutdown with ctrlc
163
+ try {
164
+ ctrlc(childProcess.pid);
165
+ if (stdinStream.writable) {
166
+ try {
167
+ // Terminate batch process (Y/N)
168
+ stdinStream.write("Y\n");
169
+ } catch (_err) {
170
+ // not much we can do here
171
+ }
172
+ }
173
+ } catch (_err) {
174
+ // ctrlc might fail
175
+ }
176
+
177
+ // Close stdin to allow process to exit cleanly
178
+ try {
179
+ stdinStream.end();
180
+ } catch (_err) {
181
+ // stdin might already be closed
182
+ }
183
+
184
+ // If process still hasn't exited, escalate
185
+ if (
186
+ childProcess.exitCode === null &&
187
+ childProcess.signalCode === null
188
+ ) {
189
+ // Try regular kill first
190
+ try {
191
+ childProcess.kill();
192
+ } catch (_err) {
193
+ // process might already be dead
194
+ }
195
+
196
+ // If still alive after kill, force-kill entire process tree
197
+ // This is necessary for bash on Windows where ctrlc doesn't work
198
+ // and child.kill() only kills the shell, leaving grandchildren alive
199
+ if (
200
+ childProcess.exitCode === null &&
201
+ childProcess.signalCode === null
202
+ ) {
203
+ yield* killTree(childProcess.pid);
204
+ }
205
+ }
206
+
207
+ // Wait for streams to finish
208
+ yield* all([io.stdoutDone.operation, io.stderrDone.operation]);
209
+ }
210
+ } catch (_e) {
211
+ // do nothing, process is probably already dead
212
+ }
213
+ }
214
+ });
215
+ };
216
+
217
+ export const isWin32 = (): boolean => platform() === "win32";
package/src/exec.ts ADDED
@@ -0,0 +1,100 @@
1
+ import shellwords from "shellwords-ts";
2
+
3
+ import { type Operation, spawn } from "effection";
4
+ import type {
5
+ CreateOSProcess,
6
+ ExecOptions,
7
+ ExitStatus,
8
+ Process,
9
+ ProcessResult,
10
+ } from "./exec/api.ts";
11
+ import { createPosixProcess } from "./exec/posix.ts";
12
+ import { createWin32Process, isWin32 } from "./exec/win32.ts";
13
+
14
+ export * from "./exec/api.ts";
15
+ export * from "./exec/error.ts";
16
+
17
+ export interface Exec extends Operation<Process> {
18
+ join(): Operation<ProcessResult>;
19
+ expect(): Operation<ProcessResult>;
20
+ }
21
+
22
+ const createProcess: CreateOSProcess = (cmd, opts) => {
23
+ if (isWin32()) {
24
+ return createWin32Process(cmd, opts);
25
+ }
26
+ return createPosixProcess(cmd, opts);
27
+ };
28
+
29
+ /**
30
+ * Execute `command` with `options`. You should use this operation for processes
31
+ * that have a finite lifetime and on which you may wish to synchronize on the
32
+ * exit status. If you want to start a process like a server that spins up and runs
33
+ * forever, consider using `daemon()`
34
+ */
35
+ export function exec(command: string, options: ExecOptions = {}): Exec {
36
+ let [cmd, ...args] = options.shell ? [command] : shellwords.split(command);
37
+ let opts = { ...options, arguments: args.concat(options.arguments || []) };
38
+
39
+ return {
40
+ *[Symbol.iterator]() {
41
+ return yield* createProcess(cmd, opts);
42
+ },
43
+ *join() {
44
+ const process = yield* createProcess(cmd, opts);
45
+
46
+ let stdout = "";
47
+ let stderr = "";
48
+
49
+ yield* spawn(function* () {
50
+ let subscription = yield* process.stdout;
51
+ let next = yield* subscription.next();
52
+ while (!next.done) {
53
+ stdout += next.value;
54
+ next = yield* subscription.next();
55
+ }
56
+ });
57
+
58
+ yield* spawn(function* () {
59
+ let subscription = yield* process.stderr;
60
+ let next = yield* subscription.next();
61
+ while (!next.done) {
62
+ stderr += next.value;
63
+ next = yield* subscription.next();
64
+ }
65
+ });
66
+
67
+ let status: ExitStatus = yield* process.join();
68
+
69
+ return { ...status, stdout, stderr };
70
+ },
71
+ *expect() {
72
+ const process = yield* createProcess(cmd, opts);
73
+
74
+ let stdout = "";
75
+ let stderr = "";
76
+
77
+ yield* spawn(function* () {
78
+ let subscription = yield* process.stdout;
79
+ let next = yield* subscription.next();
80
+ while (!next.done) {
81
+ stdout += next.value;
82
+ next = yield* subscription.next();
83
+ }
84
+ });
85
+
86
+ yield* spawn(function* () {
87
+ let subscription = yield* process.stderr;
88
+ let next = yield* subscription.next();
89
+ while (!next.done) {
90
+ stderr += next.value;
91
+ next = yield* subscription.next();
92
+ }
93
+ });
94
+
95
+ let status: ExitStatus = yield* process.expect();
96
+
97
+ return { ...status, stdout, stderr };
98
+ },
99
+ };
100
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { Err, Ok, type Operation, type Result, type Stream } from "effection";
2
+
3
+ export type OutputStream = Stream<Uint8Array, void>;
4
+
5
+ export function* box<T>(op: () => Operation<T>): Operation<Result<T>> {
6
+ try {
7
+ const value = yield* op();
8
+ return Ok(value);
9
+ } catch (e) {
10
+ return Err(e as Error);
11
+ }
12
+ }
@@ -0,0 +1,106 @@
1
+ import process from "node:process";
2
+ import { beforeEach, describe, it } from "@effectionx/bdd";
3
+ import { type Task, spawn, until, withResolvers } from "effection";
4
+ import { expect } from "expect";
5
+
6
+ import { lines } from "@effectionx/stream-helpers";
7
+ import { type Daemon, daemon } from "../mod.ts";
8
+ import { captureError, expectMatch, fetchText } from "./helpers.ts";
9
+
10
+ const SystemRoot = process.env.SystemRoot;
11
+
12
+ describe("daemon", () => {
13
+ let task: Task<void>;
14
+ let proc: Daemon;
15
+
16
+ describe("controlling from outside", () => {
17
+ beforeEach(function* () {
18
+ const result = withResolvers<Daemon>();
19
+ task = yield* spawn<void>(function* () {
20
+ let proc = yield* daemon("node", {
21
+ arguments: [
22
+ "--experimental-strip-types",
23
+ "./fixtures/echo-server.ts",
24
+ ],
25
+ env: {
26
+ PORT: "29002",
27
+ PATH: process.env.PATH as string,
28
+ ...(SystemRoot ? { SystemRoot } : {}),
29
+ },
30
+ cwd: import.meta.dirname,
31
+ });
32
+ result.resolve(proc);
33
+ yield* proc;
34
+ });
35
+
36
+ proc = yield* result.operation;
37
+
38
+ yield* expectMatch(/listening/, lines()(proc.stdout));
39
+ });
40
+
41
+ it("starts the given child", function* () {
42
+ const response = yield* fetchText("http://localhost:29002", {
43
+ method: "POST",
44
+ body: "hello",
45
+ });
46
+
47
+ expect(response.status).toEqual(200);
48
+ expect(response.text).toEqual("hello");
49
+ });
50
+
51
+ describe("halting the daemon task", () => {
52
+ beforeEach(function* () {
53
+ yield* until(task.halt());
54
+ });
55
+ it("kills the process", function* () {
56
+ expect(
57
+ yield* captureError(
58
+ fetchText("http://localhost:29002", {
59
+ method: "POST",
60
+ body: "hello",
61
+ }),
62
+ ),
63
+ ).toMatchObject({
64
+ message: expect.stringContaining("FetchError"),
65
+ });
66
+ });
67
+ });
68
+ });
69
+
70
+ describe("shutting down the daemon process prematurely", () => {
71
+ let task: Task<Error>;
72
+ beforeEach(function* () {
73
+ let proc = yield* daemon("node", {
74
+ arguments: ["--experimental-strip-types", "fixtures/echo-server.ts"],
75
+ env: {
76
+ PORT: "29001",
77
+ PATH: process.env.PATH as string,
78
+ ...(SystemRoot ? { SystemRoot } : {}),
79
+ },
80
+ cwd: import.meta.dirname,
81
+ });
82
+
83
+ task = yield* spawn(function* () {
84
+ try {
85
+ yield* proc;
86
+ } catch (e) {
87
+ return e as Error;
88
+ }
89
+ return new Error(`this shouldn't happen`);
90
+ });
91
+
92
+ yield* expectMatch(/listening/, lines()(proc.stdout));
93
+
94
+ yield* fetchText("http://localhost:29001", {
95
+ method: "POST",
96
+ body: "exit",
97
+ });
98
+ });
99
+
100
+ it("throw an error because it was not expected to close", function* () {
101
+ yield* until(
102
+ expect(task).resolves.toHaveProperty("name", "DaemonExitError"),
103
+ );
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,82 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { describe, it } from "@effectionx/bdd";
3
+ import { spawn, withResolvers } from "effection";
4
+ import { expect } from "expect";
5
+
6
+ import { once } from "@effectionx/node/events";
7
+
8
+ describe("once", () => {
9
+ it("resolves with single argument as array", function* () {
10
+ expect.assertions(1);
11
+ const emitter = new EventEmitter();
12
+ const { resolve, operation } = withResolvers<[string]>();
13
+
14
+ yield* spawn(function* () {
15
+ resolve(yield* once<[string]>(emitter, "test"));
16
+ });
17
+
18
+ yield* spawn(function* () {
19
+ emitter.emit("test", "hello");
20
+ });
21
+
22
+ expect(yield* operation).toEqual(["hello"]);
23
+ });
24
+
25
+ it("resolves with multiple arguments as array", function* () {
26
+ expect.assertions(1);
27
+ const emitter = new EventEmitter();
28
+
29
+ let { resolve, operation } = withResolvers<[number, string]>();
30
+
31
+ yield* spawn(function* () {
32
+ resolve(yield* once<[number, string]>(emitter, "exit"));
33
+ });
34
+
35
+ yield* spawn(function* () {
36
+ emitter.emit("exit", 42, "SIGTERM");
37
+ });
38
+
39
+ expect(yield* operation).toEqual([42, "SIGTERM"]);
40
+ });
41
+
42
+ it("only resolves once even with multiple emissions", function* () {
43
+ const emitter = new EventEmitter();
44
+
45
+ const { resolve, operation } = withResolvers<void>();
46
+ let results: string[][] = [];
47
+
48
+ yield* spawn(function* () {
49
+ results.push(yield* once<[string]>(emitter, "data"));
50
+ resolve();
51
+ });
52
+
53
+ yield* spawn(function* () {
54
+ emitter.emit("data", "first");
55
+ emitter.emit("data", "second");
56
+ });
57
+
58
+ yield* operation;
59
+
60
+ expect(results).toEqual([["first"]]);
61
+ });
62
+
63
+ it("removes listener after resolving", function* () {
64
+ expect.assertions(2);
65
+ const emitter = new EventEmitter();
66
+
67
+ const { resolve, operation } = withResolvers<void>();
68
+
69
+ yield* spawn(function* () {
70
+ yield* once<[string]>(emitter, "test");
71
+ resolve();
72
+ });
73
+
74
+ yield* spawn(function* () {
75
+ expect(emitter.listenerCount("test")).toBe(1);
76
+ emitter.emit("test", "hello");
77
+ });
78
+
79
+ yield* operation;
80
+ expect(emitter.listenerCount("test")).toBe(0);
81
+ });
82
+ });