Skip to content

Commit 280d65c

Browse files
Add Cloudflare package (#11801)
1 parent c4c48af commit 280d65c

30 files changed

+1451
-22
lines changed

.changeset/calm-frogs-tie.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-router/cloudflare": major
3+
---
4+
5+
For Remix consumers migrating to React Router, all exports from `@remix-run/cloudflare-pages` are now provided for React Router consumers in the `@react-router/cloudflare` package. There is no longer a separate package for Cloudflare Pages.

.changeset/fair-beans-design.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-router/cloudflare": minor
3+
---
4+
5+
The `@remix-run/cloudflare-workers` package has been deprecated. Remix consumers migrating to React Router should use the `@react-router/cloudflare` package directly. For guidance on how to use `@react-router/cloudflare` within a Cloudflare Workers context, refer to the Cloudflare Workers template.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules
2+
3+
/build
4+
.env
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { AppLoadContext, EntryContext } from "react-router";
2+
import { ServerRouter } from "react-router";
3+
import { isbot } from "isbot";
4+
import { renderToReadableStream } from "react-dom/server";
5+
6+
export default async function handleRequest(
7+
request: Request,
8+
responseStatusCode: number,
9+
responseHeaders: Headers,
10+
routerContext: EntryContext,
11+
loadContext: AppLoadContext
12+
) {
13+
const body = await renderToReadableStream(
14+
<ServerRouter context={routerContext} url={request.url} />,
15+
{
16+
signal: request.signal,
17+
onError(error: unknown) {
18+
// Log streaming rendering errors from inside the shell
19+
console.error(error);
20+
responseStatusCode = 500;
21+
},
22+
}
23+
);
24+
25+
const userAgent = request.headers.get("user-agent");
26+
if (userAgent && isbot(userAgent)) {
27+
await body.allReady;
28+
}
29+
30+
responseHeaders.set("Content-Type", "text/html");
31+
return new Response(body, {
32+
headers: responseHeaders,
33+
status: responseStatusCode,
34+
});
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
2+
3+
export function Layout({ children }: { children: React.ReactNode }) {
4+
return (
5+
<html lang="en">
6+
<head>
7+
<meta charSet="utf-8" />
8+
<meta name="viewport" content="width=device-width, initial-scale=1" />
9+
<Meta />
10+
<Links />
11+
</head>
12+
<body>
13+
{children}
14+
<ScrollRestoration />
15+
<Scripts />
16+
</body>
17+
</html>
18+
);
19+
}
20+
21+
export default function App() {
22+
return <Outlet />;
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { MetaFunction } from "react-router";
2+
3+
export const meta: MetaFunction = () => {
4+
return [
5+
{ title: "New React Router App" },
6+
{ name: "description", content: "React Router + Cloudflare" },
7+
];
8+
};
9+
10+
export default function Index() {
11+
return (
12+
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
13+
<h1>Welcome to React Router + Cloudflare</h1>
14+
</div>
15+
);
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "integration-vite-cloudflare-template",
3+
"version": "0.0.0",
4+
"private": true,
5+
"sideEffects": false,
6+
"type": "module",
7+
"scripts": {
8+
"dev": "react-router dev",
9+
"build": "react-router build",
10+
"start": "wrangler pages dev ./build/client",
11+
"tsc": "tsc"
12+
},
13+
"dependencies": {
14+
"@react-router/cloudflare": "workspace:*",
15+
"isbot": "^4.1.0",
16+
"miniflare": "^3.20231030.4",
17+
"react": "^18.2.0",
18+
"react-dom": "^18.2.0",
19+
"react-router": "workspace:*"
20+
},
21+
"devDependencies": {
22+
"@cloudflare/workers-types": "^4.20230518.0",
23+
"@react-router/dev": "workspace:*",
24+
"@types/react": "^18.2.20",
25+
"@types/react-dom": "^18.2.7",
26+
"typescript": "^5.1.6",
27+
"vite": "^5.1.0",
28+
"wrangler": "^3.28.2"
29+
},
30+
"engines": {
31+
"node": ">=18.0.0"
32+
}
33+
}
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
3+
"compilerOptions": {
4+
"lib": ["DOM", "DOM.Iterable", "ES2022"],
5+
"types": ["vite/client"],
6+
"isolatedModules": true,
7+
"esModuleInterop": true,
8+
"jsx": "react-jsx",
9+
"module": "ESNext",
10+
"moduleResolution": "Bundler",
11+
"resolveJsonModule": true,
12+
"target": "ES2022",
13+
"strict": true,
14+
"allowJs": true,
15+
"skipLibCheck": true,
16+
"forceConsistentCasingInFileNames": true,
17+
"baseUrl": ".",
18+
"noEmit": true
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {
2+
vitePlugin as reactRouter,
3+
cloudflareDevProxyVitePlugin as reactRouterCloudflareDevProxy,
4+
} from "@react-router/dev";
5+
import { defineConfig } from "vite";
6+
7+
export default defineConfig({
8+
plugins: [reactRouterCloudflareDevProxy(), reactRouter()],
9+
});

integration/helpers/vite.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "node:path";
33
import fs from "node:fs/promises";
44
import type { Readable } from "node:stream";
55
import url from "node:url";
6+
import { createRequire } from "node:module";
67
import fse from "fs-extra";
78
import stripIndent from "strip-indent";
89
import waitOn from "wait-on";
@@ -14,6 +15,8 @@ import type { Page } from "@playwright/test";
1415
import { test as base, expect } from "@playwright/test";
1516
import type { VitePluginConfig } from "@react-router/dev";
1617

18+
const require = createRequire(import.meta.url);
19+
1720
const reactRouterBin = "node_modules/@react-router/dev/dist/cli.js";
1821
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
1922
const root = path.resolve(__dirname, "../..");
@@ -194,9 +197,9 @@ export const wranglerPagesDev = async ({
194197
port: number;
195198
}) => {
196199
let nodeBin = process.argv[0];
197-
198-
// grab wrangler bin from remix-run/remix root node_modules since its not copied into integration project's node_modules
199-
let wranglerBin = path.resolve("node_modules/wrangler/bin/wrangler.js");
200+
let wranglerBin = require.resolve("wrangler/bin/wrangler.js", {
201+
paths: [cwd],
202+
});
200203

201204
let proc = spawn(
202205
nodeBin,

integration/vite-cloudflare-test.ts

+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { Page } from "@playwright/test";
2+
import { expect } from "@playwright/test";
3+
4+
import type { Files } from "./helpers/vite.js";
5+
import { test, viteConfig } from "./helpers/vite.js";
6+
7+
const files: Files = async ({ port }) => ({
8+
"vite.config.ts": `
9+
import {
10+
vitePlugin as reactRouter,
11+
cloudflareDevProxyVitePlugin as reactRouterCloudflareDevProxy,
12+
} from "@react-router/dev";
13+
import { getLoadContext } from "./load-context";
14+
15+
export default {
16+
${await viteConfig.server({ port })}
17+
plugins: [
18+
reactRouterCloudflareDevProxy({ getLoadContext }),
19+
reactRouter(),
20+
],
21+
}
22+
`,
23+
"load-context.ts": `
24+
import { type AppLoadContext } from "@react-router/cloudflare";
25+
import { type PlatformProxy } from "wrangler";
26+
27+
type Env = {
28+
MY_KV: KVNamespace;
29+
}
30+
type Cloudflare = Omit<PlatformProxy<Env>, 'dispose'>;
31+
32+
declare module "@react-router/cloudflare" {
33+
interface AppLoadContext {
34+
cloudflare: Cloudflare;
35+
env2: Cloudflare["env"];
36+
extra: string;
37+
}
38+
}
39+
40+
type GetLoadContext = (args: {
41+
request: Request;
42+
context: { cloudflare: Cloudflare };
43+
}) => AppLoadContext;
44+
45+
export const getLoadContext: GetLoadContext = ({ context }) => {
46+
return {
47+
...context,
48+
env2: context.cloudflare.env,
49+
extra: "stuff",
50+
};
51+
};
52+
`,
53+
"functions/[[page]].ts": `
54+
import { createPagesFunctionHandler } from "@react-router/cloudflare";
55+
56+
// @ts-ignore - the server build file is generated by \`react-router build\`
57+
import * as build from "../build/server";
58+
import { getLoadContext } from "../load-context";
59+
60+
export const onRequest = createPagesFunctionHandler({
61+
build,
62+
getLoadContext,
63+
});
64+
`,
65+
"wrangler.toml": `
66+
kv_namespaces = [
67+
{ id = "abc123", binding="MY_KV" }
68+
]
69+
`,
70+
"app/routes/_index.tsx": `
71+
import {
72+
type LoaderFunctionArgs,
73+
type ActionFunctionArgs,
74+
json,
75+
Form,
76+
useLoaderData,
77+
} from "react-router";
78+
79+
const key = "__my-key__";
80+
81+
export async function loader({ context }: LoaderFunctionArgs) {
82+
const { MY_KV } = context.cloudflare.env;
83+
const value = await MY_KV.get(key);
84+
return json({ value, extra: context.extra });
85+
}
86+
87+
export async function action({ request, context }: ActionFunctionArgs) {
88+
const { MY_KV } = context.env2;
89+
90+
if (request.method === "POST") {
91+
const formData = await request.formData();
92+
const value = formData.get("value") as string;
93+
await MY_KV.put(key, value);
94+
return null;
95+
}
96+
97+
if (request.method === "DELETE") {
98+
await MY_KV.delete(key);
99+
return null;
100+
}
101+
102+
throw new Error(\`Method not supported: "\${request.method}"\`);
103+
}
104+
105+
export default function Index() {
106+
const { value, extra } = useLoaderData<typeof loader>();
107+
return (
108+
<div>
109+
<h1>Welcome to React Router + Cloudflare</h1>
110+
<p data-extra>Extra: {extra}</p>
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+
test("vite dev", async ({ page, dev }) => {
136+
let { port } = await dev(files, "vite-cloudflare-template");
137+
await workflow({ page, port });
138+
});
139+
140+
test("wrangler pages dev", async ({ page, wranglerPagesDev }) => {
141+
let { port } = await wranglerPagesDev(files);
142+
await workflow({ page, port });
143+
});
144+
145+
async function workflow({ page, port }: { page: Page; port: number }) {
146+
await page.goto(`http://localhost:${port}/`, {
147+
waitUntil: "networkidle",
148+
});
149+
await expect(page.locator("[data-extra]")).toHaveText("Extra: stuff");
150+
await expect(page.locator("[data-text]")).toHaveText("No value");
151+
152+
await page.getByLabel("Set value:").fill("my-value");
153+
await page.getByRole("button").click();
154+
await expect(page.locator("[data-text]")).toHaveText("Value: my-value");
155+
expect(page.errors).toEqual([]);
156+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# React Router Cloudflare
2+
3+
Cloudflare platform abstractions for [React Router.](https://reactrouter.com)
4+
5+
```bash
6+
npm install @react-router/cloudflare @cloudflare/workers-types
7+
```

0 commit comments

Comments
 (0)