Skip to content

Commit f44419e

Browse files
pcattorijacob-ebeymarkdalgleish
authored
Cloudflare support for Vite (#8531)
Co-authored-by: Jacob Ebey <[email protected]> Co-authored-by: Mark Dalgleish <[email protected]>
1 parent db45174 commit f44419e

File tree

10 files changed

+295
-112
lines changed

10 files changed

+295
-112
lines changed

integration/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"type-fest": "^4.0.0",
3737
"typescript": "^5.1.0",
3838
"vite-env-only": "^2.0.0",
39-
"vite-tsconfig-paths": "^4.2.2"
39+
"vite-tsconfig-paths": "^4.2.2",
40+
"wrangler": "^3.24.0"
4041
}
4142
}

integration/vite-cloudflare-test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { test, expect } from "@playwright/test";
2+
import getPort from "get-port";
3+
4+
import { VITE_CONFIG, createProject, using, viteDev } from "./helpers/vite.js";
5+
6+
test.describe("Vite / cloudflare", async () => {
7+
let port: number;
8+
let cwd: string;
9+
10+
test.beforeAll(async () => {
11+
port = await getPort();
12+
cwd = await createProject({
13+
"package.json": JSON.stringify(
14+
{
15+
private: true,
16+
sideEffects: false,
17+
type: "module",
18+
scripts: {
19+
dev: "remix vite:dev",
20+
build: "remix vite:build",
21+
start: "wrangler pages dev ./build/client",
22+
deploy: "wrangler pages deploy ./build/client",
23+
typecheck: "tsc",
24+
},
25+
dependencies: {
26+
"@remix-run/cloudflare": "*",
27+
"@remix-run/cloudflare-pages": "*",
28+
"@remix-run/react": "*",
29+
isbot: "^4.1.0",
30+
miniflare: "^3.20231030.4",
31+
react: "^18.2.0",
32+
"react-dom": "^18.2.0",
33+
},
34+
devDependencies: {
35+
"@cloudflare/workers-types": "^4.20230518.0",
36+
"@remix-run/dev": "*",
37+
"@types/react": "^18.2.20",
38+
"@types/react-dom": "^18.2.7",
39+
"node-fetch": "^3.3.2",
40+
typescript: "^5.1.6",
41+
vite: "^5.0.0",
42+
"vite-tsconfig-paths": "^4.2.1",
43+
wrangler: "^3.24.0",
44+
},
45+
engines: {
46+
node: ">=18.0.0",
47+
},
48+
},
49+
null,
50+
2
51+
),
52+
"vite.config.ts": await VITE_CONFIG({
53+
port,
54+
pluginOptions: `{ adapter: (await import("@remix-run/dev")).unstable_vitePluginAdapterCloudflare() }`,
55+
}),
56+
"functions/[[page]].ts": `
57+
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
58+
59+
// @ts-ignore - the server build file is generated by \`remix vite:build\`
60+
import * as build from "../build/server";
61+
62+
export const onRequest = createPagesFunctionHandler({
63+
build,
64+
getLoadContext: (context) => ({ env: context.env }),
65+
});
66+
`,
67+
"wrangler.toml": `
68+
kv_namespaces = [
69+
{ id = "abc123", binding="MY_KV" }
70+
]
71+
`,
72+
"app/routes/_index.tsx": `
73+
import {
74+
json,
75+
type LoaderFunctionArgs,
76+
type ActionFunctionArgs,
77+
} from "@remix-run/cloudflare";
78+
import { Form, useLoaderData } from "@remix-run/react";
79+
80+
const key = "__my-key__";
81+
82+
export async function loader({ context }: LoaderFunctionArgs) {
83+
const { MY_KV } = context.env;
84+
const value = await MY_KV.get(key);
85+
return json({ value });
86+
}
87+
88+
export async function action({ request, context }: ActionFunctionArgs) {
89+
const { MY_KV: myKv } = context.env;
90+
91+
if (request.method === "POST") {
92+
const formData = await request.formData();
93+
const value = formData.get("value") as string;
94+
await myKv.put(key, value);
95+
return null;
96+
}
97+
98+
if (request.method === "DELETE") {
99+
await myKv.delete(key);
100+
return null;
101+
}
102+
103+
throw new Error(\`Method not supported: "\${request.method}"\`);
104+
}
105+
106+
export default function Index() {
107+
const { value } = useLoaderData<typeof loader>();
108+
return (
109+
<div>
110+
<h1>Welcome to Remix</h1>
111+
{value ? (
112+
<>
113+
<p data-text>Value: {value}</p>
114+
<Form method="DELETE">
115+
<button>Delete</button>
116+
</Form>
117+
</>
118+
) : (
119+
<>
120+
<p data-text>No value</p>
121+
<Form method="POST">
122+
<label htmlFor="value">Set value:</label>
123+
<input type="text" name="value" id="value" required />
124+
<br />
125+
<button>Save</button>
126+
</Form>
127+
</>
128+
)}
129+
</div>
130+
);
131+
}
132+
`,
133+
});
134+
});
135+
136+
test("vite dev", async ({ page }) => {
137+
await using(await viteDev({ cwd, port }), async () => {
138+
let pageErrors: Error[] = [];
139+
page.on("pageerror", (error) => pageErrors.push(error));
140+
141+
await page.goto(`http://localhost:${port}/`, {
142+
waitUntil: "networkidle",
143+
});
144+
await expect(page.locator("[data-text]")).toHaveText("No value");
145+
146+
await page.getByLabel("Set value:").fill("my-value");
147+
await page.getByRole("button").click();
148+
await expect(page.locator("[data-text]")).toHaveText("Value: my-value");
149+
150+
expect(pageErrors).toEqual([]);
151+
});
152+
});
153+
});

packages/remix-dev/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ export type {
1010
Unstable_BuildManifest,
1111
Unstable_VitePluginAdapter,
1212
} from "./vite";
13-
export { unstable_vitePlugin } from "./vite";
13+
export {
14+
unstable_vitePlugin,
15+
unstable_vitePluginAdapterCloudflare,
16+
} from "./vite";

packages/remix-dev/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,14 @@
9191
"msw": "^1.2.3",
9292
"strip-ansi": "^6.0.1",
9393
"tiny-invariant": "^1.2.0",
94-
"vite": "^5.0.0"
94+
"vite": "^5.0.0",
95+
"wrangler": "^3.24.0"
9596
},
9697
"peerDependencies": {
9798
"@remix-run/serve": "^2.5.1",
9899
"typescript": "^5.1.0",
99-
"vite": "^5.0.0"
100+
"vite": "^5.0.0",
101+
"wrangler": "^3.24.0"
100102
},
101103
"peerDependenciesMeta": {
102104
"@remix-run/serve": {
@@ -107,6 +109,9 @@
107109
},
108110
"vite": {
109111
"optional": true
112+
},
113+
"wrangler": {
114+
"optional": true
110115
}
111116
},
112117
"engines": {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const adapter = () => async () => {
2+
let { getBindingsProxy } = await import("wrangler");
3+
let { bindings } = await getBindingsProxy();
4+
let loadContext = bindings && { env: bindings };
5+
let viteConfig = {
6+
ssr: {
7+
resolve: {
8+
externalConditions: ["workerd", "worker"],
9+
},
10+
},
11+
};
12+
return { viteConfig, loadContext };
13+
};

packages/remix-dev/vite/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ export const unstable_vitePlugin: RemixVitePlugin = (...args) => {
1212
let { remixVitePlugin } = require("./plugin") as typeof import("./plugin");
1313
return remixVitePlugin(...args);
1414
};
15+
16+
export { adapter as unstable_vitePluginAdapterCloudflare } from "./adapters/cloudflare";
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type {
2+
IncomingHttpHeaders,
3+
IncomingMessage,
4+
ServerResponse,
5+
} from "node:http";
6+
import { once } from "node:events";
7+
import { Readable } from "node:stream";
8+
import { splitCookiesString } from "set-cookie-parser";
9+
import { createReadableStreamFromReadable } from "@remix-run/node";
10+
11+
import invariant from "../invariant";
12+
13+
export type NodeRequestHandler = (
14+
req: IncomingMessage,
15+
res: ServerResponse
16+
) => Promise<void>;
17+
18+
function fromNodeHeaders(nodeHeaders: IncomingHttpHeaders): Headers {
19+
let headers = new Headers();
20+
21+
for (let [key, values] of Object.entries(nodeHeaders)) {
22+
if (values) {
23+
if (Array.isArray(values)) {
24+
for (let value of values) {
25+
headers.append(key, value);
26+
}
27+
} else {
28+
headers.set(key, values);
29+
}
30+
}
31+
}
32+
33+
return headers;
34+
}
35+
36+
// Based on `createRemixRequest` in packages/remix-express/server.ts
37+
export function fromNodeRequest(nodeReq: IncomingMessage): Request {
38+
let origin =
39+
nodeReq.headers.origin && "null" !== nodeReq.headers.origin
40+
? nodeReq.headers.origin
41+
: `http://${nodeReq.headers.host}`;
42+
invariant(nodeReq.url, 'Expected "req.url" to be defined');
43+
let url = new URL(nodeReq.url, origin);
44+
45+
let init: RequestInit = {
46+
method: nodeReq.method,
47+
headers: fromNodeHeaders(nodeReq.headers),
48+
};
49+
50+
if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") {
51+
init.body = createReadableStreamFromReadable(nodeReq);
52+
(init as { duplex: "half" }).duplex = "half";
53+
}
54+
55+
return new Request(url.href, init);
56+
}
57+
58+
// Adapted from solid-start's `handleNodeResponse`:
59+
// https://github.com/solidjs/solid-start/blob/7398163869b489cce503c167e284891cf51a6613/packages/start/node/fetch.js#L162-L185
60+
export async function toNodeRequest(res: Response, nodeRes: ServerResponse) {
61+
nodeRes.statusCode = res.status;
62+
nodeRes.statusMessage = res.statusText;
63+
64+
let cookiesStrings = [];
65+
66+
for (let [name, value] of res.headers) {
67+
if (name === "set-cookie") {
68+
cookiesStrings.push(...splitCookiesString(value));
69+
} else nodeRes.setHeader(name, value);
70+
}
71+
72+
if (cookiesStrings.length) {
73+
nodeRes.setHeader("set-cookie", cookiesStrings);
74+
}
75+
76+
if (res.body) {
77+
// https://github.com/microsoft/TypeScript/issues/29867
78+
let responseBody = res.body as unknown as AsyncIterable<Uint8Array>;
79+
let readable = Readable.from(responseBody);
80+
readable.pipe(nodeRes);
81+
await once(readable, "end");
82+
} else {
83+
nodeRes.end();
84+
}
85+
}

0 commit comments

Comments
 (0)