Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ compose.upAll({ cwd: path.join(__dirname), log: true }).then(
)
```

For very chatty commands, use `callback` to process output chunks as they arrive and `maxOutputLength` to limit the UTF-16 code units retained per stream (the same units as JavaScript's `string.length`). It defaults to Node.js's `buffer.constants.MAX_STRING_LENGTH`. Stdout keeps the beginning of its output; stderr keeps the end so recent errors remain available. The result's `truncated.out` and `truncated.err` flags indicate whether output was dropped. Commands that parse stdout reject with an error mentioning `maxOutputLength` if stdout was truncated.

Set `maxOutputLength: 0` to disable buffering completely while still receiving every chunk through `callback`. Both `result.out` and `result.err` will be empty strings:

```javascript
await compose.logs('web', {
cwd: path.join(__dirname),
follow: true,
maxOutputLength: 0,
callback: (chunk, streamSource) => {
const stream = streamSource === 'stderr' ? process.stderr : process.stdout
stream.write(chunk)
}
})
```

Start specific services using `compose.upMany`:

```javascript
Expand Down
24 changes: 24 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ All commands return a `Promise({object})` with stdout and stderr strings and an
out: 'stdout contents',
err: 'stderr contents',
exitCode: 0, // !== 0 in case of an error
truncated: { out: false, err: false },
}
```

`truncated.out` and `truncated.err` indicate whether any output was dropped from the corresponding stream, including when buffering is disabled. These flags are also present on results rejected for a nonzero exit code and on typed results with a `data` field. Commands that parse stdout (`config`, `configServices`, `configVolumes`, `ps`, `images`, `port`, `version`, and `stats`) reject with an error mentioning `maxOutputLength` if stdout was truncated. Truncating only stderr does not prevent parsing stdout.

## Progress Callback

Although the return type is a `Promise`, it is still possible to get the process progress before the `Promise` resolves, by passing a callback function to the optional `callback` parameter.
Expand All @@ -60,6 +63,26 @@ compose.upAll({
)
```

### Output buffering

Use `maxOutputLength` to limit how much output is retained independently for stdout and stderr. The limit counts UTF-16 code units, as measured by JavaScript's `string.length`, and defaults to Node.js's `buffer.constants.MAX_STRING_LENGTH`. Stdout retains the beginning of the output; stderr retains a trailing window so the most recent error messages remain available. Reaching the limit does not stop the command, and `callback` and `log` still receive all output.

Set `maxOutputLength: 0` to disable buffering completely and process output only as it arrives. `out` and `err` remain empty strings, and each truncation flag becomes `true` if its stream emits nonempty output:

```typescript
await compose.logs('web', {
cwd: path.join(__dirname),
follow: true,
maxOutputLength: 0,
callback: (chunk, streamSource) => {
const stream = streamSource === 'stderr' ? process.stderr : process.stdout
stream.write(chunk)
}
})
```

Finite limits are rounded down and clamped between `0` and `buffer.constants.MAX_STRING_LENGTH`. Non-finite values use the default limit.

## Options

`docker-compose` accepts these params:
Expand All @@ -74,6 +97,7 @@ compose.upAll({
| `log` | `boolean` | Enable console logging |
| `composeOptions` | `string[] \| Array<string \| string[]>` | Options for all commands (e.g., `--verbose`) |
| `commandOptions` | `string[] \| Array<string \| string[]>` | Options for specific command |
| `maxOutputLength` | `number` | Maximum UTF-16 code units retained per stream; defaults to `buffer.constants.MAX_STRING_LENGTH`. `0` disables buffering. |
| `callback` | `(chunk: Buffer, sourceStream?: 'stdout' \| 'stderr') => void` | Progress callback |

### Example with options
Expand Down
124 changes: 112 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import childProcess from 'child_process'
import { constants as bufferConstants } from 'buffer'
import yaml from 'yaml'
import mapPorts from './map-ports'
export type { ComposeSpecification } from './compose-spec'
Expand Down Expand Up @@ -26,6 +27,8 @@
composeOptions?: string[] | (string | string[])[]
commandOptions?: string[] | (string | string[])[]
env?: NodeJS.ProcessEnv
/** Maximum UTF-16 code units retained per stream; 0 disables buffering. */
maxOutputLength?: number
callback?: (chunk: Buffer, streamSource?: 'stdout' | 'stderr') => void
}

Expand Down Expand Up @@ -83,17 +86,93 @@
exitCode: number | null
out: string
err: string
truncated: {
out: boolean
err: boolean
}
}

export type TypedDockerComposeResult<T> = {
exitCode: number | null
out: string
err: string
export type TypedDockerComposeResult<T> = IDockerComposeResult & {
data: T
}

const nonEmptyString = (v: string) => v !== ''

const MAX_CAPTURED_OUTPUT_LENGTH = bufferConstants.MAX_STRING_LENGTH

interface BufferedOutput {
chunks: string[]
head: number
length: number
truncated: boolean
}

const createBufferedOutput = (): BufferedOutput => ({
chunks: [],
head: 0,
length: 0,
truncated: false
})

const appendBufferedOutput = (
output: BufferedOutput,
stream: 'out' | 'err',
chunk: Buffer,
maxLength: number
): void => {
if (chunk.length === 0) {
return
}

if (maxLength === 0 || (stream === 'out' && output.length >= maxLength)) {
output.truncated = true
return
}

const chunkString = chunk.toString()
const remainingLength = maxLength - output.length
if (chunkString.length > remainingLength) {
output.truncated = true
}

const retainedChunk =
stream === 'err'
? chunkString.slice(-maxLength)
: chunkString.slice(0, remainingLength)
output.chunks.push(retainedChunk)
output.length += retainedChunk.length

// Evict individual chunks without copying the retained window on each write.
while (output.length > maxLength) {
const first = output.chunks[output.head]
const excessLength = output.length - maxLength
if (first.length <= excessLength) {
output.length -= first.length
output.chunks[output.head++] = ''
} else {
output.chunks[output.head] = first.slice(excessLength)
output.length -= excessLength
}
}

// Release discarded entries without shifting the array on every eviction.
if (output.head > 0 && output.head * 2 >= output.chunks.length) {
output.chunks = output.chunks.slice(output.head)
output.head = 0
}
}

const assertCompleteOutput = (
result: IDockerComposeResult,
command: string
): void => {
if (result.truncated.out) {
throw new Error(
`Cannot parse docker compose ${command} output because stdout was truncated by maxOutputLength. Increase maxOutputLength or omit it.`
)
}
}

export type DockerComposePsResultService = {
name: string
command: string
Expand Down Expand Up @@ -283,8 +362,8 @@
* Executes docker compose command with common options
*/
export const execCompose = (
command,

Check warning on line 365 in src/index.ts

View workflow job for this annotation

GitHub Actions / Lint

Argument 'command' should be typed
args,

Check warning on line 366 in src/index.ts

View workflow job for this annotation

GitHub Actions / Lint

Argument 'args' should be typed
options: IDockerComposeOptions = {}
): Promise<IDockerComposeResult> =>
new Promise((resolve, reject): void => {
Expand All @@ -310,6 +389,17 @@
const cwd = options.cwd
const env = options.env || undefined
const executable = options.executable
const requestedMaxOutputLength =
options.maxOutputLength ?? MAX_CAPTURED_OUTPUT_LENGTH
const maxOutputLength = Number.isFinite(requestedMaxOutputLength)
? Math.max(
0,
Math.min(
Math.floor(requestedMaxOutputLength),
MAX_CAPTURED_OUTPUT_LENGTH
)
)
: MAX_CAPTURED_OUTPUT_LENGTH

let executablePath: string
let executableArgs: string[] = []
Expand All @@ -335,25 +425,27 @@
reject(err)
})

const result: IDockerComposeResult = {
exitCode: null,
err: '',
out: ''
}
const stdout = createBufferedOutput()
const stderr = createBufferedOutput()

childProc.stdout.on('data', (chunk): void => {
result.out += chunk.toString()
appendBufferedOutput(stdout, 'out', chunk, maxOutputLength)
options.callback?.(chunk, 'stdout')
})

childProc.stderr.on('data', (chunk): void => {
result.err += chunk.toString()
appendBufferedOutput(stderr, 'err', chunk, maxOutputLength)
options.callback?.(chunk, 'stderr')
})

childProc.on('exit', (exitCode): void => {
result.exitCode = exitCode
setTimeout(() => {
const result: IDockerComposeResult = {
exitCode,
out: stdout.chunks.join(''),
err: stderr.chunks.join(''),
truncated: { out: stdout.truncated, err: stderr.truncated }
}
if (exitCode === 0) {
resolve(result)
} else {
Expand Down Expand Up @@ -583,6 +675,7 @@
): Promise<TypedDockerComposeResult<DockerComposeConfigResult>> {
try {
const result = await execCompose('config', [], options)
assertCompleteOutput(result, 'config')
const config = yaml.parse(result.out)
return {
...result,
Expand All @@ -598,6 +691,7 @@
): Promise<TypedDockerComposeResult<DockerComposeConfigServicesResult>> {
try {
const result = await execCompose('config', ['--services'], options)
assertCompleteOutput(result, 'config --services')
const services = result.out.split('\n').filter(nonEmptyString)
return {
...result,
Expand All @@ -613,6 +707,7 @@
): Promise<TypedDockerComposeResult<DockerComposeConfigVolumesResult>> {
try {
const result = await execCompose('config', ['--volumes'], options)
assertCompleteOutput(result, 'config --volumes')
const volumes = result.out.split('\n').filter(nonEmptyString)
return {
...result,
Expand All @@ -628,6 +723,7 @@
): Promise<TypedDockerComposeResult<DockerComposePsResult>> {
try {
const result = await execCompose('ps', [], options)
assertCompleteOutput(result, 'ps')
const data = mapPsOutput(result.out, options)
return {
...result,
Expand All @@ -648,6 +744,7 @@
commandOptions: [...(options?.commandOptions || []), ['--format', 'json']]
}
const result = await execCompose('images', [], jsonOptions)
assertCompleteOutput(result, 'images')
const data = mapImListOutput(result.out, jsonOptions)
return {
...result,
Expand Down Expand Up @@ -713,6 +810,7 @@

try {
const result = await execCompose('port', args, options)
assertCompleteOutput(result, 'port')
const [address, port] = result.out.split(':')
return {
...result,
Expand All @@ -731,6 +829,7 @@
): Promise<TypedDockerComposeResult<DockerComposeVersionResult>> {
try {
const result = await execCompose('version', ['--short'], options)
assertCompleteOutput(result, 'version')
const version = result.out.replace('\n', '').trim()
return {
...result,
Expand All @@ -749,6 +848,7 @@

try {
const result = await execCompose('stats', args, options)
assertCompleteOutput(result, 'stats')
// Remove first and last quote from output, as well as newline.
const output = result.out.replace('\n', '').trim().slice(1, -1)
return JSON.parse(output)
Expand Down
Loading