diff --git a/integration/helpers/vite-deno-template/.gitignore b/integration/helpers/vite-deno-template/.gitignore new file mode 100644 index 00000000000..25f125d1358 --- /dev/null +++ b/integration/helpers/vite-deno-template/.gitignore @@ -0,0 +1,4 @@ +node_modules +/app/deno.d.ts +/build +/package.json diff --git a/integration/helpers/vite-deno-template/app/root.tsx b/integration/helpers/vite-deno-template/app/root.tsx new file mode 100644 index 00000000000..e31409ca31d --- /dev/null +++ b/integration/helpers/vite-deno-template/app/root.tsx @@ -0,0 +1,25 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/vite-deno-template/app/routes/_index.tsx b/integration/helpers/vite-deno-template/app/routes/_index.tsx new file mode 100644 index 00000000000..4b80a32a373 --- /dev/null +++ b/integration/helpers/vite-deno-template/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/react"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( +
+

Welcome to Remix

+ +
+ ); +} diff --git a/integration/helpers/vite-deno-template/deno.jsonc b/integration/helpers/vite-deno-template/deno.jsonc new file mode 100644 index 00000000000..0e9265e7307 --- /dev/null +++ b/integration/helpers/vite-deno-template/deno.jsonc @@ -0,0 +1,28 @@ +{ + "tasks": { + "build": "deno run -A npm:@remix-run/dev@^2.11.2 vite:build", + "dev": "deno run -A npm:@remix-run/dev@^2.11.2 vite:dev", + "typecheck": "deno check '**/*' && deno run -A npm:typescript@^5.5.4/tsc", + "typegen": "deno types > ./app/deno.d.ts" + }, + "exclude": ["app/", "build/"], + "nodeModulesDir": true, + "imports": { + "@remix-run/dev": "npm:@remix-run/dev@^2.11.2", + "@remix-run/express": "npm:@remix-run/express@^2.11.2", + "@remix-run/react": "npm:@remix-run/react@^2.11.2", + "@remix-run/server-runtime": "npm:@remix-run/server-runtime@^2.11.2", + "@std/http": "jsr:@std/http@^1.0.4", + "@std/path": "jsr:@std/path@^1.0.3", + "@types/node": "npm:@types/node@^22.5.1", + "@types/react": "npm:@types/react@^18.3.5", + "@types/react-dom": "npm:@types/react-dom@^18.3.0", + "isbot": "npm:isbot@^5.1.17", + "postcss": "npm:postcss@^8.4.41", + "react": "npm:react@^18.3.1", + "react-dom": "npm:react-dom@^18.3.1", + "typescript": "npm:typescript@^5.5.4", + "vite": "npm:vite@^5.4.2", + "vite-tsconfig-paths": "npm:vite-tsconfig-paths@^5.0.1" + } +} diff --git a/integration/helpers/vite-deno-template/public/favicon.ico b/integration/helpers/vite-deno-template/public/favicon.ico new file mode 100644 index 00000000000..8830cf6821b Binary files /dev/null and b/integration/helpers/vite-deno-template/public/favicon.ico differ diff --git a/integration/helpers/vite-deno-template/tsconfig.json b/integration/helpers/vite-deno-template/tsconfig.json new file mode 100644 index 00000000000..96c32abe645 --- /dev/null +++ b/integration/helpers/vite-deno-template/tsconfig.json @@ -0,0 +1,32 @@ +{ + "include": [ + "app/**/*.ts", + "app/**/*.tsx", + "app/**/.server/**/*.ts", + "app/**/.server/**/*.tsx", + "app/**/.client/**/*.ts", + "app/**/.client/**/*.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": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Vite takes care of building everything, not tsc. + "noEmit": true + } +} diff --git a/integration/helpers/vite-deno-template/vite.config.mts b/integration/helpers/vite-deno-template/vite.config.mts new file mode 100644 index 00000000000..54066fb7ad8 --- /dev/null +++ b/integration/helpers/vite-deno-template/vite.config.mts @@ -0,0 +1,16 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + remix({ + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), + tsconfigPaths(), + ], +}); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 65c805288ff..1cb61291259 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -1,18 +1,19 @@ +import type { Page } from "@playwright/test"; +import { test as base, expect } from "@playwright/test"; +import dedent from "dedent"; +import fse from "fs-extra"; +import getPort from "get-port"; +import glob from "glob"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import path from "node:path"; import fs from "node:fs/promises"; +import path from "node:path"; import type { Readable } from "node:stream"; import url from "node:url"; -import fse from "fs-extra"; +import shell from "shelljs"; import stripIndent from "strip-indent"; import waitOn from "wait-on"; -import getPort from "get-port"; -import shell from "shelljs"; -import glob from "glob"; -import dedent from "dedent"; -import type { Page } from "@playwright/test"; -import { test as base, expect } from "@playwright/test"; +const denoBin = "deno"; // assume deno is globally installed const remixBin = "node_modules/@remix-run/dev/dist/cli.js"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const root = path.resolve(__dirname, "../.."); @@ -90,7 +91,10 @@ export const EXPRESS_SERVER = (args: { app.listen(port, () => console.log('http://localhost:' + port)); `; -type TemplateName = "vite-template" | "vite-cloudflare-template"; +type TemplateName = + | "vite-template" + | "vite-cloudflare-template" + | "vite-deno-template"; export async function createProject( files: Record = {}, @@ -143,6 +147,23 @@ export const viteBuild = ({ }); }; +export const viteBuildDeno = ({ + cwd, + env = {}, +}: { + cwd: string; + env?: Record; +}) => { + return spawnSync(denoBin, ["run", "-A", remixBin, "vite:build"], { + cwd, + env: { + ...process.env, + ...colorEnv, + ...env, + }, + }); +}; + export const viteRemixServe = async ({ cwd, port, @@ -204,14 +225,16 @@ type ServerArgs = { }; const createDev = - (nodeArgs: string[]) => + (args: string[], runtime = node) => async ({ cwd, port, env, basename }: ServerArgs): Promise<() => unknown> => { - let proc = node(nodeArgs, { cwd, env }); + let proc = runtime(args, { cwd, env }); await waitForServer(proc, { port, basename }); return () => proc.kill(); }; export const viteDev = createDev([remixBin, "vite:dev"]); +export const viteDevDeno = createDev(["run", "-A", remixBin, "vite:dev"], deno); + export const customDev = createDev(["./server.mjs"]); // Used for testing errors thrown on build when we don't want to start and @@ -240,6 +263,13 @@ type Fixtures = { port: number; cwd: string; }>; + viteDevDeno: ( + files: Files, + templateName?: TemplateName + ) => Promise<{ + port: number; + cwd: string; + }>; customDev: (files: Files) => Promise<{ port: number; cwd: string; @@ -272,6 +302,17 @@ export const test = base.extend({ stop?.(); }, // eslint-disable-next-line no-empty-pattern + viteDevDeno: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files, template) => { + let port = await getPort(); + let cwd = await createProject(await files({ port }), template); + stop = await viteDevDeno({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, + // eslint-disable-next-line no-empty-pattern customDev: async ({}, use) => { let stop: (() => unknown) | undefined; await use(async (files) => { @@ -331,6 +372,22 @@ function node( return proc; } +function deno( + args: string[], + options: { cwd: string; env?: Record } +) { + let proc = spawn(denoBin, args, { + cwd: options.cwd, + env: { + ...process.env, + ...colorEnv, + ...options.env, + }, + stdio: "pipe", + }); + return proc; +} + async function waitForServer( proc: ChildProcess & { stdout: Readable; stderr: Readable }, args: { port: number; basename?: string } diff --git a/integration/vite-deno-test.ts b/integration/vite-deno-test.ts new file mode 100644 index 00000000000..694dbf5eae9 --- /dev/null +++ b/integration/vite-deno-test.ts @@ -0,0 +1,90 @@ +import { expect } from "@playwright/test"; +import dedent from "dedent"; + +import type { Files } from "./helpers/vite.js"; +import { test, viteConfig } from "./helpers/vite.js"; + +const files: Files = async ({ port }) => ({ + "vite.config.ts": dedent` + export default { + ${await viteConfig.server({ port })} + plugins: [ + remix(), + ], + } + `, + "app/routes/_index.tsx": ` + import { Form, useLoaderData } from "@remix-run/react"; + import { + json, + type ActionFunctionArgs, + type LoaderFunctionArgs, + } from "@remix-run/server-runtime"; + + const key = "__my-key__"; + + export async function loader({ context }: LoaderFunctionArgs) { + const MY_KV = await Deno.openKv("test"); + const { value } = await MY_KV.get([key]); + return json({ value, extra: context.extra }); + } + + export async function action({ request }: ActionFunctionArgs) { + const MY_KV = await Deno.openKv("test"); + + if (request.method === "POST") { + const formData = await request.formData(); + const value = formData.get("value") as string; + await MY_KV.set([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 } = useLoaderData(); + return ( +
+

Welcome to Remix

+ {value ? ( + <> +

Value: {value}

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

No value

+
+ + +
+ +
+ + )} +
+ ); + } + `, +}); + +test("vite dev", async ({ page, viteDevDeno }) => { + let { port } = await viteDevDeno(files, "vite-deno-template"); + 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(page.errors).toEqual([]); +}); diff --git a/templates/deno/README.md b/templates/deno/README.md index 0452077eab8..f4832579d4c 100644 --- a/templates/deno/README.md +++ b/templates/deno/README.md @@ -40,9 +40,6 @@ deno task dev This starts your app in development mode, rebuilding assets on file changes. -The server used for development is located in `server/development.ts`. It is an -express server running Vite in middleware mode. - ## Production First, build your app for production: @@ -57,7 +54,7 @@ Then run the app in production mode: deno task start ``` -The server used for production is located in `server/production.ts`. It is +The server used for production is located in `server.production.ts`. It is served by `deno serve` for maximum performance. ## Deployment diff --git a/templates/deno/deno.jsonc b/templates/deno/deno.jsonc index ce0d6f55ab1..ee0008938d2 100644 --- a/templates/deno/deno.jsonc +++ b/templates/deno/deno.jsonc @@ -2,7 +2,7 @@ "tasks": { "build": "deno run -A npm:@remix-run/dev@^2.11.2 vite:build", "deploy": "deployctl deploy --prod --include=deno.jsonc,deno.lock,build,server --project= ./server/production.ts", - "dev": "deno run -A --watch --watch-exclude='./vite.config.*.timestamp-*.mjs' ./server/development.ts", + "dev": "deno run -A npm:@remix-run/dev@^2.11.2 vite:dev", "lint": "deno lint && deno run -A npm:eslint@^8.57.0 --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "start": "deno serve -A --parallel ./server/production.ts", "typecheck": "deno check '**/*' && deno run -A npm:typescript@^5.5.4/tsc", diff --git a/templates/deno/server/production.ts b/templates/deno/server.production.ts similarity index 90% rename from templates/deno/server/production.ts rename to templates/deno/server.production.ts index af5f70bf36d..131a28433d3 100644 --- a/templates/deno/server/production.ts +++ b/templates/deno/server.production.ts @@ -3,8 +3,8 @@ import { serveFile } from "@std/http/file-server"; import { join } from "@std/path/join"; const handleRequest = createRequestHandler( - await import("../build/server/index.js"), - "production", + await import("./build/server/index.js"), + "production" ); export default { @@ -24,7 +24,7 @@ export default { if (pathname.startsWith("/assets/")) { response.headers.set( "cache-control", - "public, max-age=31536000, immutable", + "public, max-age=31536000, immutable" ); } else { response.headers.set("cache-control", "public, max-age=600"); diff --git a/templates/deno/server/development.ts b/templates/deno/server/development.ts deleted file mode 100644 index edbf82364da..00000000000 --- a/templates/deno/server/development.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createRequestHandler } from "@remix-run/express"; -import type { ServerBuild } from "@remix-run/server-runtime"; -import express from "express"; -import { createServer } from "vite"; - -const PORT = Number(Deno.env.get("PORT")) || 8000; - -const app = express(); -const viteDevServer = await createServer({ - server: { middlewareMode: true }, -}); -app.use(viteDevServer.middlewares); -app.use(express.static("build/client", { maxAge: "1h" })); - -app.all( - "*", - createRequestHandler({ - build: () => - viteDevServer.ssrLoadModule( - "virtual:remix/server-build", - ) as Promise, - mode: "development", - }), -); - -app.listen(PORT, () => console.log(`👉 http://localhost:${PORT}`));