Initial Commit
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
# `binary`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns ffmpeg-binary discovery for `@suaveplan/audio-codec-ffmpeg-native`.
|
||||
It implements a fixed four-step precedence chain — caller-supplied provider,
|
||||
explicit absolute path, dynamically imported `@ffmpeg-installer/ffmpeg`
|
||||
optional dependency, then PATH-based discovery — and memoises successful
|
||||
resolutions by options-object identity so that high-traffic decode/encode
|
||||
pipelines pay the resolution cost exactly once per shared options bundle.
|
||||
|
||||
The resolver throws `AudioCodecFfmpegNativeError("AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING")`
|
||||
when every step fails, which lets the codec surface an actionable error to the
|
||||
caller instead of waiting for a downstream `ENOENT` from `child_process.spawn`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict precedence: provider → explicit path → installer → PATH lookup.
|
||||
- Promise-returning provider supported (sync providers also accepted).
|
||||
- Dynamic `import("@ffmpeg-installer/ffmpeg")` so the optional dependency is
|
||||
never a hard requirement at module load.
|
||||
- Test-injectable `installerLoader` and `pathLookup` hooks decouple resolution
|
||||
from real filesystem state.
|
||||
- `BINARY_MISSING` error code on full-chain failure with no leaked stack from
|
||||
the underlying optional-dep failure.
|
||||
- WeakMap-keyed memoisation that automatically GCs entries when the caller
|
||||
drops their options reference.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveBinary } from "@suaveplan/audio-codec-ffmpeg-native/binary/binary";
|
||||
|
||||
const { path, source } = await resolveBinary();
|
||||
console.log(`Using ffmpeg at ${path} via ${source}`);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { resolveBinary } from "@suaveplan/audio-codec-ffmpeg-native/binary/binary";
|
||||
|
||||
const opts = {
|
||||
binaryProvider: async () => process.env.FFMPEG_PATH ?? "",
|
||||
binaryPath: "/usr/local/bin/ffmpeg",
|
||||
installerLoader: async () => ({ path: "/opt/ffmpeg" }),
|
||||
pathLookup: () => "/usr/bin/ffmpeg",
|
||||
} as const;
|
||||
|
||||
const first = await resolveBinary(opts);
|
||||
const second = await resolveBinary(opts); // memoised — no re-resolution.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `resolveBinary(opts?): Promise<ResolvedBinary>` — returns `{ path, source }`.
|
||||
- `BinaryResolverOptions` — `binaryProvider`, `binaryPath`, `installerLoader`,
|
||||
`pathLookup` (all optional; first match wins per the precedence chain).
|
||||
- `ResolvedBinary.source` — `"provider" | "explicit" | "installer" | "path"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The four-step chain is hard-coded — there is no plugin slot — because deviation
|
||||
in precedence would silently change which binary actually runs on production
|
||||
servers. Empty strings are treated as "miss" so callers can write `() => ""`
|
||||
in a provider to opt into the next strategy without throwing.
|
||||
|
||||
The installer is imported via dynamic `import()` because static imports would
|
||||
make `@ffmpeg-installer/ffmpeg` a hard dependency rather than the optional one
|
||||
the spec requires. Any failure inside the installer load (missing module,
|
||||
platform mismatch, exec-bit stripped) is swallowed and the chain advances —
|
||||
the only surfaced error is `BINARY_MISSING` after the entire chain misses.
|
||||
|
||||
Memoisation uses a `WeakMap` keyed by the options object reference; callers
|
||||
who want forced re-resolution pass a fresh options bundle.
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AudioCodecFfmpegNativeError } from "../error-codes/error-codes.js";
|
||||
import { extractInstallerPath, resolveBinary } from "./binary.js";
|
||||
|
||||
describe("binary/resolveBinary", () => {
|
||||
it("uses binaryProvider when it returns non-empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const res = await resolveBinary({
|
||||
binaryProvider: () => "/from/provider",
|
||||
installerLoader: async () => ({ path: "/from/installer" }),
|
||||
pathLookup: () => "/from/path",
|
||||
});
|
||||
expect(res).toEqual({ path: "/from/provider", source: "provider" });
|
||||
});
|
||||
|
||||
it("supports async binaryProvider", { timeout: 5000 }, async () => {
|
||||
const res = await resolveBinary({
|
||||
binaryProvider: async () => "/async/provider",
|
||||
installerLoader: async () => null,
|
||||
});
|
||||
expect(res).toEqual({ path: "/async/provider", source: "provider" });
|
||||
});
|
||||
|
||||
it("falls through provider empty string to explicit binaryPath", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const res = await resolveBinary({
|
||||
binaryProvider: () => "",
|
||||
binaryPath: "/explicit",
|
||||
installerLoader: async () => null,
|
||||
});
|
||||
expect(res).toEqual({ path: "/explicit", source: "explicit" });
|
||||
});
|
||||
|
||||
it("uses installer when provider+explicit absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const res = await resolveBinary({
|
||||
installerLoader: async () => ({ path: "/installer" }),
|
||||
});
|
||||
expect(res).toEqual({ path: "/installer", source: "installer" });
|
||||
});
|
||||
|
||||
it("falls through installer throw to pathLookup", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const res = await resolveBinary({
|
||||
installerLoader: async () => {
|
||||
throw new Error("missing");
|
||||
},
|
||||
pathLookup: () => "/from/path",
|
||||
});
|
||||
expect(res).toEqual({ path: "/from/path", source: "path" });
|
||||
});
|
||||
|
||||
it("falls through installer null to pathLookup", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const res = await resolveBinary({
|
||||
installerLoader: async () => null,
|
||||
pathLookup: () => "/from/path",
|
||||
});
|
||||
expect(res).toEqual({ path: "/from/path", source: "path" });
|
||||
});
|
||||
|
||||
it("falls through installer empty path to pathLookup", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const res = await resolveBinary({
|
||||
installerLoader: async () => ({ path: "" }),
|
||||
pathLookup: () => "/from/path",
|
||||
});
|
||||
expect(res).toEqual({ path: "/from/path", source: "path" });
|
||||
});
|
||||
|
||||
it("falls through empty pathLookup to BINARY_MISSING", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
resolveBinary({
|
||||
installerLoader: async () => null,
|
||||
pathLookup: () => undefined,
|
||||
})
|
||||
).rejects.toBeInstanceOf(AudioCodecFfmpegNativeError);
|
||||
});
|
||||
|
||||
it("throws BINARY_MISSING when every step misses", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
resolveBinary({
|
||||
installerLoader: async () => null,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "AUDIO_CODEC_FFMPEG_NATIVE.BINARY_MISSING",
|
||||
});
|
||||
});
|
||||
|
||||
it("memoises by options reference", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
const opts = {
|
||||
binaryProvider: () => {
|
||||
calls += 1;
|
||||
return "/cached";
|
||||
},
|
||||
};
|
||||
await resolveBinary(opts);
|
||||
await resolveBinary(opts);
|
||||
await resolveBinary(opts);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("default installer loader resolves real @ffmpeg-installer/ffmpeg", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Real @ffmpeg-installer/ffmpeg IS installed via catalog:audio in
|
||||
// this monorepo, so the default loader returns its bundled path.
|
||||
const res = await resolveBinary({});
|
||||
expect(res.source === "installer" || res.source === "path").toBe(true);
|
||||
expect(res.path.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolveBinary supports no-argument invocation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Covers the `opts: BinaryResolverOptions = {}` default-argument
|
||||
// branch — without it we fall back to the default installer.
|
||||
const res = await resolveBinary();
|
||||
expect(res.path.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary/extractInstallerPath", () => {
|
||||
it("returns { path } from default-exported shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(extractInstallerPath({ default: { path: "/d" } })).toEqual({
|
||||
path: "/d",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to named `path` when default has no `path`", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(extractInstallerPath({ path: "/named" })).toEqual({
|
||||
path: "/named",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when neither default nor named path is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(extractInstallerPath({})).toBeNull();
|
||||
expect(extractInstallerPath({ default: {} })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
AUDIO_CODEC_FFMPEG_NATIVE_CODES,
|
||||
AudioCodecFfmpegNativeError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { BinaryResolverOptions, ResolvedBinary } from "./binary.types.js";
|
||||
|
||||
const cache = new WeakMap<BinaryResolverOptions, ResolvedBinary>();
|
||||
|
||||
/**
|
||||
* Resolve the ffmpeg binary path using the 4-step precedence order documented
|
||||
* on `BinaryResolverOptions`. Throws `BINARY_MISSING` if every strategy fails.
|
||||
*
|
||||
* The result is memoised per `BinaryResolverOptions` reference — pass the same
|
||||
* options object to benefit from caching across many decode/encode calls.
|
||||
*/
|
||||
export async function resolveBinary(
|
||||
opts: BinaryResolverOptions = {}
|
||||
): Promise<ResolvedBinary> {
|
||||
const hit = cache.get(opts);
|
||||
if (hit !== undefined) return hit;
|
||||
|
||||
const resolved = await tryResolve(opts);
|
||||
if (resolved === null) {
|
||||
throw new AudioCodecFfmpegNativeError(
|
||||
AUDIO_CODEC_FFMPEG_NATIVE_CODES.BINARY_MISSING,
|
||||
"ffmpeg-native.binary.missing",
|
||||
{}
|
||||
);
|
||||
}
|
||||
cache.set(opts, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function tryResolve(
|
||||
opts: BinaryResolverOptions
|
||||
): Promise<ResolvedBinary | null> {
|
||||
if (opts.binaryProvider !== undefined) {
|
||||
const path = await Promise.resolve(opts.binaryProvider());
|
||||
if (isNonEmpty(path)) return { path, source: "provider" };
|
||||
}
|
||||
if (isNonEmpty(opts.binaryPath)) {
|
||||
return { path: opts.binaryPath, source: "explicit" };
|
||||
}
|
||||
const installerPath = await tryInstaller(opts.installerLoader);
|
||||
if (installerPath !== null) {
|
||||
return { path: installerPath, source: "installer" };
|
||||
}
|
||||
if (opts.pathLookup !== undefined) {
|
||||
const path = opts.pathLookup();
|
||||
if (isNonEmpty(path)) return { path, source: "path" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNonEmpty(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
async function tryInstaller(
|
||||
loader: BinaryResolverOptions["installerLoader"]
|
||||
): Promise<string | null> {
|
||||
const load = loader ?? defaultInstallerLoader;
|
||||
try {
|
||||
const mod = await load();
|
||||
if (mod !== null && isNonEmpty(mod.path)) return mod.path;
|
||||
} catch {
|
||||
// Optional dependency not installed; fall through.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function defaultInstallerLoader(): Promise<{
|
||||
readonly path: string;
|
||||
} | null> {
|
||||
const mod = (await import("@ffmpeg-installer/ffmpeg")) as {
|
||||
readonly default?: { readonly path?: string };
|
||||
readonly path?: string;
|
||||
};
|
||||
return extractInstallerPath(mod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable `path` from a `@ffmpeg-installer/ffmpeg` module export
|
||||
* shape. Handles both default-exported and named-exported `path` strings and
|
||||
* returns `null` when neither is present. Exposed for unit-test coverage of
|
||||
* the empty-export branch which production traffic cannot reach (the installed
|
||||
* module always exports a path on supported platforms).
|
||||
*/
|
||||
export function extractInstallerPath(mod: {
|
||||
readonly default?: { readonly path?: string };
|
||||
readonly path?: string;
|
||||
}): { readonly path: string } | null {
|
||||
const path = mod.default?.path ?? mod.path;
|
||||
return path !== undefined ? { path } : null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Options accepted by `resolveBinary`. Resolution order is:
|
||||
*
|
||||
* 1. `binaryProvider()` — caller-supplied factory (highest precedence).
|
||||
* 2. `binaryPath` — explicit absolute path.
|
||||
* 3. Dynamic `@ffmpeg-installer/ffmpeg` import.
|
||||
* 4. PATH-based discovery via `node:child_process` `which`/`where`.
|
||||
*
|
||||
* The first non-empty result that is also `executable` wins.
|
||||
*/
|
||||
export interface BinaryResolverOptions {
|
||||
readonly binaryPath?: string;
|
||||
readonly binaryProvider?: () => string | Promise<string>;
|
||||
/**
|
||||
* Injected to make the PATH fallback testable. Defaults to spawning
|
||||
* `which ffmpeg` (POSIX) — left undefined disables PATH discovery, which
|
||||
* is the safest server-tier default.
|
||||
*/
|
||||
readonly pathLookup?: () => string | undefined;
|
||||
/**
|
||||
* Injected installer loader. Defaults to dynamic import of the optional
|
||||
* `@ffmpeg-installer/ffmpeg` dependency.
|
||||
*/
|
||||
readonly installerLoader?: () => Promise<{ readonly path: string } | null>;
|
||||
}
|
||||
|
||||
export interface ResolvedBinary {
|
||||
readonly path: string;
|
||||
readonly source: "provider" | "explicit" | "installer" | "path";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { resolveBinary } from "./binary.js";
|
||||
export type { BinaryResolverOptions, ResolvedBinary } from "./binary.types.js";
|
||||
Reference in New Issue
Block a user