Skip to content

Commit

Permalink
Cloudflare support for Vite (#8531)
Browse files Browse the repository at this point in the history
Co-authored-by: Jacob Ebey <jacob.ebey@live.com>
Co-authored-by: Mark Dalgleish <mark.john.dalgleish@gmail.com>
  • Loading branch information
3 people authored Jan 25, 2024
1 parent 2f46b31 commit d74514a
Show file tree
Hide file tree
Showing 26 changed files with 1,234 additions and 130 deletions.
16 changes: 16 additions & 0 deletions .changeset/fluffy-dots-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@remix-run/cloudflare-pages": patch
"@remix-run/dev": patch
"@remix-run/server-runtime": patch
---

Vite: Cloudflare Pages support

To get started with Cloudflare, you can use the [`unstable-vite-cloudflare`][template-vite-cloudflare] template:

```shellscript nonumber
npx create-remix@latest --template remix-run/remix/templates/unstable-vite-cloudflare
```

Or read the new docs at [Future > Vite > Cloudflare](https://remix.run/docs/en/main/future/vite#cloudflare) and
[Future > Vite > Migrating > Migrating Cloudflare Functions](https://remix.run/docs/en/main/future/vite#migrating-cloudflare-functions).
161 changes: 151 additions & 10 deletions docs/future/vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@ title: Vite (Unstable)

[Vite][vite] is a powerful, performant and extensible development environment for JavaScript projects. In order to improve and extend Remix's bundling capabilities, we now support Vite as an alternative compiler. In the future, Vite will become the default compiler for Remix.

<docs-warning>Note that Cloudflare is not yet supported when using Vite.</docs-warning>

## Getting started

To get started with a minimal server, you can use the [`unstable-vite`][template-vite] template:
We've got a few different Vite-based templates to get you started.

```shellscript nonumber
# Minimal server:
npx create-remix@latest --template remix-run/remix/templates/unstable-vite
```

If you'd rather customize your server, you can use the [`unstable-vite-express`][template-vite-express] template:
```shellscript nonumber
# Express:
npx create-remix@latest --template remix-run/remix/templates/unstable-vite-express
# Cloudflare:
npx create-remix@latest --template remix-run/remix/templates/unstable-vite-cloudflare
```

These templates include a `vite.config.ts` file which is where the Remix Vite plugin is configured.
Expand Down Expand Up @@ -80,6 +79,62 @@ A function for assigning addressable routes to [server bundles][server-bundles].

You may also want to enable the `manifest` option since, when server bundles are enabled, it contains mappings between routes and server bundles.

## Cloudflare

To get started with Cloudflare, you can use the [`unstable-vite-cloudflare`][template-vite-cloudflare] template:

```shellscript nonumber
npx create-remix@latest --template remix-run/remix/templates/unstable-vite-cloudflare
```

#### Bindings

Bindings for Cloudflare resources can be configured [within `wrangler.toml` for local development][wrangler-toml-bindings] or within the [Cloudflare dashboard for deployments][cloudflare-pages-bindings].
Then, you can access your bindings via `context.env`.
For example, with a [KV namespace][cloudflare-kv] bound as `MY_KV`:

```ts filename=app/routes/_index.tsx
export async function loader({ context }) {
const { MY_KV } = context.env;
const value = await MY_KV.get("my-key");
return json({ value });
}
```

#### Vite & Wrangler

There are two ways to run your Cloudflare app locally:

```shellscript nonumber
# Vite
remix vite:dev
# Wrangler
remix vite:build # build app before running wrangler
wranger pages dev ./build/client
```

While Vite provides a better development experience, Wrangler provides closer emulation of the Cloudflare environment by running your server code in [Cloudflare's `workerd` runtime][cloudflare-workerd] instead of Node.
To simulate the Cloudflare environment in Vite, Wrangler provides [Node proxies for resource bindings][wrangler-getbindingsproxy] which are automatically available when using the Remix Cloudflare adapter:

```ts filename=vite.config.ts lines=[3,10]
import {
unstable_vitePlugin as remix,
unstable_vitePluginAdapterCloudflare as cloudflare,
} from "@remix-run/dev";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [
remix({
adapter: cloudflare(),
}),
],
});
```

<docs-info>Vite will not use your Cloudflare Pages Functions (`functions/*`) in development as those are purely for Wrangler routing.</docs-info>

## Splitting up client and server code

Remix lets you write code that [runs on both the client and the server][server-vs-client].
Expand Down Expand Up @@ -263,7 +318,7 @@ export default defineConfig({
});
```

#### Migrating from a custom server
#### Migrating a custom server

If you were using a custom server in development, you'll need to edit your custom server to use Vite's `connect` middleware.
This will delegate asset requests and initial render requests to Vite during development, letting you benefit from Vite's excellent DX even with a custom server.
Expand Down Expand Up @@ -349,6 +404,66 @@ node --loader tsm ./server.ts

Just remember that there might be some noticeable slowdown for initial server startup if you do this.

#### Migrating Cloudflare Functions

<docs-warning>

The Remix Vite plugin only officially supports [Cloudflare Pages][cloudflare-pages] which is specifically designed for fullstack applications, unlike [Cloudflare Workers Sites][cloudflare-workers-sites]. If you're currently on Cloudflare Workers Sites, refer to the [Cloudflare Pages migration guide][cloudflare-pages-migration-guide].

</docs-warning>

👉 **Add the Cloudflare adapter to your Vite config**

```ts filename=vite.config.ts lines=[3,10]
import {
unstable_vitePlugin as remix,
unstable_vitePluginAdapterCloudflare as cloudflare,
} from "@remix-run/dev";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [
remix({
adapter: cloudflare(),
}),
],
});
```

Your Cloudflare app may be setting the [the Remix Config `server` field][remix-config-server] to generate a catch-all Cloudflare Function.
With Vite, this indirection is no longer necessary.
Instead, you can author a catch-all route directly for Cloudflare, just like how you would for Express or any other custom servers.

👉 **Create a catch-all route for Remix**

```ts filename=functions/[[page]].ts
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";

// @ts-ignore - the server build file is generated by `remix vite:build`
import * as build from "../build/server";

export const onRequest = createPagesFunctionHandler({
build,
getLoadContext: (context) => ({ env: context.env }),
});
```

While you'll mostly use Vite during development, you can also use Wrangler to preview and deploy your app.
To learn more, see [_Cloudflare > Vite & Wrangler_](#vite--wrangler).

👉 **Update your `package.json` scripts**

```json filename=package.json lines=[3-6]
{
"scripts": {
"dev": "remix vite:dev",
"build": "remix vite:build",
"preview": "wrangler pages dev ./build/client",
"deploy": "wrangler pages deploy ./build/client"
}
}
```

#### Migrate references to build output paths

When using the existing Remix compiler's default options, the server was compiled into `build` and the client was compiled into `public/build`. Due to differences with the way Vite typically works with its `public` directory compared to the existing Remix compiler, these output paths have changed.
Expand Down Expand Up @@ -916,6 +1031,23 @@ export default function BoundaryRoute() {

You would then nest all other routes within this, e.g. `app/routes/about.tsx` would become `app/routes/_boundary.about.tsx`, etc.

#### Wrangler errors in development

When using Cloudflare Pages, you may encounter the following error from `wrangler pages dev`:

```txt nonumber
ERROR: Your worker called response.clone(), but did not read the body of both clones.
This is wasteful, as it forces the system to buffer the entire response body
in memory, rather than streaming it through. This may cause your worker to be
unexpectedly terminated for going over the memory limit. If you only meant to
copy the response headers and metadata (e.g. in order to be able to modify
them), use `new Response(response.body, response)` instead.
```

This is a [known issue with Wrangler][cloudflare-request-clone-errors].

</docs-info>

## Acknowledgements

Vite is an amazing project, and we're grateful to the Vite team for their work.
Expand All @@ -938,8 +1070,7 @@ We're definitely late to the Vite party, but we're excited to be here now!

[vite]: https://vitejs.dev
[supported-with-some-deprecations]: #add-mdx-plugin
[template-vite]: https://github.com/remix-run/remix/tree/main/templates/unstable-vite
[template-vite-express]: https://github.com/remix-run/remix/tree/main/templates/unstable-vite-express
[template-vite-cloudflare]: https://github.com/remix-run/remix/tree/main/templates/unstable-vite-cloudflare
[remix-config]: ../file-conventions/remix-config
[app-directory]: ../file-conventions/remix-config#appdirectory
[assets-build-directory]: ../file-conventions/remix-config#assetsbuilddirectory
Expand Down Expand Up @@ -1007,3 +1138,13 @@ We're definitely late to the Vite party, but we're excited to be here now!
[hydrate-fallback]: ../route/hydrate-fallback
[react-canaries]: https://react.dev/blog/2023/05/03/react-canaries
[package-overrides]: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#overrides
[wrangler-toml-bindings]: https://developers.cloudflare.com/workers/wrangler/configuration/#bindings
[cloudflare-pages]: https://pages.cloudflare.com
[cloudflare-workers-sites]: https://developers.cloudflare.com/workers/configuration/sites
[cloudflare-pages-migration-guide]: https://developers.cloudflare.com/pages/migrations/migrating-from-workers
[cloudflare-request-clone-errors]: https://github.com/cloudflare/workers-sdk/issues/3259
[cloudflare-pages-bindings]: https://developers.cloudflare.com/pages/functions/bindings/
[cloudflare-kv]: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces
[cloudflare-workerd]: https://blog.cloudflare.com/workerd-open-source-workers-runtime
[wrangler-getbindingsproxy]: https://github.com/cloudflare/workers-sdk/pull/4523
[remix-config-server]: https://remix.run/docs/en/main/file-conventions/remix-config#server
3 changes: 2 additions & 1 deletion integration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"type-fest": "^4.0.0",
"typescript": "^5.1.0",
"vite-env-only": "^2.0.0",
"vite-tsconfig-paths": "^4.2.2"
"vite-tsconfig-paths": "^4.2.2",
"wrangler": "^3.24.0"
}
}
153 changes: 153 additions & 0 deletions integration/vite-cloudflare-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { test, expect } from "@playwright/test";
import getPort from "get-port";

import { VITE_CONFIG, createProject, using, viteDev } from "./helpers/vite.js";

test.describe("Vite / cloudflare", async () => {
let port: number;
let cwd: string;

test.beforeAll(async () => {
port = await getPort();
cwd = await createProject({
"package.json": JSON.stringify(
{
private: true,
sideEffects: false,
type: "module",
scripts: {
dev: "remix vite:dev",
build: "remix vite:build",
start: "wrangler pages dev ./build/client",
deploy: "wrangler pages deploy ./build/client",
typecheck: "tsc",
},
dependencies: {
"@remix-run/cloudflare": "*",
"@remix-run/cloudflare-pages": "*",
"@remix-run/react": "*",
isbot: "^4.1.0",
miniflare: "^3.20231030.4",
react: "^18.2.0",
"react-dom": "^18.2.0",
},
devDependencies: {
"@cloudflare/workers-types": "^4.20230518.0",
"@remix-run/dev": "*",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"node-fetch": "^3.3.2",
typescript: "^5.1.6",
vite: "^5.0.0",
"vite-tsconfig-paths": "^4.2.1",
wrangler: "^3.24.0",
},
engines: {
node: ">=18.0.0",
},
},
null,
2
),
"vite.config.ts": await VITE_CONFIG({
port,
pluginOptions: `{ adapter: (await import("@remix-run/dev")).unstable_vitePluginAdapterCloudflare() }`,
}),
"functions/[[page]].ts": `
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
// @ts-ignore - the server build file is generated by \`remix vite:build\`
import * as build from "../build/server";
export const onRequest = createPagesFunctionHandler({
build,
getLoadContext: (context) => ({ env: context.env }),
});
`,
"wrangler.toml": `
kv_namespaces = [
{ id = "abc123", binding="MY_KV" }
]
`,
"app/routes/_index.tsx": `
import {
json,
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "@remix-run/cloudflare";
import { Form, useLoaderData } from "@remix-run/react";
const key = "__my-key__";
export async function loader({ context }: LoaderFunctionArgs) {
const { MY_KV } = context.env;
const value = await MY_KV.get(key);
return json({ value });
}
export async function action({ request, context }: ActionFunctionArgs) {
const { MY_KV: myKv } = context.env;
if (request.method === "POST") {
const formData = await request.formData();
const value = formData.get("value") as string;
await myKv.put(key, value);
return null;
}
if (request.method === "DELETE") {
await myKv.delete(key);
return null;
}
throw new Error(\`Method not supported: "\${request.method}"\`);
}
export default function Index() {
const { value } = useLoaderData<typeof loader>();
return (
<div>
<h1>Welcome to Remix</h1>
{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 }) => {
await using(await viteDev({ cwd, port }), async () => {
let pageErrors: Error[] = [];
page.on("pageerror", (error) => pageErrors.push(error));

await page.goto(`http://localhost:${port}/`, {
waitUntil: "networkidle",
});
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(pageErrors).toEqual([]);
});
});
});
Loading

0 comments on commit d74514a

Please sign in to comment.