Skip to content

Commit 26ffa05

Browse files
feat(vite-plugin): Add Containers support in vite preview (#10040)
* feat(vite-plugin): Add Containers support in `vite preview` * feedback fixes * rebase fixes
1 parent 845459e commit 26ffa05

File tree

5 files changed

+215
-84
lines changed

5 files changed

+215
-84
lines changed

.changeset/short-planes-beam.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/vite-plugin": patch
3+
---
4+
5+
feat(vite-plugin): Add containers support in `vite preview`
6+
7+
Adds support for Cloudflare Containers in `vite preview`. Please note that at the time of this PR a container image can only specify the path to a `Dockerfile`. Support for registry links will be added in a later version.
Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
1-
import { expect, test } from "vitest";
2-
import { getTextResponse, isBuild, isCINonLinux } from "../../__test-utils__";
1+
import { expect, test, vi } from "vitest";
2+
import {
3+
getTextResponse,
4+
isCINonLinux,
5+
viteTestUrl,
6+
} from "../../__test-utils__";
37

4-
// skip build test until containers support is implemented in `vite preview`
58
// We can only really run these tests on Linux, because we build our images for linux/amd64,
69
// and github runners don't really support container virtualization in any sane way
7-
test.skipIf(isBuild || isCINonLinux)("starts container", async () => {
10+
test.skipIf(isCINonLinux)("starts container", async () => {
811
const startResponse = await getTextResponse("/start");
912
expect(startResponse).toBe("Container create request sent...");
1013

1114
const statusResponse = await getTextResponse("/status");
1215
expect(statusResponse).toBe("true");
16+
17+
await vi.waitFor(
18+
async () => {
19+
const fetchResponse = await fetch(`${viteTestUrl}/fetch`, {
20+
signal: AbortSignal.timeout(500),
21+
});
22+
expect(await fetchResponse.text()).toBe("Hello World!");
23+
},
24+
{ timeout: 2000, interval: 500 }
25+
);
1326
});

packages/vite-plugin-cloudflare/src/containers.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { runDockerCmd } from "@cloudflare/containers-shared/src/utils";
1+
import assert from "node:assert";
2+
import path from "node:path";
3+
import { prepareContainerImagesForDev } from "@cloudflare/containers-shared/src/images";
4+
import { getDevContainerImageName } from "@cloudflare/containers-shared/src/knobs";
5+
import {
6+
isDockerfile,
7+
runDockerCmd,
8+
} from "@cloudflare/containers-shared/src/utils";
9+
import type { WorkerConfig } from "./plugin-config";
210

311
/**
412
* Returns the path to the Docker executable as defined by the
@@ -45,3 +53,108 @@ export async function removeContainersByIds(
4553
return;
4654
}
4755
}
56+
57+
/**
58+
* @returns Container options suitable for building or pulling images,
59+
* with image tag set to well-known dev format, or undefined if
60+
* containers are not enabled or not configured.
61+
*/
62+
async function getContainerOptions(options: {
63+
containersConfig: WorkerConfig["containers"];
64+
isContainersEnabled: boolean;
65+
containerBuildId: string;
66+
configPath?: string;
67+
}) {
68+
const {
69+
containersConfig,
70+
isContainersEnabled,
71+
containerBuildId,
72+
configPath,
73+
} = options;
74+
75+
if (!containersConfig?.length || isContainersEnabled === false) {
76+
return undefined;
77+
}
78+
79+
return containersConfig.map((container) => {
80+
if (isDockerfile(container.image, configPath)) {
81+
return {
82+
dockerfile: container.image,
83+
image_build_context:
84+
container.image_build_context ?? path.dirname(container.image),
85+
image_vars: container.image_vars,
86+
class_name: container.class_name,
87+
image_tag: getDevContainerImageName(
88+
container.class_name,
89+
containerBuildId
90+
),
91+
};
92+
} else {
93+
return {
94+
image_uri: container.image,
95+
class_name: container.class_name,
96+
image_tag: getDevContainerImageName(
97+
container.class_name,
98+
containerBuildId
99+
),
100+
};
101+
}
102+
});
103+
}
104+
105+
/**
106+
* Builds or pulls the container images for local development, and returns the
107+
* corresponding list of image tags
108+
*
109+
* @param options.containersConfig The configured containers
110+
* @param options.containerBuildId The container build id
111+
* @param options.isContainersEnabled Whether containers is enabled for this Worker
112+
* @param options.dockerPath The path to the Docker executable
113+
* @param options.configPath The path of the wrangler configuration file
114+
* @returns The list of image tags corresponding to the built/pulled container images
115+
*/
116+
export async function prepareContainerImages(options: {
117+
containersConfig: WorkerConfig["containers"];
118+
containerBuildId?: string;
119+
isContainersEnabled: boolean;
120+
dockerPath: string;
121+
configPath?: string;
122+
}): Promise<Set<string>> {
123+
assert(
124+
options.containerBuildId,
125+
"Build ID should be set if containers are enabled and defined"
126+
);
127+
128+
const {
129+
containersConfig,
130+
isContainersEnabled,
131+
dockerPath,
132+
containerBuildId,
133+
configPath,
134+
} = options;
135+
const uniqueImageTags = new Set<string>();
136+
137+
// Assemble container options and build if necessary
138+
const containerOptions = await getContainerOptions({
139+
containersConfig,
140+
containerBuildId,
141+
isContainersEnabled,
142+
configPath,
143+
});
144+
145+
if (containerOptions) {
146+
// keep track of them so we can clean up later
147+
for (const container of containerOptions) {
148+
uniqueImageTags.add(container.image_tag);
149+
}
150+
151+
await prepareContainerImagesForDev({
152+
dockerPath,
153+
containerOptions,
154+
onContainerImagePreparationStart: () => {},
155+
onContainerImagePreparationEnd: () => {},
156+
});
157+
}
158+
159+
return uniqueImageTags;
160+
}

packages/vite-plugin-cloudflare/src/index.ts

Lines changed: 64 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import assert from "node:assert";
22
import * as fsp from "node:fs/promises";
33
import * as path from "node:path";
4-
import { prepareContainerImagesForDev } from "@cloudflare/containers-shared/src/images";
5-
import { getDevContainerImageName } from "@cloudflare/containers-shared/src/knobs";
64
import {
75
generateContainerBuildId,
86
getContainerIdsByImageTags,
9-
isDockerfile,
107
} from "@cloudflare/containers-shared/src/utils";
118
import { generateStaticRoutingRuleMatcher } from "@cloudflare/workers-shared/asset-worker/src/utils/rules-engine";
129
import replace from "@rollup/plugin-replace";
@@ -29,7 +26,11 @@ import {
2926
kRequestType,
3027
ROUTER_WORKER_NAME,
3128
} from "./constants";
32-
import { getDockerPath, removeContainersByIds } from "./containers";
29+
import {
30+
getDockerPath,
31+
prepareContainerImages,
32+
removeContainersByIds,
33+
} from "./containers";
3334
import {
3435
addDebugToVitePrintUrls,
3536
getDebugPathHtml,
@@ -75,7 +76,6 @@ import type {
7576
ResolvedPluginConfig,
7677
WorkerConfig,
7778
} from "./plugin-config";
78-
import type { ContainerDevOptions } from "@cloudflare/containers-shared";
7979
import type { StaticRouting } from "@cloudflare/workers-shared/utils/types";
8080
import type { Unstable_RawConfig } from "wrangler";
8181

@@ -99,7 +99,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
9999

100100
const additionalModulePaths = new Set<string>();
101101
const nodeJsCompatWarningsMap = new Map<WorkerConfig, NodeJsCompatWarnings>();
102-
const containerImageTagsSeen = new Set<string>();
102+
let containerImageTagsSeen: Set<string> | undefined;
103103
let runningContainerIds: Array<string>;
104104

105105
return [
@@ -445,38 +445,25 @@ if (import.meta.hot) {
445445
}
446446

447447
if (hasDevContainers) {
448-
assert(
449-
containerBuildId,
450-
"Build ID should be set if containers are enabled and defined"
451-
);
452-
// Assemble container options and build if necessary
453-
const containerOptions = getContainerOptions(
454-
entryWorkerConfig,
455-
containerBuildId
456-
);
457448
const dockerPath = getDockerPath();
458449

459-
if (containerOptions) {
460-
// keep track of them so we can clean up later
461-
for (const container of containerOptions) {
462-
containerImageTagsSeen.add(container.image_tag);
463-
}
464-
465-
await prepareContainerImagesForDev({
466-
dockerPath,
467-
containerOptions,
468-
onContainerImagePreparationStart: () => {},
469-
onContainerImagePreparationEnd: () => {},
470-
});
471-
}
450+
containerImageTagsSeen = await prepareContainerImages({
451+
containersConfig: entryWorkerConfig.containers,
452+
containerBuildId,
453+
isContainersEnabled: entryWorkerConfig.dev.enable_containers,
454+
dockerPath,
455+
configPath: entryWorkerConfig.configPath,
456+
});
472457

473458
// poll Docker every two seconds and update the list of ids of all
474459
// running containers
475460
const dockerPollIntervalId = setInterval(async () => {
476-
runningContainerIds = await getContainerIdsByImageTags(
477-
dockerPath,
478-
containerImageTagsSeen
479-
);
461+
if (containerImageTagsSeen?.size) {
462+
runningContainerIds = await getContainerIdsByImageTags(
463+
dockerPath,
464+
containerImageTagsSeen
465+
);
466+
}
480467
}, 2000);
481468

482469
/*
@@ -563,14 +550,52 @@ if (import.meta.hot) {
563550
vitePreviewServer
564551
);
565552

553+
// first Worker in the Array is always the entry Worker
554+
const entryWorkerConfig = resolvedPluginConfig.workers[0];
555+
const hasDevContainers =
556+
entryWorkerConfig?.containers?.length &&
557+
entryWorkerConfig.dev.enable_containers;
558+
let containerBuildId: string | undefined;
559+
560+
if (hasDevContainers) {
561+
containerBuildId = generateContainerBuildId();
562+
}
563+
566564
miniflare = new Miniflare(
567-
await getPreviewMiniflareOptions(
565+
await getPreviewMiniflareOptions({
568566
resolvedPluginConfig,
569567
vitePreviewServer,
570-
inputInspectorPort
571-
)
568+
inspectorPort: inputInspectorPort,
569+
containerBuildId,
570+
})
572571
);
573572

573+
if (hasDevContainers) {
574+
const dockerPath = getDockerPath();
575+
576+
containerImageTagsSeen = await prepareContainerImages({
577+
containersConfig: entryWorkerConfig.containers,
578+
containerBuildId,
579+
isContainersEnabled: entryWorkerConfig.dev.enable_containers,
580+
dockerPath,
581+
configPath: entryWorkerConfig.configPath,
582+
});
583+
584+
const dockerPollIntervalId = setInterval(async () => {
585+
if (containerImageTagsSeen?.size) {
586+
runningContainerIds = await getContainerIdsByImageTags(
587+
dockerPath,
588+
containerImageTagsSeen
589+
);
590+
}
591+
}, 2000);
592+
593+
process.on("exit", () => {
594+
clearInterval(dockerPollIntervalId);
595+
removeContainersByIds(dockerPath, runningContainerIds);
596+
});
597+
}
598+
574599
handleWebSocket(vitePreviewServer.httpServer, () => {
575600
assert(miniflare, `Miniflare not defined`);
576601

@@ -587,7 +612,10 @@ if (import.meta.hot) {
587612
);
588613
},
589614
async buildEnd() {
590-
if (resolvedViteConfig.command === "serve") {
615+
if (
616+
resolvedViteConfig.command === "serve" &&
617+
containerImageTagsSeen?.size
618+
) {
591619
const dockerPath = getDockerPath();
592620
runningContainerIds = await getContainerIdsByImageTags(
593621
dockerPath,
@@ -1037,42 +1065,4 @@ if (import.meta.hot) {
10371065
? resolvedPluginConfig.workers[environmentName]
10381066
: undefined;
10391067
}
1040-
1041-
/**
1042-
* @returns Container options suitable for building or pulling images,
1043-
* with image tag set to well-known dev format, or undefined if
1044-
* containers are not enabled or not configured.
1045-
*/
1046-
function getContainerOptions(
1047-
config: WorkerConfig,
1048-
containerBuildId: string
1049-
): ContainerDevOptions[] | undefined {
1050-
if (!config.containers?.length || config.dev.enable_containers === false) {
1051-
return undefined;
1052-
}
1053-
return config.containers.map((container) => {
1054-
if (isDockerfile(container.image, config.configPath)) {
1055-
return {
1056-
dockerfile: container.image,
1057-
image_build_context:
1058-
container.image_build_context ?? path.dirname(container.image),
1059-
image_vars: container.image_vars,
1060-
class_name: container.class_name,
1061-
image_tag: getDevContainerImageName(
1062-
container.class_name,
1063-
containerBuildId
1064-
),
1065-
};
1066-
} else {
1067-
return {
1068-
image_uri: container.image,
1069-
class_name: container.class_name,
1070-
image_tag: getDevContainerImageName(
1071-
container.class_name,
1072-
containerBuildId
1073-
),
1074-
};
1075-
}
1076-
});
1077-
}
10781068
}

packages/vite-plugin-cloudflare/src/miniflare-options.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -660,11 +660,18 @@ function getPreviewModules(
660660
} satisfies Pick<WorkerOptions, "rootPath" | "modules">;
661661
}
662662

663-
export async function getPreviewMiniflareOptions(
664-
resolvedPluginConfig: PreviewResolvedConfig,
665-
vitePreviewServer: vite.PreviewServer,
666-
inspectorPort: number | false
667-
): Promise<MiniflareOptions> {
663+
export async function getPreviewMiniflareOptions(config: {
664+
resolvedPluginConfig: PreviewResolvedConfig;
665+
vitePreviewServer: vite.PreviewServer;
666+
inspectorPort: number | false;
667+
containerBuildId?: string;
668+
}): Promise<MiniflareOptions> {
669+
const {
670+
resolvedPluginConfig,
671+
vitePreviewServer,
672+
inspectorPort,
673+
containerBuildId,
674+
} = config;
668675
const resolvedViteConfig = vitePreviewServer.config;
669676
const workers: Array<WorkerOptions> = (
670677
await Promise.all(
@@ -702,6 +709,7 @@ export async function getPreviewMiniflareOptions(
702709
remoteProxySessionData?.session?.remoteProxyConnectionString,
703710
remoteBindingsEnabled:
704711
resolvedPluginConfig.experimental.remoteBindings,
712+
containerBuildId,
705713
}
706714
);
707715

0 commit comments

Comments
 (0)