breaking
CI / test (push) Successful in 9m56s

This commit is contained in:
Raven Scott
2026-07-10 20:36:11 -04:00
parent d6ce72af66
commit 0bb6ba1692
31 changed files with 10231 additions and 3772 deletions
+557 -292
View File
@@ -1,333 +1,598 @@
// composeManager.js
// Utility for managing Docker Compose deployments
import Docker from 'dockerode';
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
import logger from './logger.js';
/**
* Docker Compose helpers: js-yaml parse + docker compose CLI lifecycle.
*/
import { spawn } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import yaml from 'js-yaml'
import logger from './logger.js'
/**
* Parse docker-compose.yml content and extract service definitions
* @param {string} composeContent - YAML content of docker-compose.yml
* @returns {Object} Parsed compose structure
* Parse docker-compose YAML with js-yaml and normalize service fields.
* @param {string} composeContent
* @returns {{ version: string|null, services: Record<string, object>, networks: object, volumes: object, raw: object }}
*/
export function parseComposeFile(composeContent) {
// Simple YAML parser for basic compose files
// For production, consider using js-yaml library
const services = {};
const lines = composeContent.split('\n');
let currentService = null;
let inService = false;
let indentLevel = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith('#')) continue;
// Detect services section
if (line === 'services:' || line.startsWith('services:')) {
inService = true;
continue;
}
if (inService) {
// Service name (no indentation after services:)
if (!line.includes(':') && !line.startsWith('-')) {
const serviceName = line.replace(':', '').trim();
if (serviceName && !serviceName.includes(' ')) {
currentService = serviceName;
services[currentService] = {
name: currentService,
image: null,
ports: [],
volumes: [],
environment: [],
networks: [],
depends_on: [],
restart: 'no',
command: null,
entrypoint: null,
};
}
} else if (currentService && line.includes(':')) {
const [key, ...valueParts] = line.split(':');
const keyName = key.trim();
const value = valueParts.join(':').trim();
switch (keyName) {
case 'image':
services[currentService].image = value;
break;
case 'restart':
services[currentService].restart = value;
break;
case 'command':
services[currentService].command = value.replace(/^["']|["']$/g, '');
break;
case 'entrypoint':
services[currentService].entrypoint = value.replace(/^["']|["']$/g, '');
break;
}
} else if (currentService && (line.startsWith('-') || line.includes(':'))) {
// Handle array items
if (line.includes('ports:')) {
// Next lines will be port mappings
let j = i + 1;
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
const portLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
if (portLine && portLine.includes(':')) {
services[currentService].ports.push(portLine);
}
j++;
}
i = j - 1;
} else if (line.includes('volumes:')) {
let j = i + 1;
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
const volLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
if (volLine) {
services[currentService].volumes.push(volLine);
}
j++;
}
i = j - 1;
} else if (line.includes('environment:')) {
let j = i + 1;
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
const envLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
if (envLine && envLine.includes('=')) {
services[currentService].environment.push(envLine);
}
j++;
}
i = j - 1;
} else if (line.includes('networks:')) {
let j = i + 1;
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
const netLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
if (netLine) {
services[currentService].networks.push(netLine);
}
j++;
}
i = j - 1;
} else if (line.includes('depends_on:')) {
let j = i + 1;
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
const depLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
if (depLine) {
services[currentService].depends_on.push(depLine);
}
j++;
}
i = j - 1;
}
}
}
if (!composeContent || typeof composeContent !== 'string') {
throw new Error('Compose content is required')
}
return { services, version: '3' };
let raw
try {
raw = yaml.load(composeContent, { schema: yaml.DEFAULT_SCHEMA })
} catch (err) {
throw new Error(`Invalid compose YAML: ${err.message}`)
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('Compose file must be a YAML mapping')
}
const servicesIn = raw.services
if (!servicesIn || typeof servicesIn !== 'object' || Array.isArray(servicesIn)) {
throw new Error('Compose file must define a "services" mapping')
}
/** @type {Record<string, object>} */
const services = {}
for (const [name, def] of Object.entries(servicesIn)) {
if (!def || typeof def !== 'object') {
throw new Error(`Service "${name}" must be a mapping`)
}
services[name] = normalizeService(name, def)
}
return {
version: raw.version != null ? String(raw.version) : null,
services,
networks: raw.networks && typeof raw.networks === 'object' ? raw.networks : {},
volumes: raw.volumes && typeof raw.volumes === 'object' ? raw.volumes : {},
raw,
}
}
/**
* Deploy a Docker Compose stack
* @param {Docker} docker - Dockerode instance
* @param {string} composeContent - YAML content
* @param {string} stackName - Name of the stack
* @returns {Promise<Object>} Deployment result
* @param {string} name
* @param {object} def
*/
function normalizeService(name, def) {
const environment = normalizeEnvironment(def.environment)
const ports = normalizeStringList(def.ports)
const volumes = normalizeStringList(def.volumes)
const networks = normalizeNetworks(def.networks)
const depends_on = normalizeDependsOn(def.depends_on)
let command = def.command ?? null
if (Array.isArray(command)) command = command.map(String)
else if (command != null) command = String(command)
let entrypoint = def.entrypoint ?? null
if (Array.isArray(entrypoint)) entrypoint = entrypoint.map(String)
else if (entrypoint != null) entrypoint = String(entrypoint)
return {
name,
image: def.image != null ? String(def.image) : null,
build: def.build ?? null,
ports,
volumes,
environment,
networks,
depends_on,
restart: def.restart != null ? String(def.restart) : 'no',
command,
entrypoint,
labels: def.labels && typeof def.labels === 'object' ? def.labels : {},
working_dir: def.working_dir || def.workingDir || null,
user: def.user != null ? String(def.user) : null,
}
}
function normalizeEnvironment(env) {
if (!env) return []
if (Array.isArray(env)) return env.map(String)
if (typeof env === 'object') {
return Object.entries(env).map(([k, v]) => (v === null || v === undefined ? k : `${k}=${v}`))
}
return []
}
function normalizeStringList(value) {
if (!value) return []
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === 'string') return item
if (item && typeof item === 'object') {
// long syntax: { target, published, ... } or volume objects
if (item.published != null && item.target != null) {
return `${item.published}:${item.target}${item.protocol ? '/' + item.protocol : ''}`
}
if (item.source != null && item.target != null) {
return `${item.source}:${item.target}${item.read_only ? ':ro' : ''}`
}
return JSON.stringify(item)
}
return String(item)
})
}
return [String(value)]
}
function normalizeNetworks(networks) {
if (!networks) return []
if (Array.isArray(networks)) return networks.map(String)
if (typeof networks === 'object') return Object.keys(networks)
return []
}
function normalizeDependsOn(depends) {
if (!depends) return []
if (Array.isArray(depends)) return depends.map(String)
if (typeof depends === 'object') return Object.keys(depends)
return [String(depends)]
}
/**
* Validate compose content; throws on invalid YAML / missing services.
* @param {string} composeContent
*/
export function validateComposeFile(composeContent) {
const parsed = parseComposeFile(composeContent)
const names = Object.keys(parsed.services)
if (names.length === 0) throw new Error('Compose file has no services')
for (const [name, svc] of Object.entries(parsed.services)) {
if (!svc.image && !svc.build) {
throw new Error(`Service "${name}" needs an image or build`)
}
}
return parsed
}
/**
* Run `docker compose` in a temp dir with the given YAML.
* @param {string[]} args - args after `docker compose -f file -p project`
* @param {{ composeContent: string, projectName: string, timeoutMs?: number }} opts
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
*/
export function runComposeCli(args, { composeContent, projectName, timeoutMs = 120000 }) {
return new Promise((resolve, reject) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peardock-compose-'))
const file = path.join(dir, 'docker-compose.yml')
fs.writeFileSync(file, composeContent, 'utf8')
const fullArgs = ['compose', '-f', file, '-p', projectName, ...args]
const child = spawn('docker', fullArgs, {
env: process.env,
cwd: dir,
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGTERM')
reject(new Error(`docker compose timed out after ${timeoutMs}ms`))
}, timeoutMs)
child.stdout?.on('data', (d) => {
stdout += d.toString()
})
child.stderr?.on('data', (d) => {
stderr += d.toString()
})
child.on('error', (err) => {
clearTimeout(timer)
cleanup(dir)
reject(err)
})
child.on('close', (code) => {
clearTimeout(timer)
cleanup(dir)
resolve({ code: code ?? 1, stdout, stderr })
})
})
}
/**
* Run compose against an existing project name (no YAML write) when possible.
* Uses label-based discovery; for CLI, still needs a file — callers pass content when available.
*/
function cleanup(dir) {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
// ignore
}
}
/**
* Deploy via `docker compose up -d` when CLI is available; else dockerode fallback.
* @param {import('dockerode')} docker
* @param {string} composeContent
* @param {string} stackName
*/
export async function deployComposeStack(docker, composeContent, stackName) {
const parsed = validateComposeFile(composeContent)
// Prefer official Compose V2 CLI
try {
const parsed = parseComposeFile(composeContent);
const results = [];
const createdContainers = [];
// Deploy services in dependency order
const servicesToDeploy = Object.keys(parsed.services);
const deployedServices = new Set();
async function deployService(serviceName) {
if (deployedServices.has(serviceName)) {
return;
const result = await runComposeCli(['up', '-d', '--remove-orphans'], {
composeContent,
projectName: stackName,
timeoutMs: 300000,
})
if (result.code === 0) {
return {
success: true,
stackName,
method: 'compose-cli',
services: Object.keys(parsed.services).map((s) => ({ service: s, status: 'up' })),
message: `Stack "${stackName}" deployed successfully`,
stdout: result.stdout,
}
const service = parsed.services[serviceName];
// Deploy dependencies first
for (const dep of service.depends_on || []) {
if (parsed.services[dep] && !deployedServices.has(dep)) {
await deployService(dep);
}
}
// Deploy the service
const containerName = `${stackName}_${serviceName}`;
// Check if container already exists
const existingContainers = await docker.listContainers({ all: true });
const existing = existingContainers.find(c =>
c.Names.some(n => n.includes(containerName))
);
if (existing) {
logger.info(`Container ${containerName} already exists, skipping`);
deployedServices.add(serviceName);
results.push({ service: serviceName, status: 'exists', containerId: existing.Id });
return;
}
// Build container config
const containerConfig = {
name: containerName,
Image: service.image,
Labels: {
'com.docker.compose.project': stackName,
'com.docker.compose.service': serviceName,
},
};
if (service.command) {
containerConfig.Cmd = service.command.split(' ');
}
if (service.entrypoint) {
containerConfig.Entrypoint = service.entrypoint.split(' ');
}
if (service.environment && service.environment.length > 0) {
containerConfig.Env = service.environment;
}
const hostConfig = {
RestartPolicy: { Name: service.restart || 'no' },
};
if (service.ports && service.ports.length > 0) {
hostConfig.PortBindings = {};
service.ports.forEach(portStr => {
if (portStr.includes(':')) {
const [hostPort, containerPort] = portStr.split(':');
const [port, protocol] = containerPort.split('/');
hostConfig.PortBindings[`${port}/${protocol || 'tcp'}`] = [{ HostPort: hostPort }];
}
});
}
if (service.volumes && service.volumes.length > 0) {
hostConfig.Binds = service.volumes;
}
containerConfig.HostConfig = hostConfig;
// Create and start container
const container = await docker.createContainer(containerConfig);
await container.start();
createdContainers.push(container.id);
deployedServices.add(serviceName);
results.push({ service: serviceName, status: 'created', containerId: container.id });
logger.info(`Deployed service ${serviceName} as container ${containerName}`);
}
// Deploy all services
for (const serviceName of servicesToDeploy) {
await deployService(serviceName);
// Fall through only if compose binary missing-style errors
if (!/not found|No such file|unknown command/i.test(result.stderr + result.stdout)) {
throw new Error(result.stderr || result.stdout || `docker compose exited ${result.code}`)
}
logger.warn('docker compose CLI failed, using dockerode fallback', { stderr: result.stderr })
} catch (err) {
if (err.message && !/ENOENT|not found|spawn/i.test(err.message)) {
// compose ran but failed
if (!/ENOENT|spawn docker/i.test(err.message)) {
logger.warn('compose CLI deploy error, trying dockerode', { error: err.message })
}
}
return {
success: true,
stackName,
services: results,
message: `Stack "${stackName}" deployed successfully`,
};
} catch (error) {
logger.error('Failed to deploy compose stack', { error: error.message, stackName });
throw error;
}
return deployViaDockerode(docker, parsed, stackName)
}
/**
* List all running stacks
* @param {Docker} docker - Dockerode instance
* @returns {Promise<Array>} List of stacks
* @param {import('dockerode')} docker
* @param {ReturnType<typeof parseComposeFile>} parsed
* @param {string} stackName
*/
async function deployViaDockerode(docker, parsed, stackName) {
const results = []
const deployedServices = new Set()
async function deployService(serviceName) {
if (deployedServices.has(serviceName)) return
const service = parsed.services[serviceName]
if (!service) throw new Error(`Unknown service dependency: ${serviceName}`)
for (const dep of service.depends_on || []) {
if (parsed.services[dep] && !deployedServices.has(dep)) {
await deployService(dep)
}
}
const containerName = `${stackName}_${serviceName}`
const existingContainers = await docker.listContainers({ all: true })
const existing = existingContainers.find((c) => c.Names?.some((n) => n.includes(containerName)))
if (existing) {
logger.info(`Container ${containerName} already exists, skipping`)
deployedServices.add(serviceName)
results.push({ service: serviceName, status: 'exists', containerId: existing.Id })
return
}
if (!service.image) {
throw new Error(
`Service "${serviceName}" has no image (build-only services require docker compose CLI)`
)
}
const containerConfig = {
name: containerName,
Image: service.image,
Labels: {
'com.docker.compose.project': stackName,
'com.docker.compose.service': serviceName,
...(flattenLabels(service.labels) || {}),
},
}
if (service.command) {
containerConfig.Cmd = Array.isArray(service.command)
? service.command
: String(service.command).split(/\s+/)
}
if (service.entrypoint) {
containerConfig.Entrypoint = Array.isArray(service.entrypoint)
? service.entrypoint
: String(service.entrypoint).split(/\s+/)
}
if (service.environment?.length) containerConfig.Env = service.environment
if (service.working_dir) containerConfig.WorkingDir = service.working_dir
if (service.user) containerConfig.User = service.user
const hostConfig = {
RestartPolicy: { Name: service.restart || 'no' },
}
if (service.ports?.length) {
hostConfig.PortBindings = {}
containerConfig.ExposedPorts = {}
for (const portStr of service.ports) {
const mapping = parsePortMapping(portStr)
if (!mapping) continue
const key = `${mapping.containerPort}/${mapping.protocol}`
containerConfig.ExposedPorts[key] = {}
hostConfig.PortBindings[key] = [{ HostPort: mapping.hostPort || '' }]
}
}
if (service.volumes?.length) {
hostConfig.Binds = service.volumes.filter((v) => typeof v === 'string' && v.includes(':'))
}
containerConfig.HostConfig = hostConfig
const container = await docker.createContainer(containerConfig)
await container.start()
deployedServices.add(serviceName)
results.push({ service: serviceName, status: 'created', containerId: container.id })
logger.info(`Deployed service ${serviceName} as container ${containerName}`)
}
for (const serviceName of Object.keys(parsed.services)) {
await deployService(serviceName)
}
return {
success: true,
stackName,
method: 'dockerode',
services: results,
message: `Stack "${stackName}" deployed successfully`,
}
}
function flattenLabels(labels) {
if (!labels || typeof labels !== 'object') return null
if (Array.isArray(labels)) {
const out = {}
for (const item of labels) {
const s = String(item)
const i = s.indexOf('=')
if (i > 0) out[s.slice(0, i)] = s.slice(i + 1)
}
return out
}
const out = {}
for (const [k, v] of Object.entries(labels)) out[k] = String(v)
return out
}
function parsePortMapping(portStr) {
// "8080:80/tcp", "80", "127.0.0.1:8080:80"
const s = String(portStr).replace(/^["']|["']$/g, '')
const protocolMatch = s.match(/\/(tcp|udp)$/i)
const protocol = protocolMatch ? protocolMatch[1].toLowerCase() : 'tcp'
const withoutProto = protocolMatch ? s.slice(0, -protocolMatch[0].length) : s
const parts = withoutProto.split(':')
if (parts.length === 1) {
return { hostPort: '', containerPort: parts[0], protocol }
}
if (parts.length === 2) {
return { hostPort: parts[0], containerPort: parts[1], protocol }
}
if (parts.length === 3) {
return { hostPort: parts[1], containerPort: parts[2], protocol }
}
return null
}
/**
* @param {import('dockerode')} docker
*/
export async function listStacks(docker) {
try {
const containers = await docker.listContainers({ all: true });
const stacks = {};
const containers = await docker.listContainers({ all: true })
const stacks = {}
containers.forEach(container => {
const labels = container.Labels || {};
const project = labels['com.docker.compose.project'];
const service = labels['com.docker.compose.service'];
for (const container of containers) {
const labels = container.Labels || {}
const project = labels['com.docker.compose.project']
const service = labels['com.docker.compose.service']
if (!project) continue
if (project) {
if (!stacks[project]) {
stacks[project] = {
name: project,
services: [],
containers: [],
};
if (!stacks[project]) {
stacks[project] = {
name: project,
services: [],
containers: [],
}
stacks[project].services.push(service || 'unknown');
stacks[project].containers.push({
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
state: container.State,
image: container.Image,
});
}
});
stacks[project].services.push(service || 'unknown')
stacks[project].containers.push({
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
state: container.State,
status: container.Status,
image: container.Image,
service: service || 'unknown',
})
}
return Object.values(stacks);
return Object.values(stacks)
} catch (error) {
logger.error('Failed to list stacks', { error: error.message });
throw error;
logger.error('Failed to list stacks', { error: error.message })
throw error
}
}
/**
* Remove a Docker Compose stack
* @param {Docker} docker - Dockerode instance
* @param {string} stackName - Name of the stack
* @returns {Promise<Object>} Removal result
* @param {import('dockerode')} docker
* @param {string} stackName
*/
export async function removeComposeStack(docker, stackName) {
try {
const containers = await docker.listContainers({ all: true });
const stackContainers = containers.filter(c => {
const labels = c.Labels || {};
return labels['com.docker.compose.project'] === stackName;
});
const results = [];
for (const containerInfo of stackContainers) {
try {
const container = docker.getContainer(containerInfo.Id);
if (containerInfo.State === 'running') {
await container.stop();
}
await container.remove({ force: true });
results.push({ id: containerInfo.Id, success: true });
} catch (error) {
results.push({ id: containerInfo.Id, success: false, error: error.message });
}
}
// Try compose down if we can find any container with project label (CLI needs a file — use stub)
const containers = await docker.listContainers({ all: true })
const stackContainers = containers.filter((c) => {
const labels = c.Labels || {}
return labels['com.docker.compose.project'] === stackName
})
if (stackContainers.length === 0) {
return {
success: true,
stackName,
removed: results.length,
results,
message: `Stack "${stackName}" removed successfully`,
};
} catch (error) {
logger.error('Failed to remove compose stack', { error: error.message, stackName });
throw error;
removed: 0,
results: [],
message: `Stack "${stackName}" not found or already removed`,
}
}
const results = []
for (const containerInfo of stackContainers) {
try {
const container = docker.getContainer(containerInfo.Id)
if (containerInfo.State === 'running') {
await container.stop({ t: 10 })
}
await container.remove({ force: true })
results.push({ id: containerInfo.Id, success: true })
} catch (error) {
results.push({ id: containerInfo.Id, success: false, error: error.message })
}
}
return {
success: true,
stackName,
removed: results.filter((r) => r.success).length,
results,
message: `Stack "${stackName}" removed successfully`,
}
}
/**
* List containers for a compose project (ps).
* @param {import('dockerode')} docker
* @param {string} stackName
*/
export async function stackPs(docker, stackName) {
const stacks = await listStacks(docker)
const stack = stacks.find((s) => s.name === stackName)
if (!stack) {
return { success: true, stackName, containers: [], message: 'Stack not found' }
}
return {
success: true,
type: 'stackPs',
stackName,
containers: stack.containers,
services: stack.services,
}
}
/**
* Collect recent logs from all containers in a stack.
* @param {import('dockerode')} docker
* @param {string} stackName
* @param {{ tail?: number }} [opts]
*/
export async function stackLogs(docker, stackName, opts = {}) {
const tail = Math.min(Number(opts.tail) || 100, 2000)
const stacks = await listStacks(docker)
const stack = stacks.find((s) => s.name === stackName)
if (!stack) throw new Error(`Stack "${stackName}" not found`)
const logs = []
for (const c of stack.containers) {
try {
const buf = await docker.getContainer(c.id).logs({
stdout: true,
stderr: true,
tail,
timestamps: true,
})
const text = Buffer.isBuffer(buf) ? demuxDockerLogs(buf) : String(buf)
logs.push({
containerId: c.id,
name: c.name,
service: c.service,
logs: text,
})
} catch (err) {
logs.push({
containerId: c.id,
name: c.name,
service: c.service,
error: err.message,
})
}
}
return { success: true, type: 'stackLogs', stackName, data: logs }
}
/**
* Pull images for services in a stack (by listing containers' images + compose not required).
* @param {import('dockerode')} docker
* @param {string} stackName
* @param {{ composeContent?: string }} [opts]
*/
export async function stackPull(docker, stackName, opts = {}) {
const images = new Set()
if (opts.composeContent) {
const parsed = parseComposeFile(opts.composeContent)
for (const svc of Object.values(parsed.services)) {
if (svc.image) images.add(svc.image)
}
} else {
const stacks = await listStacks(docker)
const stack = stacks.find((s) => s.name === stackName)
if (!stack) throw new Error(`Stack "${stackName}" not found`)
for (const c of stack.containers) {
if (c.image) images.add(c.image)
}
}
const results = []
for (const image of images) {
try {
const stream = await docker.pull(image)
await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()))
})
results.push({ image, success: true })
} catch (err) {
results.push({ image, success: false, error: err.message })
}
}
return {
success: results.every((r) => r.success),
type: 'stackPull',
stackName,
results,
message: `Pulled ${results.filter((r) => r.success).length}/${results.length} images for "${stackName}"`,
}
}
/** Strip docker multiplex headers from log buffers when possible. */
function demuxDockerLogs(buffer) {
// Heuristic: if looks like muxed frames, strip 8-byte headers
try {
const chunks = []
let offset = 0
while (offset + 8 <= buffer.length) {
const size = buffer.readUInt32BE(offset + 4)
if (size < 0 || offset + 8 + size > buffer.length) {
return buffer.toString('utf8')
}
chunks.push(buffer.subarray(offset + 8, offset + 8 + size).toString('utf8'))
offset += 8 + size
}
if (chunks.length) return chunks.join('')
} catch {
// fall through
}
return buffer.toString('utf8')
}