-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Cloudflare support for Vite #8531
Changes from all commits
66a89ab
9f49258
a903006
b10321c
ba404f8
5e41f43
8793188
2a789a4
dadd18e
130f060
b383418
a18f939
5f10d7f
31c2d15
d2cf14f
673dc64
72954eb
028ac1e
66a2adc
eb9084b
4022791
60ca0ab
c30f22e
6a78dae
8d534fe
92b44b6
b24b14a
132fc21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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). |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be 'wrangler'? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typo There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh now I see it 😅 yep you're right |
||
``` | ||
|
||
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]. | ||
|
@@ -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. | ||
|
@@ -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. | ||
|
@@ -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. | ||
|
@@ -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 | ||
|
@@ -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 |
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([]); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😍