Skip to content

Commit 0c4008c

Browse files
feat(vite-plugin): Add containers support in vite-plugin (#9819)
* feat(vite-plugin): Add containers support in `vite dev` Adds support for Cloudflare Containers in `vite dev`. 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. * suggestions Co-authored-by: emily-shen <[email protected]> * feedback fixes --------- Co-authored-by: emily-shen <[email protected]>
1 parent 5796ca9 commit 0c4008c

File tree

26 files changed

+568
-60
lines changed

26 files changed

+568
-60
lines changed

.changeset/wet-rockets-stare.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cloudflare/vite-plugin": patch
3+
"@cloudflare/containers-shared": patch
4+
"wrangler": patch
5+
---
6+
7+
feat(vite-plugin): Add containers support in `vite dev`
8+
9+
Adds support for Cloudflare Containers in `vite dev`. 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, as will containers support in `vite preview`.

packages/containers-shared/src/build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawn } from "child_process";
22
import { readFileSync } from "fs";
33
import path from "path";
4-
import { BuildArgs, ContainerDevOptions, Logger } from "./types";
4+
import type { BuildArgs, ContainerDevOptions, Logger } from "./types";
55

66
export async function constructBuildCommand(
77
options: BuildArgs,

packages/containers-shared/src/client/core/request.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ const getFormData = (options: ApiRequestOptions): FormData | undefined => {
113113
if (options.formData) {
114114
const formData = new FormData();
115115

116-
const process = (key: string, value: any) => {
117-
if (isString(value) || isBlob(value)) {
116+
const process = async (key: string, value: any) => {
117+
if (isString(value)) {
118118
formData.append(key, value);
119119
} else {
120120
formData.append(key, JSON.stringify(value));
@@ -233,7 +233,7 @@ const parseResponseSchemaV4 = <T>(
233233
result = {};
234234
}
235235
} else {
236-
result = { error: fetchResult.errors?.[0].message };
236+
result = { error: fetchResult.errors?.[0]?.message };
237237
}
238238
return {
239239
url,
@@ -263,6 +263,10 @@ export const sendRequest = async (
263263
};
264264

265265
if (config.WITH_CREDENTIALS) {
266+
// :(
267+
// The vite-plugin is attempting to typecheck everything with worker types, which does not support request.credentials
268+
// Also note this is always set to "omit".
269+
// @ts-ignore
266270
request.credentials = config.CREDENTIALS;
267271
}
268272

packages/containers-shared/src/images.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
isCloudflareRegistryLink,
55
} from "./knobs";
66
import { dockerLoginManagedRegistry } from "./login";
7-
import { ContainerDevOptions } from "./types";
7+
import { type ContainerDevOptions } from "./types";
88
import {
99
checkExposedPorts,
1010
isDockerfile,
@@ -49,18 +49,25 @@ export async function pullImage(
4949
* such as checking if the Docker CLI is installed, and if the container images
5050
* expose any ports.
5151
*/
52-
export async function prepareContainerImagesForDev(
53-
dockerPath: string,
54-
containerOptions: ContainerDevOptions[],
55-
configPath: string | undefined,
52+
export async function prepareContainerImagesForDev(options: {
53+
dockerPath: string;
54+
configPath?: string;
55+
containerOptions: ContainerDevOptions[];
5656
onContainerImagePreparationStart: (args: {
5757
containerOptions: ContainerDevOptions;
5858
abort: () => void;
59-
}) => void,
59+
}) => void;
6060
onContainerImagePreparationEnd: (args: {
6161
containerOptions: ContainerDevOptions;
62-
}) => void
63-
) {
62+
}) => void;
63+
}) {
64+
const {
65+
dockerPath,
66+
configPath,
67+
containerOptions,
68+
onContainerImagePreparationStart,
69+
onContainerImagePreparationEnd,
70+
} = options;
6471
let aborted = false;
6572
if (process.platform === "win32") {
6673
throw new Error(

packages/containers-shared/src/utils.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { execFile, spawn, StdioOptions } from "child_process";
1+
import { execFile, spawn } from "child_process";
2+
import { randomUUID } from "crypto";
23
import { existsSync, statSync } from "fs";
34
import path from "path";
45
import { dockerImageInspect } from "./inspect";
5-
import { ContainerDevOptions } from "./types";
6+
import { type ContainerDevOptions } from "./types";
7+
import type { StdioOptions } from "child_process";
68

79
/** helper for simple docker command call that don't require any io handling */
810
export const runDockerCmd = (
@@ -127,7 +129,7 @@ export const isDockerfile = (
127129
}
128130
const imageParts = image.split("/");
129131

130-
if (!imageParts[imageParts.length - 1].includes(":")) {
132+
if (!imageParts[imageParts.length - 1]?.includes(":")) {
131133
throw new Error(
132134
errorPrefix +
133135
`If this is an image registry path, it needs to include at least a tag ':' (e.g: docker.io/httpd:1)`
@@ -146,19 +148,22 @@ export const isDockerfile = (
146148

147149
/**
148150
* Kills and removes any containers which come from the given image tag
151+
*
152+
* Please note that this function has an almost identical counterpart
153+
* in the `vite-plugin-cloudflare` package (see `removeContainersByIds`).
154+
* If you make any changes to this fn, please make sure you persist those
155+
* changes in `removeContainersByIds` if necessary.
149156
*/
150157
export const cleanupContainers = async (
151158
dockerPath: string,
152159
imageTags: Set<string>
153160
) => {
154161
try {
155162
// Find all containers (stopped and running) for each built image
156-
const containerIds: string[] = [];
157-
for (const imageTag of imageTags) {
158-
containerIds.push(
159-
...(await getContainerIdsFromImage(dockerPath, imageTag))
160-
);
161-
}
163+
const containerIds = await getContainerIdsByImageTags(
164+
dockerPath,
165+
imageTags
166+
);
162167

163168
if (containerIds.length === 0) {
164169
return true;
@@ -176,7 +181,31 @@ export const cleanupContainers = async (
176181
}
177182
};
178183

179-
const getContainerIdsFromImage = async (
184+
/**
185+
* See https://docs.docker.com/reference/cli/docker/container/ls/#ancestor
186+
*
187+
* @param dockerPath The path to the Docker executable
188+
* @param imageTags A set of ancestor image tags
189+
* @returns The ids of all containers that share the given image tags as ancestors.
190+
*/
191+
export async function getContainerIdsByImageTags(
192+
dockerPath: string,
193+
imageTags: Set<string>
194+
): Promise<Array<string>> {
195+
const ids = new Set<string>();
196+
197+
for (const imageTag of imageTags) {
198+
const containerIdsFromImage = await getContainerIdsFromImage(
199+
dockerPath,
200+
imageTag
201+
);
202+
containerIdsFromImage.forEach((id) => ids.add(id));
203+
}
204+
205+
return Array.from(ids);
206+
}
207+
208+
export const getContainerIdsFromImage = async (
180209
dockerPath: string,
181210
ancestorImage: string
182211
) => {
@@ -214,3 +243,10 @@ export async function checkExposedPorts(
214243
);
215244
}
216245
}
246+
247+
/**
248+
* Generates a random container build id
249+
*/
250+
export function generateContainerBuildId() {
251+
return randomUUID().slice(0, 8);
252+
}

packages/vite-plugin-cloudflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"ws": "catalog:default"
5555
},
5656
"devDependencies": {
57+
"@cloudflare/containers-shared": "workspace:*",
5758
"@cloudflare/mock-npm-registry": "workspace:*",
5859
"@cloudflare/workers-shared": "workspace:*",
5960
"@cloudflare/workers-tsconfig": "workspace:*",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /usr/src/app
4+
RUN echo '{"name": "simple-node-app", "version": "1.0.0", "dependencies": {"ws": "^8.0.0"}}' > package.json
5+
RUN npm install
6+
7+
COPY ./container/simple-node-app.js app.js
8+
EXPOSE 8080
9+
CMD [ "node", "app.js" ]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { expect, test } from "vitest";
2+
import { getTextResponse, isBuild, isCINonLinux } from "../../__test-utils__";
3+
4+
// skip build test until containers support is implemented in `vite preview`
5+
// We can only really run these tests on Linux, because we build our images for linux/amd64,
6+
// and github runners don't really support container virtualization in any sane way
7+
test.skipIf(isBuild || isCINonLinux)("starts container", async () => {
8+
const startResponse = await getTextResponse("/start");
9+
expect(startResponse).toBe("Container create request sent...");
10+
11+
const statusResponse = await getTextResponse("/status");
12+
expect(statusResponse).toBe("true");
13+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const { createServer } = require("http");
2+
3+
const webSocketEnabled = process.env.WS_ENABLED === "true";
4+
5+
// Create HTTP server
6+
const server = createServer(function (req, res) {
7+
if (req.url === "/ws") {
8+
// WebSocket upgrade will be handled by the WebSocket server
9+
return;
10+
}
11+
12+
res.writeHead(200, { "Content-Type": "text/plain" });
13+
res.write("Hello World!");
14+
res.end();
15+
});
16+
17+
// Check if WebSocket functionality is enabled
18+
if (webSocketEnabled) {
19+
const WebSocket = require("ws");
20+
21+
// Create WebSocket server
22+
const wss = new WebSocket.Server({
23+
server: server,
24+
path: "/ws",
25+
});
26+
27+
wss.on("connection", function connection(ws) {
28+
console.log("WebSocket connection established");
29+
30+
ws.on("message", function message(data) {
31+
console.log("Received:", data.toString());
32+
// Echo the message back with prefix
33+
ws.send("Echo: " + data.toString());
34+
});
35+
36+
ws.on("close", function close() {
37+
console.log("WebSocket connection closed");
38+
});
39+
40+
ws.on("error", console.error);
41+
});
42+
}
43+
44+
server.listen(8080, function () {
45+
console.log("Server listening on port 8080");
46+
if (webSocketEnabled) {
47+
console.log("WebSocket support enabled");
48+
}
49+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "@playground/containers",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"build": "vite build",
7+
"check:types": "tsc --build",
8+
"dev": "vite dev",
9+
"preview": "vite preview"
10+
},
11+
"devDependencies": {
12+
"@cloudflare/vite-plugin": "workspace:*",
13+
"@cloudflare/workers-tsconfig": "workspace:*",
14+
"@cloudflare/workers-types": "^4.20250617.0",
15+
"typescript": "catalog:default",
16+
"vite": "catalog:vite-plugin",
17+
"wrangler": "workspace:*"
18+
}
19+
}

0 commit comments

Comments
 (0)