Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2c89ab3
Add ProcessApi middleware support with exec and daemon operations
taras Mar 27, 2026
8378a3d
Fix biome formatting in process middleware test
taras Mar 27, 2026
bc10ba3
Fix lint: remove noExplicitAny and unused imports in test
taras Mar 27, 2026
4280e4e
Update tsconfig refs and fix TypeScript error in mock test
taras Mar 27, 2026
cd90c33
Fix biome formatting in tsconfig.json
taras Mar 27, 2026
c0dec2e
Move exec call inside resource in daemon handler
taras Mar 27, 2026
3d958b0
Migrate process test from @effectionx/bdd to @effectionx/vitest
taras Apr 10, 2026
5d95acb
Revert keywords to match main
taras Apr 10, 2026
a087978
Namespace api scope with @effectionx/process
taras Apr 16, 2026
3483763
Use plain @effectionx/process scope for Stdio api
taras Apr 16, 2026
5d286cc
Rename Stdio api scope to @effectionx/stdio
taras Apr 16, 2026
d0c8146
Move Stdio api from @effectionx/process to @effectionx/node
taras Apr 16, 2026
99561a7
Add stdin to Stdio api as a readable Stream
taras Apr 16, 2026
99c212c
Destructure Stdio.operations and use destructured names
taras Apr 16, 2026
e1f5d2e
Fix stdin to return Stream directly instead of Operation<Stream>
taras Apr 16, 2026
861ff29
Fix docs to use destructured names and remove double yield on stdin
taras Apr 16, 2026
0756bfc
Revert "Fix docs to use destructured names and remove double yield on…
taras Apr 17, 2026
bfa7d9d
Revert "Fix stdin to return Stream directly instead of Operation<Stre…
taras Apr 17, 2026
e88a23b
Revert "Destructure Stdio.operations and use destructured names"
taras Apr 17, 2026
44b94e3
Revert "Add stdin to Stdio api as a readable Stream"
taras Apr 17, 2026
3ff682d
Revert "Move Stdio api from @effectionx/process to @effectionx/node"
taras Apr 17, 2026
06dfc3d
Restore process:io scope for Stdio api
taras Apr 17, 2026
8b40750
Use @effectionx/process:io scope for Stdio api
taras Apr 17, 2026
430775f
Merge branch 'main' into feat/process-api
taras Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions process/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { type Api, createApi } from "@effectionx/context-api";
import type { Operation } from "effection";
import { resource } from "effection";

import type { Daemon } from "./src/daemon.ts";
import type { ExecOptions, Process } from "./src/exec/types.ts";
import { DaemonExitError } from "./src/exec/error.ts";
import { createPosixProcess } from "./src/exec/posix.ts";
import { createWin32Process, isWin32 } from "./src/exec/win32.ts";

export interface ProcessHandler {
exec(command: string, options: ExecOptions): Operation<Process>;
daemon(command: string, options: ExecOptions): Operation<Daemon>;
}

export const ProcessApi: Api<ProcessHandler> = createApi(
"@effectionx/process",
{
*exec(command: string, options: ExecOptions): Operation<Process> {
if (isWin32()) {
return yield* createWin32Process(command, options);
}
return yield* createPosixProcess(command, options);
},

*daemon(command: string, options: ExecOptions): Operation<Daemon> {
return yield* resource(function* (provide) {
let process = yield* ProcessApi.operations.exec(command, options);

yield* provide({
*[Symbol.iterator]() {
let status = yield* process.join();
throw new DaemonExitError(status, command, options);
},
...process,
});
});
},
},
);
1 change: 1 addition & 0 deletions process/mod.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./api.ts";
export * from "./src/exec.ts";
export { type Daemon, daemon } from "./src/daemon.ts";
export * from "./src/api.ts";
2 changes: 1 addition & 1 deletion process/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import type { StdioApi } from "./exec/types.ts";
* });
* ```
*/
export const Stdio = createApi<StdioApi>("process:io", {
export const Stdio = createApi<StdioApi>("@effectionx/process:io", {
*stdout(line) {
process.stdout.write(line);
},
Expand Down
24 changes: 4 additions & 20 deletions process/src/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { type Operation, resource } from "effection";
import type { Operation } from "effection";

import {
DaemonExitError,
exec,
type ExecOptions,
type ExitStatus,
type Process,
} from "./exec.ts";
import { ProcessApi } from "../api.ts";
import type { ExecOptions, Process } from "./exec.ts";

export interface Daemon extends Operation<void>, Process {}

Expand All @@ -19,16 +14,5 @@ export function daemon(
command: string,
options: ExecOptions = {},
): Operation<Daemon> {
return resource(function* (provide) {
// TODO: should we be able to terminate the process from here?
let process = yield* exec(command, options);

yield* provide({
*[Symbol.iterator]() {
let status: ExitStatus = yield* process.join();
throw new DaemonExitError(status, command, options);
},
...process,
});
});
return ProcessApi.operations.daemon(command, options);
}
18 changes: 5 additions & 13 deletions process/src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import shellwords from "shellwords-ts";

import { type Operation, spawn } from "effection";
import type {
CreateOSProcess,
ExecOptions,
ExitStatus,
Process,
ProcessResult,
} from "./exec/types.ts";
import { createPosixProcess } from "./exec/posix.ts";
import { createWin32Process, isWin32 } from "./exec/win32.ts";

import { ProcessApi } from "../api.ts";

export * from "./exec/types.ts";
export * from "./exec/error.ts";
Expand All @@ -26,13 +25,6 @@ export interface Exec extends Operation<Process> {
expect(): Operation<ProcessResult>;
}

const createProcess: CreateOSProcess = (cmd, opts) => {
if (isWin32()) {
return createWin32Process(cmd, opts);
}
return createPosixProcess(cmd, opts);
};

/**
* Execute `command` with `options`. You should use this operation for processes
* that have a finite lifetime and on which you may wish to synchronize on the
Expand Down Expand Up @@ -60,10 +52,10 @@ export function exec(command: string, options: ExecOptions = {}): Exec {

return {
*[Symbol.iterator]() {
return yield* createProcess(cmd, opts);
return yield* ProcessApi.operations.exec(cmd, opts);
},
*join() {
const process = yield* createProcess(cmd, opts);
const process = yield* ProcessApi.operations.exec(cmd, opts);

let stdout = "";
let stderr = "";
Expand Down Expand Up @@ -91,7 +83,7 @@ export function exec(command: string, options: ExecOptions = {}): Exec {
return { ...status, stdout, stderr };
},
*expect() {
const process = yield* createProcess(cmd, opts);
const process = yield* ProcessApi.operations.exec(cmd, opts);

let stdout = "";
let stderr = "";
Expand Down
85 changes: 85 additions & 0 deletions process/test/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it } from "@effectionx/vitest";
import { scoped } from "effection";
import { expect } from "expect";

import { type Process, ProcessApi, exec } from "../mod.ts";

describe("ProcessApi middleware", () => {
it("can intercept process creation with logging", function* () {
let commands: string[] = [];

yield* ProcessApi.around({
*exec(args, next) {
let [cmd] = args;
commands.push(cmd);
return yield* next(...args);
},
});

yield* exec("node", {
arguments: ["-e", "console.log('hello')"],
}).join();

yield* exec("node", {
arguments: ["-e", "console.log('world')"],
}).join();

expect(commands).toEqual(["node", "node"]);
});

it("middleware is scoped and does not leak", function* () {
let outerCalls: string[] = [];

yield* ProcessApi.around({
*exec(args, next) {
outerCalls.push("outer");
return yield* next(...args);
},
});

yield* exec("node", {
arguments: ["-e", "console.log('before')"],
}).join();

expect(outerCalls).toEqual(["outer"]);

yield* scoped(function* () {
let innerCalls: string[] = [];

yield* ProcessApi.around({
*exec(args, next) {
innerCalls.push("inner");
return yield* next(...args);
},
});

yield* exec("node", {
arguments: ["-e", "console.log('inner')"],
}).join();

// inner scope hits both outer and inner middleware
expect(outerCalls).toEqual(["outer", "outer"]);
expect(innerCalls).toEqual(["inner"]);
});

// after child scope exits, inner middleware is gone
outerCalls.length = 0;
yield* exec("node", {
arguments: ["-e", "console.log('after')"],
}).join();

expect(outerCalls).toEqual(["outer"]);
});

it("can mock process creation", function* () {
yield* ProcessApi.around({
*exec(_args, _next) {
// Return a fake process without spawning anything
return { pid: 42 } as Process;
},
});

let process = yield* exec("anything");
expect(process.pid).toBe(42);
});
});
Loading