Skip to content

Commit

Permalink
Idiot-proof API (#105)
Browse files Browse the repository at this point in the history
  • Loading branch information
kevgo committed Aug 18, 2020
1 parent 09f2173 commit 08f66db
Show file tree
Hide file tree
Showing 11 changed files with 140 additions and 111 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,40 +36,40 @@ $ npm install observable-process
Load this library into your JavaScript code:

```js
const { createObservableProcess } = require("observable-process")
const { start } = require("observable-process")
```

– or –

```ts
import { createObservableProcess } from "observable-process"
import { start } from "observable-process"
```

## Starting processes

The best way to provide the command to run is in the form of an argv array:
The best way to provide the command to start is in the form of an argv array:

```js
const observable = createObservableProcess(["node", "server.js"])
const observable = start(["node", "server.js"])
```

You can also provide the full command line to run as a string:

```js
const observable = createObservableProcess("node server.js")
const observable = start("node server.js")
```

By default, the process runs in the current directory. To set the different
working directory for the subprocess:

```js
const observable = createObservableProcess("node server.js", { cwd: "~/tmp" })
const observable = start("node server.js", { cwd: "~/tmp" })
```

You can provide custom environment variables for the process:

```js
const observable = createObservableProcess("node server.js", {
const observable = start("node server.js", {
env: {
foo: "bar",
PATH: process.env.PATH,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
],
"homepage": "https://github.com/Originate/observable-process",
"license": "ISC",
"main": "dist/observable-process.js",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/Originate/observable-process.git"
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { ObservableProcess } from "./observable-process"
export { Result } from "./result"
export { SearchableStream } from "./searchable-stream"
export { start } from "./start"
95 changes: 32 additions & 63 deletions src/observable-process.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,18 @@
import * as childProcess from "child_process"
import mergeStream from "merge-stream"
import stringArgv from "string-argv"
import { createSearchableStream, SearchableStream } from "./searchable-stream"
import util from "util"
import { Result } from "./result"
const delay = util.promisify(setTimeout)

/** The options that can be provided to Spawn */
export interface SpawnOptions {
cwd?: string
env?: NodeJS.ProcessEnv
}

/** starts a new ObservableProcess with the given options */
export function createObservableProcess(command: string | string[], args: SpawnOptions = {}) {
// determine args
let argv: string[] = []
if (!command) {
throw new Error("createObservableProcess: no command to execute given")
}
if (typeof command === "string") {
argv = stringArgv(command)
} else if (Array.isArray(command)) {
argv = command
} else {
throw new Error("observable.spawn: you must provide the command to run as a string or string[]")
}
const [runnable, ...params] = argv

// start the process
return new ObservableProcess({
cwd: args.cwd || process.cwd(),
env: args.env || process.env,
params,
runnable,
})
}

/** a long-running process whose behavior can be observed at runtime */
export class ObservableProcess {
/** indicates whether the process has stopped running */
ended: boolean

/** the code with which the process has ended */
exitCode: number | null

/** whether the process was manually terminated by the user */
killed: boolean

/** the underlying ChildProcess instance */
process: childProcess.ChildProcess

/** populated when the process finishes */
private result: Result | undefined

/** the STDIN stream of the underlying ChildProcess */
stdin: NodeJS.WritableStream

Expand All @@ -63,42 +26,43 @@ export class ObservableProcess {
output: SearchableStream

/** functions to call when this process ends */
private endedListeners: Array<() => void>
private endedCallbacks: Array<(result: Result) => void>

constructor(args: { runnable: string; params: string[]; cwd: string; env: NodeJS.ProcessEnv }) {
this.ended = false
this.killed = false
this.endedListeners = []
this.exitCode = null
this.endedCallbacks = []
this.process = childProcess.spawn(args.runnable, args.params, {
cwd: args.cwd,
env: args.env,
})
this.process.on("close", this.onClose.bind(this))
if (this.process.stdin == null) {
// NOTE: this exists only to make the typechecker shut up
throw new Error("process.stdin should not be null")
throw new Error("process.stdin should not be null") // this exists only to make the typechecker shut up
}
this.stdin = this.process.stdin
if (this.process.stdout == null) {
// NOTE: this exists only to make the typechecker shut up
throw new Error("process.stdout should not be null")
throw new Error("process.stdout should not be null") // NOTE: this exists only to make the typechecker shut up
}
this.stdout = createSearchableStream(this.process.stdout)
if (this.process.stderr == null) {
// NOTE: this exists only to make the typechecker shut up
throw new Error("process.stderr should not be null")
throw new Error("process.stderr should not be null") // NOTE: this exists only to make the typechecker shut up
}
this.stderr = createSearchableStream(this.process.stderr)
const outputStream = mergeStream(this.process.stdout, this.process.stderr)
this.output = createSearchableStream(outputStream)
}

/** stops the currently running process */
async kill() {
this.killed = true
async kill(): Promise<Result> {
this.result = {
exitCode: -1,
killed: true,
stdOutput: this.stdout.fullText(),
errOutput: this.stderr.fullText(),
combinedOutput: this.output.fullText(),
}
this.process.kill()
await delay(0)
await delay(1)
return this.result
}

/** returns the process ID of the underlying ChildProcess */
Expand All @@ -107,21 +71,26 @@ export class ObservableProcess {
}

/** returns a promise that resolves when the underlying ChildProcess terminates */
async waitForEnd(): Promise<void> {
if (this.ended) {
return
async waitForEnd(): Promise<Result> {
if (this.result) {
return this.result
}
return new Promise((resolve) => {
this.endedListeners.push(resolve)
this.endedCallbacks.push(resolve)
})
}

/** called when the underlying ChildProcess terminates */
private onClose(exitCode: number) {
this.ended = true
this.exitCode = exitCode
for (const resolver of this.endedListeners) {
resolver()
this.result = {
exitCode,
killed: false,
stdOutput: this.stdout.fullText(),
errOutput: this.stderr.fullText(),
combinedOutput: this.output.fullText(),
}
for (const endedCallback of this.endedCallbacks) {
endedCallback(this.result)
}
}
}
17 changes: 17 additions & 0 deletions src/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** Provides the results of running the process */
export interface Result {
/** the code with which the process has ended */
exitCode: number

/** whether the process was manually terminated by the user */
killed: boolean

/** full output on the STDOUT stream */
stdOutput: string

/** full output on the STDERR stream */
errOutput: string

/** combined output from STDOUT and STDERR */
combinedOutput: string
}
33 changes: 33 additions & 0 deletions src/start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import stringArgv from "string-argv"
import { ObservableProcess } from "./observable-process"

/** The options that can be provided to Spawn */
export interface SpawnOptions {
cwd?: string
env?: NodeJS.ProcessEnv
}

/** starts a new ObservableProcess with the given options */
export function start(command: string | string[], args: SpawnOptions = {}): ObservableProcess {
// determine args
if (!command) {
throw new Error("start: no command to execute given")
}
let argv: string[] = []
if (typeof command === "string") {
argv = stringArgv(command)
} else if (Array.isArray(command)) {
argv = command
} else {
throw new Error("start: you must provide the command to run as a string or string[]")
}
const [runnable, ...params] = argv

// start the process
return new ObservableProcess({
cwd: args.cwd || process.cwd(),
env: args.env || process.env,
params,
runnable,
})
}
5 changes: 3 additions & 2 deletions test/helpers/start-node-process.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createObservableProcess, ObservableProcess } from "../../src/observable-process"
import { ObservableProcess } from "../../src/observable-process"
import { start } from "../../src/start"

export function startNodeProcess(code: string): ObservableProcess {
return createObservableProcess(["node", "-e", code])
return start(["node", "-e", code])
}
10 changes: 5 additions & 5 deletions test/input-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ import { startNodeProcess } from "./helpers/start-node-process"

test("ObservableProcess.stdin", async function () {
// start a process that reads from STDIN
const observable = startNodeProcess(
const running = startNodeProcess(
"process.stdin.on('data', data => { process.stdout.write(data.toString()) });\
process.stdin.on('end', () => { process.stdout.write('\\nEND') })"
)

// write some stuff into the STDIN stream of this process
observable.stdin.write("hello")
running.stdin.write("hello")

// close the STDIN stream
observable.stdin.end()
running.stdin.end()

// verify
await observable.waitForEnd()
assert.equal(observable.output.fullText(), "hello\nEND")
await running.waitForEnd()
assert.equal(running.output.fullText(), "hello\nEND")
})
17 changes: 6 additions & 11 deletions test/stop-test.ts → test/kill-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,6 @@ import got from "got"
import portFinder from "portfinder"
import { startNodeProcess } from "./helpers/start-node-process"

test("ObservableProcess.waitForEnd()", async function () {
const process = startNodeProcess("setTimeout(function() {}, 1)")
await process.waitForEnd()
assert.equal(process.ended, true)
assert.equal(process.killed, false)
})

test("ObservableProcess.kill()", async function () {
this.timeout(8000)

Expand All @@ -24,13 +17,15 @@ test("ObservableProcess.kill()", async function () {
await assertIsRunning(port)

// kill the process
await longRunningProcess.kill()
const result = await longRunningProcess.kill()

// verify the process is no longer running
await assertIsNotRunning(port)
assert.equal(longRunningProcess.ended, true, "process should be ended")
assert.equal(longRunningProcess.killed, true, "process should be killed")
assert.equal(longRunningProcess.exitCode, null)
assert.equal(result.killed, true, "process should be killed")
assert.equal(result.exitCode, -1)
assert.equal(result.stdOutput, "online\n")
assert.equal(result.errOutput, "")
assert.equal(result.combinedOutput, "online\n")
})

async function assertIsRunning(port: number) {
Expand Down
30 changes: 15 additions & 15 deletions test/start-test.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,42 @@
import { strict as assert } from "assert"
import { createObservableProcess } from "../src/observable-process"
import { start } from "../src/start"

suite("ObservableProcess.spawn()")

test("starting a process via an argv array", async function () {
const observable = createObservableProcess(["node", "-e", "console.log('hello')"])
await observable.waitForEnd()
assert.equal(observable.exitCode, 0)
const observable = start(["node", "-e", "console.log('hello')"])
const result = await observable.waitForEnd()
assert.equal(result.exitCode, 0)
})

test("starting a process via a string", async function () {
const observable = createObservableProcess("node -e console.log('hello')")
await observable.waitForEnd()
assert.equal(observable.exitCode, 0)
const observable = start("node -e console.log('hello')")
const result = await observable.waitForEnd()
assert.equal(result.exitCode, 0)
})

test("starting processes in the path", async function () {
const observable = createObservableProcess("node -h")
await observable.waitForEnd()
assert.equal(observable.exitCode, 0)
const observable = start("node -h")
const result = await observable.waitForEnd()
assert.equal(result.exitCode, 0)
})

test("no command to run", function () {
assert.throws(function () {
// @ts-ignore
createObservableProcess()
}, new Error("createObservableProcess: no command to execute given"))
start()
}, new Error("start: no command to execute given"))
})

test("wrong argument type", function () {
assert.throws(function () {
// @ts-ignore
createObservableProcess(1)
}, new Error("observable.spawn: you must provide the command to run as a string or string[]"))
start(1)
}, new Error("start: you must provide the command to run as a string or string[]"))
})

test("providing environment variables", async function () {
const observable = createObservableProcess(["node", "-e", "console.log('foo:', process.env.foo)"], {
const observable = start(["node", "-e", "console.log('foo:', process.env.foo)"], {
env: { foo: "bar", PATH: process.env.PATH },
})
await observable.waitForEnd()
Expand Down
Loading

0 comments on commit 08f66db

Please sign in to comment.