Skip to content
convt
Esc
↑↓navigate↵open⌘Jpreview
On this page

Node helper

A reusable convert() for Node with format detection, retries, a timeout and cancellation.

convt.mjs wraps the whole job flow in one function. The other examples import it. It runs on Node 18 or newer, Bun and Deno, with no packages.

import { readFile, writeFile } from "node:fs/promises";
import { basename, extname } from "node:path";

const API = process.env.CONVT_API ?? "https://convt-api-production.up.railway.app";
const KEY = process.env.CONVT_API_KEY;
const FINISHED = ["succeeded", "failed", "cancelled"];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export class ConvtError extends Error {
  constructor(code, message, status) {
    super(`${code}: ${message}`);
    this.code = code;
    this.status = status;
  }
}

async function request(path, { method = "GET", body, signal } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(API + path, {
      method,
      signal,
      headers: {
        Authorization: `Bearer ${KEY}`,
        ...(body ? { "Content-Type": "application/json" } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const text = await res.text();
    if (res.ok) return JSON.parse(text);
    let error;
    try {
      const { error: e } = JSON.parse(text);
      error = new ConvtError(e.code, e.message, res.status);
    } catch {
      error = new ConvtError("http_error", text, res.status);
    }
    // Creating a job is not safe to repeat, so only retry it on 429.
    const creating = method === "POST" && path === "/v1/jobs";
    const retryable = res.status === 429 || (!creating && res.status >= 500);
    if (!retryable || attempt >= 4) throw error;
    await sleep(Math.min(30_000, 1000 * 2 ** attempt) * (0.5 + Math.random() / 2));
  }
}

let formatsByExtension;
/** Maps a file name to a convt format id using the public GET /v1/formats. */
export async function formatFor(name) {
  if (!formatsByExtension) {
    const formats = await (await fetch(`${API}/v1/formats`)).json();
    formatsByExtension = new Map(formats.flatMap((f) => f.extensions.map((e) => [e, f.id])));
  }
  const id = formatsByExtension.get(extname(name).slice(1).toLowerCase());
  if (!id) throw new ConvtError("unknown_extension", `No convt format for ${name}`);
  return id;
}

/**
 * Converts bytes and returns [{ name, url }] for each output.
 * Cancels the job if anything fails, so its reservation is released.
 */
export async function convert({ bytes, from, to, timeoutMs = 11 * 60_000, pollMs = 1000 }) {
  const signal = AbortSignal.timeout(timeoutMs);
  const { job, upload_url } = await request("/v1/jobs", {
    method: "POST",
    body: { input_format: from, target_format: to, input_bytes: bytes.byteLength },
    signal,
  });
  try {
    const put = await fetch(upload_url, { method: "PUT", body: bytes, signal });
    if (!put.ok) throw new ConvtError("upload_failed", await put.text(), put.status);

    let current = await request(`/v1/jobs/${job.id}/start`, { method: "POST", signal });
    while (!FINISHED.includes(current.status)) {
      await sleep(pollMs);
      current = await request(`/v1/jobs/${job.id}`, { signal });
    }
    if (current.status !== "succeeded")
      throw new ConvtError(current.error_code ?? current.status, `job ${current.status}`);

    const { outputs } = await request(`/v1/jobs/${job.id}/download`, { signal });
    return outputs;
  } catch (error) {
    await request(`/v1/jobs/${job.id}/cancel`, { method: "POST" }).catch(() => {});
    throw error;
  }
}

/**
 * Converts a file on disk and writes each output to the current directory.
 * Outputs come back as input.webp, input-2.png and so on; they are saved
 * under the input's own name instead: photo.webp, report-2.png.
 */
export async function convertFile(path, to, options = {}) {
  const bytes = await readFile(path);
  const outputs = await convert({ ...options, bytes, from: await formatFor(path), to });
  const stem = basename(path, extname(path));
  const saved = [];
  for (const output of outputs) {
    const res = await fetch(output.url);
    if (!res.ok) throw new ConvtError("download_failed", await res.text(), res.status);
    const name = basename(output.name).replace(/^input/, stem);
    await writeFile(name, Buffer.from(await res.arrayBuffer()));
    saved.push(name);
  }
  return saved;
}

// node convt.mjs photo.png webp
if (import.meta.url === `file://${process.argv[1]}`) {
  const [path, to] = process.argv.slice(2);
  if (!path || !to) {
    console.error("usage: node convt.mjs <file> <target-format>");
    process.exit(2);
  }
  for (const name of await convertFile(path, to)) console.log(`saved ${name}`);
}

Run it from the command line:

$ export CONVT_API_KEY=cvt_live_...
$ node convt.mjs report.docx pdf
saved report.pdf

Or import it:

import { convertFile } from "./convt.mjs";

await convertFile("clip.mov", "mp4");

What it handles

  • Format detection. formatFor() maps the extension to a format id with the public GET /v1/formats.
  • Retries. 429 and 5xx responses are retried with jittered backoff. Creating a job is retried only on 429, because repeating it after an unknown failure could reserve two jobs.
  • Timeout. The whole conversion gives up after 11 minutes by default.
  • Cleanup. Any failure after the job exists cancels it, so you are not left with an open reservation.
  • Output names. The API names outputs input.<ext>. convertFile() saves them under the input’s name, so report.pdf to PNG writes report.png, report-2.png and so on.
  • Polling rate. Pass { pollMs } as the third argument to convertFile() to poll less often when several jobs run at once.