diff --git a/integration/package.json b/integration/package.json index 9c7654c804..50d6b69d70 100644 --- a/integration/package.json +++ b/integration/package.json @@ -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" } } diff --git a/integration/vite-cloudflare-test.ts b/integration/vite-cloudflare-test.ts new file mode 100644 index 0000000000..deff424124 --- /dev/null +++ b/integration/vite-cloudflare-test.ts @@ -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(); + return ( +
+

Welcome to Remix

+ {value ? ( + <> +

Value: {value}

+
+ +
+ + ) : ( + <> +

No value

+
+ + +
+ +
+ + )} +
+ ); + } + `, + }); + }); + + 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([]); + }); + }); +}); diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index bbbbcbd41f..db014d6db6 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -10,4 +10,7 @@ export type { Unstable_BuildManifest, Unstable_VitePluginAdapter, } from "./vite"; -export { unstable_vitePlugin } from "./vite"; +export { + unstable_vitePlugin, + unstable_vitePluginAdapterCloudflare, +} from "./vite"; diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index beb0f6b15f..960d424109 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -91,12 +91,14 @@ "msw": "^1.2.3", "strip-ansi": "^6.0.1", "tiny-invariant": "^1.2.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "wrangler": "^3.24.0" }, "peerDependencies": { "@remix-run/serve": "^2.5.1", "typescript": "^5.1.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "wrangler": "^3.24.0" }, "peerDependenciesMeta": { "@remix-run/serve": { @@ -107,6 +109,9 @@ }, "vite": { "optional": true + }, + "wrangler": { + "optional": true } }, "engines": { diff --git a/packages/remix-dev/vite/adapters/cloudflare.ts b/packages/remix-dev/vite/adapters/cloudflare.ts new file mode 100644 index 0000000000..88983bf471 --- /dev/null +++ b/packages/remix-dev/vite/adapters/cloudflare.ts @@ -0,0 +1,13 @@ +export const adapter = () => async () => { + let { getBindingsProxy } = await import("wrangler"); + let { bindings } = await getBindingsProxy(); + let loadContext = bindings && { env: bindings }; + let viteConfig = { + ssr: { + resolve: { + externalConditions: ["workerd", "worker"], + }, + }, + }; + return { viteConfig, loadContext }; +}; diff --git a/packages/remix-dev/vite/index.ts b/packages/remix-dev/vite/index.ts index 2cc7075c14..332ab2816b 100644 --- a/packages/remix-dev/vite/index.ts +++ b/packages/remix-dev/vite/index.ts @@ -12,3 +12,5 @@ export const unstable_vitePlugin: RemixVitePlugin = (...args) => { let { remixVitePlugin } = require("./plugin") as typeof import("./plugin"); return remixVitePlugin(...args); }; + +export { adapter as unstable_vitePluginAdapterCloudflare } from "./adapters/cloudflare"; diff --git a/packages/remix-dev/vite/node-adapter.ts b/packages/remix-dev/vite/node-adapter.ts new file mode 100644 index 0000000000..8abd0422b0 --- /dev/null +++ b/packages/remix-dev/vite/node-adapter.ts @@ -0,0 +1,85 @@ +import type { + IncomingHttpHeaders, + IncomingMessage, + ServerResponse, +} from "node:http"; +import { once } from "node:events"; +import { Readable } from "node:stream"; +import { splitCookiesString } from "set-cookie-parser"; +import { createReadableStreamFromReadable } from "@remix-run/node"; + +import invariant from "../invariant"; + +export type NodeRequestHandler = ( + req: IncomingMessage, + res: ServerResponse +) => Promise; + +function fromNodeHeaders(nodeHeaders: IncomingHttpHeaders): Headers { + let headers = new Headers(); + + for (let [key, values] of Object.entries(nodeHeaders)) { + if (values) { + if (Array.isArray(values)) { + for (let value of values) { + headers.append(key, value); + } + } else { + headers.set(key, values); + } + } + } + + return headers; +} + +// Based on `createRemixRequest` in packages/remix-express/server.ts +export function fromNodeRequest(nodeReq: IncomingMessage): Request { + let origin = + nodeReq.headers.origin && "null" !== nodeReq.headers.origin + ? nodeReq.headers.origin + : `http://${nodeReq.headers.host}`; + invariant(nodeReq.url, 'Expected "req.url" to be defined'); + let url = new URL(nodeReq.url, origin); + + let init: RequestInit = { + method: nodeReq.method, + headers: fromNodeHeaders(nodeReq.headers), + }; + + if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") { + init.body = createReadableStreamFromReadable(nodeReq); + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); +} + +// Adapted from solid-start's `handleNodeResponse`: +// https://github.com/solidjs/solid-start/blob/7398163869b489cce503c167e284891cf51a6613/packages/start/node/fetch.js#L162-L185 +export async function toNodeRequest(res: Response, nodeRes: ServerResponse) { + nodeRes.statusCode = res.status; + nodeRes.statusMessage = res.statusText; + + let cookiesStrings = []; + + for (let [name, value] of res.headers) { + if (name === "set-cookie") { + cookiesStrings.push(...splitCookiesString(value)); + } else nodeRes.setHeader(name, value); + } + + if (cookiesStrings.length) { + nodeRes.setHeader("set-cookie", cookiesStrings); + } + + if (res.body) { + // https://github.com/microsoft/TypeScript/issues/29867 + let responseBody = res.body as unknown as AsyncIterable; + let readable = Readable.from(responseBody); + readable.pipe(nodeRes); + await once(readable, "end"); + } else { + nodeRes.end(); + } +} diff --git a/packages/remix-dev/vite/node/adapter.ts b/packages/remix-dev/vite/node/adapter.ts deleted file mode 100644 index 204b649c28..0000000000 --- a/packages/remix-dev/vite/node/adapter.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { - IncomingHttpHeaders, - IncomingMessage, - ServerResponse, -} from "node:http"; -import { once } from "node:events"; -import { Readable } from "node:stream"; -import { splitCookiesString } from "set-cookie-parser"; -import { - type ServerBuild, - createReadableStreamFromReadable, -} from "@remix-run/node"; -import { createRequestHandler as createBaseRequestHandler } from "@remix-run/server-runtime"; - -import invariant from "../../invariant"; - -function createHeaders(requestHeaders: IncomingHttpHeaders) { - let headers = new Headers(); - - for (let [key, values] of Object.entries(requestHeaders)) { - if (values) { - if (Array.isArray(values)) { - for (let value of values) { - headers.append(key, value); - } - } else { - headers.set(key, values); - } - } - } - - return headers; -} - -// Based on `createRemixRequest` in packages/remix-express/server.ts -function createRequest(req: IncomingMessage, res: ServerResponse): Request { - let origin = - req.headers.origin && "null" !== req.headers.origin - ? req.headers.origin - : `http://${req.headers.host}`; - invariant(req.url, 'Expected "req.url" to be defined'); - let url = new URL(req.url, origin); - - let init: RequestInit = { - method: req.method, - headers: createHeaders(req.headers), - }; - - if (req.method !== "GET" && req.method !== "HEAD") { - init.body = createReadableStreamFromReadable(req); - (init as { duplex: "half" }).duplex = "half"; - } - - return new Request(url.href, init); -} - -// Adapted from solid-start's `handleNodeResponse`: -// https://github.com/solidjs/solid-start/blob/7398163869b489cce503c167e284891cf51a6613/packages/start/node/fetch.js#L162-L185 -async function handleNodeResponse(webRes: Response, res: ServerResponse) { - res.statusCode = webRes.status; - res.statusMessage = webRes.statusText; - - let cookiesStrings = []; - - for (let [name, value] of webRes.headers) { - if (name === "set-cookie") { - cookiesStrings.push(...splitCookiesString(value)); - } else res.setHeader(name, value); - } - - if (cookiesStrings.length) { - res.setHeader("set-cookie", cookiesStrings); - } - - if (webRes.body) { - // https://github.com/microsoft/TypeScript/issues/29867 - let responseBody = webRes.body as unknown as AsyncIterable; - let readable = Readable.from(responseBody); - readable.pipe(res); - await once(readable, "end"); - } else { - res.end(); - } -} - -export let createRequestHandler = ( - build: ServerBuild, - { mode = "production" }: { mode?: string } -) => { - let handler = createBaseRequestHandler(build, mode); - return async (req: IncomingMessage, res: ServerResponse) => { - let request = createRequest(req, res); - let response = await handler(request, {}); - handleNodeResponse(response, res); - }; -}; diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 6cf2e561c9..8558b1c78f 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -9,6 +9,7 @@ import babel from "@babel/core"; import { type ServerBuild, unstable_setDevServerHooks as setDevServerHooks, + createRequestHandler, } from "@remix-run/server-runtime"; import { init as initEsModuleLexer, @@ -27,7 +28,11 @@ import { } from "../config"; import { type Manifest as BrowserManifest } from "../manifest"; import invariant from "../invariant"; -import { createRequestHandler } from "./node/adapter"; +import { + type NodeRequestHandler, + fromNodeRequest, + toNodeRequest, +} from "./node-adapter"; import { getStylesForUrl, isCssModulesFile } from "./styles"; import * as VirtualModule from "./vmod"; import { resolveFileUrl } from "./resolve-file-url"; @@ -124,13 +129,16 @@ type AdapterRemixConfigOverrides = Pick< >; type AdapterConfig = AdapterRemixConfigOverrides & { + loadContext?: Record; buildEnd?: BuildEndHook; + viteConfig: Vite.UserConfig; }; type Adapter = Omit; export type VitePluginAdapter = (args: { remixConfig: VitePluginConfig; + viteConfig: Vite.UserConfig; }) => AdapterConfig | Promise; export type VitePluginConfig = RemixEsbuildUserConfigJsdocOverrides & @@ -454,6 +462,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { // We only pass in the plugin config that the user defined. We don't // know the final resolved config until the adapter has been resolved. remixConfig: remixUserConfig, + viteConfig: viteUserConfig, }) : undefined; let adapter: Adapter | undefined = @@ -738,7 +747,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { ) ); - return { + let defaults = { __remixPluginContext: ctx, appType: "custom", experimental: { hmrPartialAccept: true }, @@ -784,13 +793,11 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { ...(viteCommand === "build" && { base: ctx.remixConfig.publicPath, build: { - ...viteUserConfig.build, ...(!viteConfigEnv.isSsrBuild ? { manifest: true, outDir: getClientBuildDirectory(ctx.remixConfig), rollupOptions: { - ...viteUserConfig.build?.rollupOptions, preserveEntrySignatures: "exports-only", input: [ ctx.entryClientFilePath, @@ -815,7 +822,6 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { manifest: true, // We need the manifest to detect SSR-only assets outDir: getServerBuildDirectory(ctx), rollupOptions: { - ...viteUserConfig.build?.rollupOptions, preserveEntrySignatures: "exports-only", input: serverBuildId, output: { @@ -827,6 +833,10 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { }, }), }; + return vite.mergeConfig( + defaults, + ctx.remixConfig.adapter?.viteConfig ?? {} + ); }, async configResolved(resolvedViteConfig) { await initEsModuleLexer; @@ -944,7 +954,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { ); } }, - configureServer(viteDevServer) { + async configureServer(viteDevServer) { setDevServerHooks({ // Give the request handler access to the critical CSS in dev to avoid a // flash of unstyled content since Vite injects CSS file contents via JS @@ -1004,11 +1014,17 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { serverBuildId )) as ServerBuild; - let handle = createRequestHandler(build, { - mode: "development", - }); - - await handle(req, res); + let handler = createRequestHandler(build, "development"); + let nodeHandler: NodeRequestHandler = async ( + nodeReq, + nodeRes + ) => { + let req = fromNodeRequest(nodeReq); + let { adapter } = ctx.remixConfig; + let res = await handler(req, adapter?.loadContext); + await toNodeRequest(res, nodeRes); + }; + await nodeHandler(req, res); } catch (error) { next(error); } diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index e205828dc0..a477d3db58 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -36,7 +36,7 @@ export type RequestHandler = ( ) => Promise; export type CreateRequestHandlerFunction = ( - build: ServerBuild | (() => Promise), + build: ServerBuild | (() => ServerBuild | Promise), mode?: string ) => RequestHandler; @@ -81,6 +81,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( return async function requestHandler(request, loadContext = {}) { _build = typeof build === "function" ? await build() : build; + mode ??= _build.mode; if (typeof build === "function") { let derived = derive(_build, mode); routes = derived.routes;