Skip to content

Commit

Permalink
Add Cloudflare package (#11801)
Browse files Browse the repository at this point in the history
  • Loading branch information
markdalgleish authored Jul 15, 2024
1 parent c4c48af commit 280d65c
Show file tree
Hide file tree
Showing 30 changed files with 1,451 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/calm-frogs-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-router/cloudflare": major
---

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.
5 changes: 5 additions & 0 deletions .changeset/fair-beans-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-router/cloudflare": minor
---

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.
4 changes: 4 additions & 0 deletions integration/helpers/vite-cloudflare-template/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules

/build
.env
35 changes: 35 additions & 0 deletions integration/helpers/vite-cloudflare-template/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext
) {
const body = await renderToReadableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
signal: request.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
responseStatusCode = 500;
},
}
);

const userAgent = request.headers.get("user-agent");
if (userAgent && isbot(userAgent)) {
await body.allReady;
}

responseHeaders.set("Content-Type", "text/html");
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}
23 changes: 23 additions & 0 deletions integration/helpers/vite-cloudflare-template/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}
16 changes: 16 additions & 0 deletions integration/helpers/vite-cloudflare-template/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { MetaFunction } from "react-router";

export const meta: MetaFunction = () => {
return [
{ title: "New React Router App" },
{ name: "description", content: "React Router + Cloudflare" },
];
};

export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>Welcome to React Router + Cloudflare</h1>
</div>
);
}
33 changes: 33 additions & 0 deletions integration/helpers/vite-cloudflare-template/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "integration-vite-cloudflare-template",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "react-router dev",
"build": "react-router build",
"start": "wrangler pages dev ./build/client",
"tsc": "tsc"
},
"dependencies": {
"@react-router/cloudflare": "workspace:*",
"isbot": "^4.1.0",
"miniflare": "^3.20231030.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "workspace:*"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230518.0",
"@react-router/dev": "workspace:*",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"typescript": "^5.1.6",
"vite": "^5.1.0",
"wrangler": "^3.28.2"
},
"engines": {
"node": ">=18.0.0"
}
}
Binary file not shown.
20 changes: 20 additions & 0 deletions integration/helpers/vite-cloudflare-template/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["vite/client"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2022",
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"noEmit": true
}
}
9 changes: 9 additions & 0 deletions integration/helpers/vite-cloudflare-template/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {
vitePlugin as reactRouter,
cloudflareDevProxyVitePlugin as reactRouterCloudflareDevProxy,
} from "@react-router/dev";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [reactRouterCloudflareDevProxy(), reactRouter()],
});
9 changes: 6 additions & 3 deletions integration/helpers/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "node:path";
import fs from "node:fs/promises";
import type { Readable } from "node:stream";
import url from "node:url";
import { createRequire } from "node:module";
import fse from "fs-extra";
import stripIndent from "strip-indent";
import waitOn from "wait-on";
Expand All @@ -14,6 +15,8 @@ import type { Page } from "@playwright/test";
import { test as base, expect } from "@playwright/test";
import type { VitePluginConfig } from "@react-router/dev";

const require = createRequire(import.meta.url);

const reactRouterBin = "node_modules/@react-router/dev/dist/cli.js";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
const root = path.resolve(__dirname, "../..");
Expand Down Expand Up @@ -194,9 +197,9 @@ export const wranglerPagesDev = async ({
port: number;
}) => {
let nodeBin = process.argv[0];

// grab wrangler bin from remix-run/remix root node_modules since its not copied into integration project's node_modules
let wranglerBin = path.resolve("node_modules/wrangler/bin/wrangler.js");
let wranglerBin = require.resolve("wrangler/bin/wrangler.js", {
paths: [cwd],
});

let proc = spawn(
nodeBin,
Expand Down
156 changes: 156 additions & 0 deletions integration/vite-cloudflare-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";

import type { Files } from "./helpers/vite.js";
import { test, viteConfig } from "./helpers/vite.js";

const files: Files = async ({ port }) => ({
"vite.config.ts": `
import {
vitePlugin as reactRouter,
cloudflareDevProxyVitePlugin as reactRouterCloudflareDevProxy,
} from "@react-router/dev";
import { getLoadContext } from "./load-context";
export default {
${await viteConfig.server({ port })}
plugins: [
reactRouterCloudflareDevProxy({ getLoadContext }),
reactRouter(),
],
}
`,
"load-context.ts": `
import { type AppLoadContext } from "@react-router/cloudflare";
import { type PlatformProxy } from "wrangler";
type Env = {
MY_KV: KVNamespace;
}
type Cloudflare = Omit<PlatformProxy<Env>, 'dispose'>;
declare module "@react-router/cloudflare" {
interface AppLoadContext {
cloudflare: Cloudflare;
env2: Cloudflare["env"];
extra: string;
}
}
type GetLoadContext = (args: {
request: Request;
context: { cloudflare: Cloudflare };
}) => AppLoadContext;
export const getLoadContext: GetLoadContext = ({ context }) => {
return {
...context,
env2: context.cloudflare.env,
extra: "stuff",
};
};
`,
"functions/[[page]].ts": `
import { createPagesFunctionHandler } from "@react-router/cloudflare";
// @ts-ignore - the server build file is generated by \`react-router build\`
import * as build from "../build/server";
import { getLoadContext } from "../load-context";
export const onRequest = createPagesFunctionHandler({
build,
getLoadContext,
});
`,
"wrangler.toml": `
kv_namespaces = [
{ id = "abc123", binding="MY_KV" }
]
`,
"app/routes/_index.tsx": `
import {
type LoaderFunctionArgs,
type ActionFunctionArgs,
json,
Form,
useLoaderData,
} from "react-router";
const key = "__my-key__";
export async function loader({ context }: LoaderFunctionArgs) {
const { MY_KV } = context.cloudflare.env;
const value = await MY_KV.get(key);
return json({ value, extra: context.extra });
}
export async function action({ request, context }: ActionFunctionArgs) {
const { MY_KV } = context.env2;
if (request.method === "POST") {
const formData = await request.formData();
const value = formData.get("value") as string;
await MY_KV.put(key, value);
return null;
}
if (request.method === "DELETE") {
await MY_KV.delete(key);
return null;
}
throw new Error(\`Method not supported: "\${request.method}"\`);
}
export default function Index() {
const { value, extra } = useLoaderData<typeof loader>();
return (
<div>
<h1>Welcome to React Router + Cloudflare</h1>
<p data-extra>Extra: {extra}</p>
{value ? (
<>
<p data-text>Value: {value}</p>
<Form method="DELETE">
<button>Delete</button>
</Form>
</>
) : (
<>
<p data-text>No value</p>
<Form method="POST">
<label htmlFor="value">Set value:</label>
<input type="text" name="value" id="value" required />
<br />
<button>Save</button>
</Form>
</>
)}
</div>
);
}
`,
});

test("vite dev", async ({ page, dev }) => {
let { port } = await dev(files, "vite-cloudflare-template");
await workflow({ page, port });
});

test("wrangler pages dev", async ({ page, wranglerPagesDev }) => {
let { port } = await wranglerPagesDev(files);
await workflow({ page, port });
});

async function workflow({ page, port }: { page: Page; port: number }) {
await page.goto(`http://localhost:${port}/`, {
waitUntil: "networkidle",
});
await expect(page.locator("[data-extra]")).toHaveText("Extra: stuff");
await expect(page.locator("[data-text]")).toHaveText("No value");

await page.getByLabel("Set value:").fill("my-value");
await page.getByRole("button").click();
await expect(page.locator("[data-text]")).toHaveText("Value: my-value");
expect(page.errors).toEqual([]);
}
7 changes: 7 additions & 0 deletions packages/react-router-cloudflare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# React Router Cloudflare

Cloudflare platform abstractions for [React Router.](https://reactrouter.com)

```bash
npm install @react-router/cloudflare @cloudflare/workers-types
```
Loading

0 comments on commit 280d65c

Please sign in to comment.