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:
5
.changeset/fn-4133-dockerode-lazy-load.md
Normal file
5
.changeset/fn-4133-dockerode-lazy-load.md
Normal 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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user