feat(FN-4133): lazy-load dockerode in DockerClientService

Lazy-loads the `dockerode` module in `DockerClientService` so the heavy dependency is only imported when Docker functionality is actually needed, improving startup time for non-Docker use cases. Tests were added to verify the lazy-loading behavior.

Fusion-Task-Id: FN-4133
This commit is contained in:
Fusion
2026-05-12 18:29:48 -07:00
committed by gsxdsm
parent 8b037e4c7e
commit 0f5a5b5d09
3 changed files with 113 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Lazy-load `dockerode` for Docker support, with actionable missing-package errors instead of import-time failures. Based on HarryCordewener's work in PR #58 and PR #59.

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor } = vi.hoisted(() => { const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor, dockerodeModuleFactoryMock } = vi.hoisted(() => {
const execMock = vi.fn(); const execMock = vi.fn();
const readFileMock = vi.fn(); const readFileMock = vi.fn();
const pingMock = vi.fn(); const pingMock = vi.fn();
@@ -9,12 +9,13 @@ const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor }
const dockerCtor = vi.fn().mockImplementation(() => ({ const dockerCtor = vi.fn().mockImplementation(() => ({
ping: pingMock, ping: pingMock,
version: versionMock, version: versionMock,
getContainer: vi.fn(() => ({ inspect: inspectMock })), getContainer: vi.fn(() => ({ inspect: inspectMock, logs: vi.fn().mockResolvedValue(Buffer.from("logs")) })),
})); }));
return { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor }; const dockerodeModuleFactoryMock = vi.fn(() => ({ default: dockerCtor }));
return { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor, dockerodeModuleFactoryMock };
}); });
vi.mock("dockerode", () => ({ default: dockerCtor })); vi.mock("dockerode", dockerodeModuleFactoryMock);
vi.mock("node:child_process", () => ({ exec: execMock })); vi.mock("node:child_process", () => ({ exec: execMock }));
vi.mock("node:fs/promises", () => ({ readFile: readFileMock })); vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
@@ -23,6 +24,8 @@ import { DockerClientService } from "../docker-client";
describe("DockerClientService", () => { describe("DockerClientService", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.resetModules();
vi.doMock("dockerode", dockerodeModuleFactoryMock);
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: "", stderr: "" })); execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: "", stderr: "" }));
pingMock.mockResolvedValue(undefined); pingMock.mockResolvedValue(undefined);
versionMock.mockResolvedValue({ Version: "24.0.0", ApiVersion: "1.43", Os: "linux" }); versionMock.mockResolvedValue({ Version: "24.0.0", ApiVersion: "1.43", Os: "linux" });
@@ -131,4 +134,59 @@ describe("DockerClientService", () => {
const source = await import("node:fs/promises").then((m) => m.readFile(new URL("../docker-client.ts", import.meta.url), "utf8")); const source = await import("node:fs/promises").then((m) => m.readFile(new URL("../docker-client.ts", import.meta.url), "utf8"));
expect(source.includes("execSync")).toBe(false); expect(source.includes("execSync")).toBe(false);
}); });
describe("lazy loading", () => {
afterEach(() => {
vi.resetModules();
vi.doMock("dockerode", dockerodeModuleFactoryMock);
});
it("returns actionable error when dockerode is missing", async () => {
const missingModuleError = Object.assign(new Error("Cannot find package 'dockerode'"), { code: "ERR_MODULE_NOT_FOUND" });
vi.resetModules();
vi.doMock("dockerode", () => ({
get default() {
throw missingModuleError;
},
}));
const { DockerClientService: FreshDockerClientService } = await import("../docker-client");
const service = new FreshDockerClientService();
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain("Install it with: npm install dockerode");
});
it("preserves diagnostics for generic load failures", async () => {
vi.resetModules();
vi.doMock("dockerode", () => ({
get default() {
throw new Error("boom — internal init failure");
},
}));
const { DockerClientService: FreshDockerClientService } = await import("../docker-client");
const service = new FreshDockerClientService();
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to load 'dockerode': boom — internal init failure");
});
it("loads dockerode only once across multiple calls", async () => {
vi.resetModules();
vi.doMock("dockerode", dockerodeModuleFactoryMock);
const { DockerClientService: FreshDockerClientService } = await import("../docker-client");
const service = new FreshDockerClientService();
await service.testConnection();
await service.testConnection({ host: "tcp://1.2.3.4:2376" });
await service.getContainerInfo("abc");
expect(dockerodeModuleFactoryMock).toHaveBeenCalledTimes(1);
});
});
}); });

View File

@@ -1,5 +1,5 @@
import Docker from "dockerode";
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import type Docker from "dockerode";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { import type {
@@ -15,6 +15,11 @@ const EXEC_OPTIONS = {
maxBuffer: 5 * 1024 * 1024, maxBuffer: 5 * 1024 * 1024,
} as const; } as const;
type DockerodeModule = typeof import("dockerode");
type DockerConstructor = DockerodeModule extends { default: infer T } ? T : DockerodeModule;
let dockerodeConstructorPromise: Promise<DockerConstructor> | null = null;
function isLocalDaemonHost(host?: string): boolean { function isLocalDaemonHost(host?: string): boolean {
return !host || host.trim() === "" || host === "unix:///var/run/docker.sock"; return !host || host.trim() === "" || host === "unix:///var/run/docker.sock";
} }
@@ -24,6 +29,42 @@ function toErrorMessage(error: unknown): string {
return String(error); return String(error);
} }
function isDockerodeMissingModuleError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const code = "code" in error ? (error as Error & { code?: string }).code : undefined;
return (
code === "ERR_MODULE_NOT_FOUND" ||
code === "MODULE_NOT_FOUND" ||
/cannot find (package|module) ['"]dockerode['"]/i.test(error.message)
);
}
async function loadDockerode(): Promise<DockerConstructor> {
if (!dockerodeConstructorPromise) {
dockerodeConstructorPromise = import("dockerode")
.then((mod) => ("default" in mod && mod.default ? mod.default : mod) as DockerConstructor)
.catch((error: unknown) => {
// Clear the cache on any failure so a later retry can succeed after environment changes.
dockerodeConstructorPromise = null;
if (isDockerodeMissingModuleError(error)) {
throw new Error("Docker support requires the optional 'dockerode' package. Install it with: npm install dockerode", {
cause: error,
});
}
throw new Error(`Failed to load 'dockerode': ${toErrorMessage(error)}`, {
cause: error,
});
});
}
return dockerodeConstructorPromise;
}
interface DockerContextCliEntry { interface DockerContextCliEntry {
Name?: string; Name?: string;
Description?: string; Description?: string;
@@ -53,6 +94,7 @@ export class DockerClientService {
const parsed = JSON.parse(stdout) as Array<{ Endpoints?: { docker?: { Host?: string } } }>; const parsed = JSON.parse(stdout) as Array<{ Endpoints?: { docker?: { Host?: string } } }>;
const dockerHost = parsed[0]?.Endpoints?.docker?.Host; const dockerHost = parsed[0]?.Endpoints?.docker?.Host;
if (!dockerHost) throw new Error(`Docker context "${contextName}" does not define a Docker endpoint host`); if (!dockerHost) throw new Error(`Docker context "${contextName}" does not define a Docker endpoint host`);
const Docker = await loadDockerode();
return new Docker({ host: dockerHost }); return new Docker({ host: dockerHost });
} }
@@ -72,9 +114,11 @@ export class DockerClientService {
if (hostConfig.tlsKeyPath) options.key = await readFile(hostConfig.tlsKeyPath); if (hostConfig.tlsKeyPath) options.key = await readFile(hostConfig.tlsKeyPath);
if (hostConfig.tlsVerify === false) options.rejectUnauthorized = false; if (hostConfig.tlsVerify === false) options.rejectUnauthorized = false;
const Docker = await loadDockerode();
return new Docker(options); return new Docker(options);
} }
const Docker = await loadDockerode();
return new Docker(); return new Docker();
} }