Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/tools/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ async fn compile_inner(
} else {
bail!(
"Could not detect a supported framework in '{}'.\n\
Supported frameworks: Next.js, Astro, Fresh, Remix, SvelteKit, Nuxt, SolidStart, TanStack Start, Vite\n\
Supported frameworks: Next.js, Astro, Fresh, Remix, React Router, SvelteKit, Nuxt, SolidStart, TanStack Start, Vite\n\
Provide an explicit entrypoint instead of a directory.",
dir.display()
);
Expand Down
2 changes: 1 addition & 1 deletion cli/tools/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ async fn compile_desktop(
Some(entrypoint_temp)
} else {
bail!(
"Could not detect a supported framework in the current directory.\nSupported frameworks: Next.js, Astro, Fresh, Remix, SvelteKit, Nuxt, SolidStart, TanStack Start, Vite\nProvide an explicit entrypoint instead."
"Could not detect a supported framework in the current directory.\nSupported frameworks: Next.js, Astro, Fresh, Remix, React Router, SvelteKit, Nuxt, SolidStart, TanStack Start, Vite\nProvide an explicit entrypoint instead."
);
}
} else {
Expand Down
299 changes: 296 additions & 3 deletions cli/tools/framework.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

//! Framework detection for `deno compile .`.
//!
//! Detects web frameworks (Next.js, Astro, Remix, SvelteKit, Nuxt, Fresh,
//! SolidStart, TanStack Start, Vite) and generates the appropriate
//! entrypoint and include paths so that `deno compile .` just works.
//! Detects web frameworks (Next.js, Astro, Remix, React Router, SvelteKit,
//! Nuxt, Fresh, SolidStart, TanStack Start, Vite) and generates the
//! appropriate entrypoint and include paths so that `deno compile .` just
//! works.

use std::path::Path;
use std::path::PathBuf;
Expand All @@ -13,6 +14,7 @@ use deno_core::error::AnyError;
use deno_core::serde_json;

/// Result of framework detection.
#[derive(Debug)]
pub struct FrameworkDetection {
/// Name of the detected framework (for display).
pub name: &'static str,
Expand Down Expand Up @@ -122,6 +124,15 @@ pub fn detect_framework(
return Ok(Some(detect_remix(dir)));
}

// React Router (framework mode) - the successor to Remix. It uses the
// `@react-router/dev` build tool rather than the old `@remix-run/*`
// packages, so without this branch it would fall through to generic Vite
// detection (which looks for `dist/` and misses React Router's
// `build/client` output).
if deps.has("@react-router/dev") || deps.has_dev("@react-router/dev") {
return Ok(Some(detect_react_router(dir)));
}

// SolidStart
if deps.has("@solidjs/start") {
return Ok(Some(detect_nitro_framework(dir, "SolidStart")));
Expand Down Expand Up @@ -269,6 +280,140 @@ fn detect_remix(dir: &Path) -> FrameworkDetection {
}
}

fn detect_react_router(dir: &Path) -> FrameworkDetection {
// React Router framework mode builds in one of two shapes:
// * SPA (`ssr: false`): a fully static site in `build/client` with a
// prerendered `index.html` fallback and no server to run.
// * SSR (default): a server build at `build/server/index.js`. This works in
// Deno when the app provides a Web Streams `app/entry.server.tsx` using
// `renderToReadableStream`; the default React Router Node entry uses
// `renderToPipeableStream` and fails during `react-router build` under
// Deno before this generated entrypoint is compiled.
// Detection runs *before* the build, so decide from the config file.
let spa_mode = [
"react-router.config.ts",
"react-router.config.js",
"react-router.config.mjs",
]
.iter()
.find_map(|f| std::fs::read_to_string(dir.join(f)).ok())
.map(|text| {
// Strip comments first so a commented-out `// ssr: false` can't be mistaken
// for the real setting, then match whitespace-insensitively so `ssr: false`
// and `ssr:false` both count.
strip_comments(&text)
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.contains("ssr:false")
})
.unwrap_or(false);

if spa_mode {
// SPA: serve the static client build, falling back to `index.html` so the
// client-side router survives a hard refresh on a history-API route.
FrameworkDetection {
name: "React Router",
entrypoint_code: r#"// @ts-nocheck
import { serveDir } from "jsr:@std/http/file-server";
// React Router SPA mode emits a static site into `build/client`. Resolve it
// against the VFS in the compiled binary via import.meta.dirname rather than
// the runtime CWD.
const fsRoot = import.meta.dirname + "/build/client";
Deno.serve(async (req) => {
const res = await serveDir(req, { fsRoot, quiet: true });
// SPA fallback: route unmatched HTML navigations back to index.html so
// client-side routers keep working after a hard refresh.
if (
res.status === 404 &&
req.method === "GET" &&
(req.headers.get("accept") ?? "").includes("text/html")
) {
const index = new Request(new URL("/index.html", req.url), {
headers: req.headers,
});
return await serveDir(index, { fsRoot, quiet: true });
}
return res;
});
"#
.into(),
include_paths: vec!["build/client".into()],
build_command: Some(deno_task_build()),
}
} else {
// SSR: serve immutable client assets from `build/client`, then hand all
// application routes, data requests, actions, and misses to React Router's
// Fetch API request handler from `build/server/index.js`.
FrameworkDetection {
name: "React Router",
entrypoint_code: r#"// @ts-nocheck
import { serveDir } from "jsr:@std/http/file-server";
import { createRequestHandler } from "react-router";
import * as build from "./build/server/index.js";

// React Router emits client assets into `build/client` and its server build
// into `build/server`. Resolve both against the VFS in the compiled binary via
// import.meta.dirname rather than the runtime CWD.
const fsRoot = import.meta.dirname + "/build/client";
const handler = createRequestHandler(build, "production");

Deno.serve(async (req) => {
if (req.method === "GET" || req.method === "HEAD") {
const staticRes = await serveDir(req, { fsRoot, quiet: true });
if (staticRes.status !== 404) {
return staticRes;
}
}
return await handler(req);
});
"#
.into(),
include_paths: vec!["build".into()],
build_command: Some(deno_task_build()),
}
}
}

/// Remove `//` line comments and `/* */` block comments from config source, so
/// a commented-out setting (e.g. `// ssr: false`) isn't mistaken for the real
/// one by the substring heuristics above.
fn strip_comments(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
while let Some(c) = chars.next() {
if c == '/' {
match chars.peek() {
Some('/') => {
// Line comment: drop until end of line.
for c2 in chars.by_ref() {
if c2 == '\n' {
out.push('\n');
break;
}
}
continue;
}
Some('*') => {
// Block comment: drop until the closing `*/`.
chars.next();
let mut prev = '\0';
for c2 in chars.by_ref() {
if prev == '*' && c2 == '/' {
break;
}
prev = c2;
}
continue;
}
_ => {}
}
}
out.push(c);
}
out
}

fn detect_nuxt(dir: &Path) -> FrameworkDetection {
detect_nitro_framework(dir, "Nuxt")
}
Expand Down Expand Up @@ -849,6 +994,154 @@ mod tests {
assert_eq!(det.name, "TanStack Start");
}

// --- React Router ---

#[test]
fn detects_react_router_spa() {
let dir = setup_dir();
fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
)
.unwrap();
fs::write(dir.path().join("vite.config.ts"), "").unwrap();
fs::write(
dir.path().join("react-router.config.ts"),
"export default { ssr: false };\n",
)
.unwrap();
let det = detect_framework(dir.path()).unwrap().unwrap();
assert_eq!(det.name, "React Router");
// SPA serves the static client build, not `dist/`.
assert!(det.entrypoint_code.contains("serveDir"));
assert!(det.entrypoint_code.contains("build/client"));
assert!(!det.entrypoint_code.contains("createRequestHandler"));
assert_eq!(det.include_paths, vec!["build/client"]);
let cmd = det.build_command.unwrap();
assert_eq!(cmd[1..], vec!["task", "build"]);
}

#[test]
fn detects_react_router_spa_whitespace_insensitive() {
// The `ssr: false` heuristic strips whitespace, so `ssr:false` (no space)
// must still be recognized as SPA.
let dir = setup_dir();
fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
)
.unwrap();
fs::write(dir.path().join("vite.config.ts"), "").unwrap();
fs::write(
dir.path().join("react-router.config.ts"),
"export default {\n ssr:false,\n};\n",
)
.unwrap();
let det = detect_framework(dir.path()).unwrap().unwrap();
assert_eq!(det.name, "React Router");
assert_eq!(det.include_paths, vec!["build/client"]);
}

#[test]
fn detects_react_router_ssr_config() {
// SSR (the React Router default) serves static client assets first, then
// hands application requests to the server build's Fetch API handler.
let dir = setup_dir();
fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
)
.unwrap();
fs::write(dir.path().join("vite.config.ts"), "").unwrap();
fs::write(
dir.path().join("react-router.config.ts"),
"export default {};\n",
)
.unwrap();
let det = detect_framework(dir.path()).unwrap().unwrap();
assert_eq!(det.name, "React Router");
assert!(det.entrypoint_code.contains("createRequestHandler"));
assert!(det.entrypoint_code.contains("build/server/index.js"));
assert!(det.entrypoint_code.contains("build/client"));
assert_eq!(det.include_paths, vec!["build"]);
}

#[test]
fn detects_react_router_without_config_defaults_to_ssr() {
// No react-router.config file at all -> React Router defaults to SSR.
let dir = setup_dir();
fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
)
.unwrap();
fs::write(dir.path().join("vite.config.ts"), "").unwrap();
let det = detect_framework(dir.path()).unwrap().unwrap();
assert_eq!(det.name, "React Router");
assert_eq!(det.include_paths, vec!["build"]);
assert!(det.entrypoint_code.contains("createRequestHandler"));
}

#[test]
fn react_router_commented_ssr_false_is_still_ssr() {
// A commented-out `// ssr: false` above a real `ssr: true` must NOT be
// mistaken for SPA.
let dir = setup_dir();
fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
)
.unwrap();
fs::write(dir.path().join("vite.config.ts"), "").unwrap();
fs::write(
dir.path().join("react-router.config.ts"),
"export default {\n // set ssr: false to opt into SPA\n ssr: true,\n};\n",
)
.unwrap();
let det = detect_framework(dir.path()).unwrap().unwrap();
assert_eq!(det.name, "React Router");
assert_eq!(det.include_paths, vec!["build"]);
assert!(det.entrypoint_code.contains("createRequestHandler"));
}

#[test]
fn react_router_takes_precedence_over_generic_vite() {
// A React Router project always carries a vite.config + vite dep; make sure
// an `ssr: false` SPA is detected as React Router rather than falling
// through to generic Vite.
let dir = setup_dir();
fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"@react-router/dev":"^7.0.0","vite":"^5.0.0"}}"#,
)
.unwrap();
fs::write(dir.path().join("vite.config.ts"), "").unwrap();
fs::write(
dir.path().join("react-router.config.ts"),
"export default { ssr: false };\n",
)
.unwrap();
let det = detect_framework(dir.path()).unwrap().unwrap();
assert_eq!(det.name, "React Router");
}

#[test]
fn react_router_library_mode_is_detected_as_vite() {
// React Router used purely as a routing *library* (no `@react-router/dev`
// build tool) is just a Vite SPA. It must NOT be detected as the React
// Router framework, which would look for a `build/` output a library user
// never produces.
let dir = setup_dir();
fs::write(
dir.path().join("package.json"),
r#"{"dependencies":{"react-router":"^7.0.0"},"devDependencies":{"vite":"^5.0.0"}}"#,
)
.unwrap();
fs::write(dir.path().join("vite.config.ts"), "").unwrap();
let det = detect_framework(dir.path()).unwrap().unwrap();
assert_eq!(det.name, "Vite");
}

// --- Vite ---

#[test]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
error: Could not detect a supported framework in '[WILDCARD]'.
Supported frameworks: Next.js, Astro, Fresh, Remix, SvelteKit, Nuxt, SolidStart, TanStack Start, Vite
Supported frameworks: Next.js, Astro, Fresh, Remix, React Router, SvelteKit, Nuxt, SolidStart, TanStack Start, Vite
Provide an explicit entrypoint instead of a directory.
Loading