/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/tools/framework.rs
1 852 строки
64 KB
Leo Kettmeir
fix(desktop): run Vite-based HMR dev servers inside the desktop runtime (#36488)
08 авг 2026, 11:48
Не верифицирован
08 авг 2026, 11:48
98dc759
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. //! Framework detection for `deno compile .`. //! //! 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; use deno_core::anyhow::Context; use deno_core::anyhow::bail; 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, /// Generated entrypoint TypeScript/JavaScript code (production). pub entrypoint_code: String, /// Directories to include in the compiled binary. pub include_paths: Vec<String>, /// Optional build command to run before compilation (e.g. "next build"). /// The command is run with the detected directory as cwd. pub build_command: Option<Vec<String>>, /// Command to launch the framework's HMR server for desktop pub hmr_command: Option<Vec<String>>, } /// Entrypoint compiled for `deno desktop --hmr` on projects whose dev command /// is plain `vite dev`: it boots the project's own Vite dev server through /// Vite's JS API *inside* the desktop runtime, so server-side code (SvelteKit /// endpoints, Vite SSR handlers) keeps access to `Deno.desktop` — matching the /// compiled app, where the server also runs in the desktop runtime (#35899). /// /// The runtime publishes `DENO_SERVE_ADDRESS` before user code runs and the /// webview polls-then-navigates to that port. Binding Vite to it explicitly /// (rather than relying on the node:http listen override alone) keeps the /// port Vite believes it serves on — `printUrls`, HMR websocket origin — in /// agreement with where it actually listens. const VITE_DEV_ENTRYPOINT: &str = r#"// @ts-nocheck import { createServer } from "vite"; const addr = Deno.env.get("DENO_SERVE_ADDRESS") ?? ""; const match = addr.match(/^tcp:(.+):(\d+)$/); const server = await createServer({ server: match ? { host: match[1], port: Number(match[2]), strictPort: true } : {}, }); await server.listen(); server.printUrls(); "#; impl FrameworkDetection { /// Entrypoint that boots the framework's dev server *inside* the desktop /// runtime for `deno desktop --hmr`, so server-side code keeps access to /// `Deno.desktop` (#35899). `None` means `--hmr` falls back to spawning /// `hmr_command` as a separate plain-Deno process, where desktop APIs are /// unavailable to server code. pub fn hmr_entrypoint_code(&self) -> Option<&'static str> { match self.name { // Both run plain `vite dev` as their dev task; boot the same server // through Vite's JS API so it lives inside the desktop runtime. The // wrapper dev CLIs (`nuxi dev`, `vinxi dev`, `react-router dev`, // Fresh's dev.ts) are not plain Vite and keep the external path. "Vite" | "SvelteKit" => Some(VITE_DEV_ENTRYPOINT), _ => None, } } /// Directories (relative to the project root) where the framework keeps /// static assets like favicons. Used to auto-detect a desktop app icon /// when the user didn't supply `--icon`. pub fn static_asset_dirs(&self) -> &'static [&'static str] { match self.name { // Next.js App Router puts `favicon.ico` / `icon.*` directly in `app/` // (or `src/app/`); the Pages Router and older versions use `public/`. "Next.js" => &["public", "app", "src/app"], "Fresh" | "SvelteKit" => &["static"], _ => &["public"], } } } /// Search a framework's static asset directories for a favicon that can /// double as the desktop app icon. Returns the first match in priority /// order, restricted to file formats supported by `target_os` (so we /// don't pick a `.png` for a Windows build where bundling would silently /// drop it). pub fn find_framework_favicon( dir: &Path, detection: &FrameworkDetection, target_os: &str, ) -> Option<PathBuf> { let exts: &[&str] = match target_os { "macos" => &["icns", "png"], "windows" => &["ico"], _ => &["png"], }; let names = ["icon", "favicon", "apple-touch-icon", "logo"]; for sub in detection.static_asset_dirs() { let base = dir.join(sub); if !base.is_dir() { continue; } for name in names { for ext in exts { let candidate = base.join(format!("{name}.{ext}")); if candidate.is_file() { return Some(candidate); } } } } None } /// Run the framework's build step (e.g. `deno task build`) with `dir` as the /// working directory, if the detection specifies one. This must run before the /// framework's `include_paths` are bundled, since they point at the build /// output (`dist`, `.next`, etc.) which doesn't exist until the build runs. /// /// Shared by `deno compile .` and `deno desktop .` so the two can't drift. pub fn run_build_command( detection: &FrameworkDetection, dir: &Path, ) -> Result<(), AnyError> { let Some(build_cmd) = &detection.build_command else { return Ok(()); }; log::info!( "{} {} project...", deno_terminal::colors::green("Building"), detection.name, ); let status = std::process::Command::new(&build_cmd[0]) .args(&build_cmd[1..]) .current_dir(dir) .status() .with_context(|| { format!("Failed to run build command: {}", build_cmd.join(" ")) })?; if !status.success() { deno_core::anyhow::bail!( "{} build failed (exit code: {})", detection.name, status.code().unwrap_or(-1) ); } Ok(()) } /// Detect a web framework in the given directory. /// /// Detection priority: /// 1. Config-file based detection (highest priority) /// 2. Package.json dependency-based detection /// 3. deno.json import-based detection pub fn detect_framework( dir: &Path, ) -> Result<Option<FrameworkDetection>, AnyError> { // --- Config-file based detection (highest priority) --- // Next.js: next.config.{js,mjs,ts} if has_config_file(dir, "next.config") { return Ok(Some(detect_nextjs(dir)?)); } // Fresh: fresh.gen.ts or _fresh/ if dir.join("fresh.gen.ts").exists() || dir.join("_fresh").is_dir() { return Ok(Some(detect_fresh(dir))); } // Astro: astro.config.{mjs,ts,js} if has_config_file(dir, "astro.config") { return Ok(Some(detect_astro(dir))); } // Nuxt: nuxt.config.{ts,js,mjs} if has_config_file(dir, "nuxt.config") { return Ok(Some(detect_nuxt(dir))); } // SvelteKit: svelte.config.{js,ts} plus a `@sveltejs/kit` dependency. Once // SvelteKit owns detection we must NOT fall through to the generic Vite // branch below (SvelteKit ships a vite.config + `vite` dependency, so it // would otherwise be misdetected as a plain Vite SPA and bundle a // non-existent `dist/`). `detect_sveltekit` resolves the adapter's output // shape, or errors with an actionable hint when the configured adapter isn't // one `deno desktop` can bundle. A bare `svelte.config.*` is not enough on // its own: the Vite Svelte template ships a `svelte.config.js` with only // `vitePreprocess()` and no SvelteKit, and such plain Svelte SPAs must keep // falling through to the Vite branch that bundles `dist/`. let has_sveltekit = read_package_deps(dir) .map(|deps| deps.has("@sveltejs/kit") || deps.has_dev("@sveltejs/kit")) .unwrap_or(false); if has_config_file(dir, "svelte.config") && has_sveltekit { return detect_sveltekit(dir).map(Some); } // --- Package.json dependency-based detection --- if let Some(deps) = read_package_deps(dir) { // Remix if deps.has("@remix-run/react") || deps.has_dev("@remix-run/dev") { 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"))); } // TanStack Start if deps.has("@tanstack/react-start") || deps.has("@tanstack/solid-start") { return Ok(Some(detect_nitro_framework(dir, "TanStack Start"))); } } // --- Vite (lowest priority among bundlers) --- // A generic Vite project, recognized by its config file or a `vite` // dependency. Many meta-frameworks build on Vite, but those are all matched // earlier (by their own config file or framework dependency), so reaching // here means a plain Vite app (SPA/MPA or a hand-rolled SSR server). let has_vite_dep = read_package_deps(dir) .map(|deps| deps.has("vite") || deps.has_dev("vite")) .unwrap_or(false); if has_config_file(dir, "vite.config") || has_vite_dep { return Ok(Some(detect_vite(dir))); } // --- deno.json import-based detection --- if let Some(imports) = read_deno_json_imports(dir) && imports .iter() .any(|i| i.starts_with("fresh") || i.starts_with("@fresh/core")) { return Ok(Some(detect_fresh(dir))); } Ok(None) } // --- Framework-specific detection --- fn deno_exe() -> String { std::env::current_exe() .map(|p| p.display().to_string()) .unwrap_or_else(|_| "deno".into()) } fn deno_task_build() -> Vec<String> { vec![deno_exe(), "task".into(), "build".into()] } fn deno_task_dev() -> Vec<String> { vec![deno_exe(), "task".into(), "dev".into()] } fn detect_nextjs(dir: &Path) -> Result<FrameworkDetection, AnyError> { let version = detect_package_version(dir, "next").unwrap_or(15); let entrypoint = format!( r#"// @ts-nocheck import {{ nextStart }} from "npm:next@^{version}/dist/cli/next-start.js"; globalThis.addEventListener("unhandledrejection", (e) => {{ console.error("[entrypoint] Unhandled rejection:", e.reason); if (e.reason?.stack) console.error("[entrypoint] Stack:", e.reason.stack); }}); // Guard: skip for forked workers (child_process.fork sets NODE_CHANNEL_FD). // Workers use override_main_module to run their target script directly. if (!Deno.env.get("NODE_CHANNEL_FD")) {{ // Use import.meta.dirname so paths resolve against the VFS in the // compiled binary rather than the runtime CWD. await nextStart({{ hostname: "0.0.0.0" }}, import.meta.dirname); }} "#, ); // `next-server` serves files in `public/` at the URL root; without it // shipped, every `<img src="/foo.png">` 404s. Optional in the project // (some apps put nothing there), so only include when present. let mut include_paths = vec![".next".into()]; if dir.join("public").is_dir() { include_paths.push("public".into()); } Ok(FrameworkDetection { name: "Next.js", entrypoint_code: entrypoint, include_paths, build_command: Some(deno_task_build()), hmr_command: None, // TODO: add command to enable `deno desktop --hmr` for Next.js }) } fn detect_astro(_dir: &Path) -> FrameworkDetection { FrameworkDetection { name: "Astro", entrypoint_code: "// @ts-nocheck\nimport \"./dist/server/entry.mjs\";\n" .into(), include_paths: vec!["dist".into()], build_command: Some(deno_task_build()), hmr_command: None, // TODO: add command to enable `deno desktop --hmr` for Astro } } fn detect_fresh(dir: &Path) -> FrameworkDetection { // Fresh 2.x uses _fresh/server.js (build output) or imports @fresh/core // in deno.json. We intentionally do NOT use `fresh.gen.ts + deno.json` // as a heuristic because Fresh 1 projects also have both of those files. let is_fresh2 = dir.join("_fresh/server.js").exists() || read_deno_json_imports(dir) .map(|imports| imports.iter().any(|i| i.starts_with("@fresh/core"))) .unwrap_or(false); if is_fresh2 { // `_fresh/snapshot.js` records static assets as `filePath: // "static/foo.png"` and `_fresh/server.js` constructs the // ProdBuildCache with `root = path.join(import.meta.dirname, "..")`, // so the runtime reads them via `<root>/static/...`. The `static/` // directory must therefore land in the VFS alongside `_fresh/` or // every image / font / video 404s. `static/` is conventional for // Fresh; if it doesn't exist the include is a harmless no-op. let mut include_paths = vec!["_fresh".into()]; if dir.join("static").is_dir() { include_paths.push("static".into()); } FrameworkDetection { name: "Fresh", entrypoint_code: r#"// @ts-nocheck const mod = await import("./_fresh/server.js"); Deno.serve(mod.default.fetch); "# .into(), include_paths, build_command: Some(deno_task_build()), hmr_command: Some(deno_task_dev()), } } else { // Fresh 1.x — no build step needed, server-rendered FrameworkDetection { name: "Fresh", entrypoint_code: "// @ts-nocheck\nimport \"./main.ts\";\n".into(), include_paths: vec![], build_command: None, hmr_command: None, } } } fn detect_remix(dir: &Path) -> FrameworkDetection { // `remix-serve` serves files from `public/` at the URL root; ship it // when present so static assets resolve. let mut include_paths = vec!["build".into()]; if dir.join("public").is_dir() { include_paths.push("public".into()); } FrameworkDetection { name: "Remix", entrypoint_code: "// @ts-nocheck\nimport \"./node_modules/.bin/remix-serve\";\n".into(), include_paths, build_command: Some(deno_task_build()), hmr_command: None, // TODO: add command to enable `deno desktop --hmr` for Remix } } 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()), hmr_command: Some(deno_task_dev()), } } 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()), hmr_command: Some(deno_task_dev()), } } } /// 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") } fn detect_sveltekit(dir: &Path) -> Result<FrameworkDetection, AnyError> { // Prefer post-build evidence of a supported output shape, since that // proves which adapter was actually used. if dir.join(".deno-deploy/server.ts").exists() { return Ok(FrameworkDetection { name: "SvelteKit", entrypoint_code: "// @ts-nocheck\nimport \"./.deno-deploy/server.ts\";\n" .into(), include_paths: vec![".deno-deploy".into()], build_command: Some(deno_task_build()), hmr_command: Some(deno_task_dev()), }); } if dir.join(".output/server/index.ts").exists() || dir.join(".output/server/index.mjs").exists() { let ext = if dir.join(".output/server/index.ts").exists() { "ts" } else { "mjs" }; return Ok(FrameworkDetection { name: "SvelteKit", entrypoint_code: format!( "// @ts-nocheck\nimport \"./.output/server/index.{ext}\";\n" ), include_paths: vec![".output".into()], build_command: Some(deno_task_build()), hmr_command: Some(deno_task_dev()), }); } // `@sveltejs/adapter-node` and `svelte-adapter-deno` both emit a `build/` // directory whose `index.js` boots the server and whose `handler.js` serves // the static assets from sibling `client`/`static`/`prerendered` // directories. Those asset directories aren't part of the module graph, so // they must be included explicitly or every request 404s in the compiled // binary. The adapter-node server runs under Deno via Node.js compatibility. if dir.join("build/index.js").exists() && dir.join("build/handler.js").exists() { return Ok(FrameworkDetection { name: "SvelteKit", entrypoint_code: "// @ts-nocheck\nimport \"./build/index.js\";\n".into(), include_paths: sveltekit_build_includes(dir), build_command: Some(deno_task_build()), hmr_command: Some(deno_task_dev()), }); } // No build artifacts yet — fall back to config inspection. let config_text = read_config_text(dir, "svelte.config"); if config_text.contains("@deno/svelte-adapter") { return Ok(FrameworkDetection { name: "SvelteKit", entrypoint_code: "// @ts-nocheck\nimport \"./.deno-deploy/server.ts\";\n" .into(), include_paths: vec![".deno-deploy".into()], build_command: Some(deno_task_build()), hmr_command: Some(deno_task_dev()), }); } // `@sveltejs/adapter-node` emits the same `build/index.js` shape as // `svelte-adapter-deno` and runs under Deno via Node.js compatibility, so no // Deno-specific adapter is required. Default `out` is `build`; only // `build/client` is guaranteed to exist after the build, so include just // that here — the post-build branch above picks up `static`/`prerendered` // when they're present. if config_text.contains("svelte-adapter-deno") || config_text.contains("@sveltejs/adapter-node") { return Ok(FrameworkDetection { name: "SvelteKit", entrypoint_code: "// @ts-nocheck\nimport \"./build/index.js\";\n".into(), include_paths: vec!["build/client".into()], build_command: Some(deno_task_build()), hmr_command: Some(deno_task_dev()), }); } if config_text.contains("nitro") { return Ok(FrameworkDetection { name: "SvelteKit", entrypoint_code: "// @ts-nocheck\nimport \"./.output/server/index.mjs\";\n".into(), include_paths: vec![".output".into()], build_command: Some(deno_task_build()), hmr_command: Some(deno_task_dev()), }); } // SvelteKit is present but the configured adapter isn't one we can locate a // bundleable server output for (e.g. `adapter-auto`, `adapter-vercel`, // `adapter-cloudflare`). `adapter-auto` is the `npx sv create` default and // produces *no* local server output unless a known cloud platform is // detected at build time, so point the user at a concrete adapter. let auto_hint = if config_text.contains("adapter-auto") { "`@sveltejs/adapter-auto` produces no local server output, so there is \ nothing to bundle. " } else { "" }; bail!( "SvelteKit detected, but no adapter that `deno desktop` can bundle is \ configured.\n{auto_hint}Configure a supported adapter in your \ svelte.config:\n - `@sveltejs/adapter-node` (runs under Deno via \ Node.js compatibility)\n - `@deno/svelte-adapter` (for Deno Deploy)" ); } /// Existing asset directories under a `svelte-adapter-deno` `build/` output /// that need to be embedded so the adapter's static file server can find them. fn sveltekit_build_includes(dir: &Path) -> Vec<String> { ["client", "static", "prerendered"] .iter() .map(|sub| format!("build/{sub}")) .filter(|rel| dir.join(rel).is_dir()) .collect() } /// Nuxt, SolidStart, TanStack Start all use Nitro with the `deno_server` /// preset, outputting to `.output/server/index.{ts,mjs}`. fn detect_nitro_framework( dir: &Path, name: &'static str, ) -> FrameworkDetection { let ext = if dir.join(".output/server/index.ts").exists() { "ts" } else { "mjs" }; FrameworkDetection { name, entrypoint_code: format!( "// @ts-nocheck\nimport \"./.output/server/index.{ext}\";\n" ), include_paths: vec![".output".into()], build_command: Some(deno_task_build()), // Nuxt (`nuxi dev`), SolidStart and TanStack Start (`vinxi dev`) all run a // Vite-based dev server that prints the `Local: http://…` line // `parse_dev_server_url` scans for, so `deno task dev` drives HMR for all // three. hmr_command: Some(deno_task_dev()), } } fn detect_vite(dir: &Path) -> FrameworkDetection { // SSR: a hand-written server entrypoint (`server.{js,ts,mjs}`) that boots the // Vite-built server bundle. Prefer it when present. if let Some(server_file) = ["server.js", "server.ts", "server.mjs"] .iter() .find(|f| dir.join(f).exists()) { return FrameworkDetection { name: "Vite", entrypoint_code: format!("// @ts-nocheck\nimport \"./{server_file}\";\n"), include_paths: vec!["dist".into()], build_command: Some(deno_task_build()), // `vite` (and Vue/React SPA templates built on it) prints the // `Local: http://…` line `parse_dev_server_url` scans for, so // `deno task dev` drives HMR. hmr_command: Some(deno_task_dev()), }; } // SPA / MPA: no server entrypoint. `vite build` emits a static site to // `dist/`; serve it over HTTP so the webview can load it, falling back to // `index.html` for client-side routes so a hard refresh on a history-API // route still resolves. FrameworkDetection { name: "Vite", entrypoint_code: r#"// @ts-nocheck // Pin the major version so the compiled binary is reproducible and doesn't pick // up a breaking `@std/http` release on a later rebuild. import { serveDir } from "jsr:@std/http@^1/file-server"; // `vite build` emits a static site into `dist/`. Resolve it against the VFS in // the compiled binary via import.meta.dirname rather than the runtime CWD. const fsRoot = import.meta.dirname + "/dist"; 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!["dist".into()], build_command: Some(deno_task_build()), // See the SSR branch above: the Vite dev server prints the `Local:` URL // that `parse_dev_server_url` scans for, so `deno task dev` drives HMR. hmr_command: Some(deno_task_dev()), } } // --- Helpers --- /// Check if a config file exists with any common extension. /// Config-file extensions framework detection recognizes. Used both to detect /// a config's presence and to read its text, so the two can't drift. const CONFIG_EXTENSIONS: [&str; 5] = ["js", "mjs", "ts", "mts", "cjs"]; fn has_config_file(dir: &Path, base_name: &str) -> bool { CONFIG_EXTENSIONS .iter() .any(|ext| dir.join(format!("{base_name}.{ext}")).exists()) } /// Read the text of the first matching `base_name.<ext>` config file, trying /// the same extension set as [`has_config_file`] so a config that passes the /// presence gate is never read as empty. fn read_config_text(dir: &Path, base_name: &str) -> String { CONFIG_EXTENSIONS .iter() .find_map(|ext| { std::fs::read_to_string(dir.join(format!("{base_name}.{ext}"))).ok() }) .unwrap_or_default() } /// Read package.json dependencies. fn read_package_deps(dir: &Path) -> Option<PackageDeps> { let content = std::fs::read_to_string(dir.join("package.json")).ok()?; let pkg: serde_json::Value = serde_json::from_str(&content).ok()?; Some(PackageDeps { deps: pkg .get("dependencies") .cloned() .unwrap_or(serde_json::Value::Object(Default::default())), dev_deps: pkg .get("devDependencies") .cloned() .unwrap_or(serde_json::Value::Object(Default::default())), }) } struct PackageDeps { deps: serde_json::Value, dev_deps: serde_json::Value, } impl PackageDeps { fn has(&self, name: &str) -> bool { self.deps.get(name).is_some() } fn has_dev(&self, name: &str) -> bool { self.dev_deps.get(name).is_some() } } /// Extract the major version number from a package.json dependency. fn detect_package_version(dir: &Path, package: &str) -> Option<u32> { let content = std::fs::read_to_string(dir.join("package.json")).ok()?; let pkg: serde_json::Value = serde_json::from_str(&content).ok()?; let ver_str = pkg .get("dependencies") .and_then(|d| d.get(package)) .or_else(|| pkg.get("devDependencies").and_then(|d| d.get(package)))? .as_str()?; // Extract major version from "^16.1.6", "~15.0.0", "14.2.3", etc. ver_str .chars() .skip_while(|c: &char| !c.is_ascii_digit()) .take_while(|c: &char| c.is_ascii_digit()) .collect::<String>() .parse() .ok() } /// Read the `imports` keys from deno.json / deno.jsonc. /// /// Uses JSONC-aware parsing so that commented deno.jsonc files are /// handled correctly instead of silently failing detection. fn read_deno_json_imports(dir: &Path) -> Option<Vec<String>> { let content = std::fs::read_to_string(dir.join("deno.json")) .or_else(|_| std::fs::read_to_string(dir.join("deno.jsonc"))) .ok()?; let config: serde_json::Value = jsonc_parser::parse_to_serde_value(&content, &Default::default()) .ok() .flatten()?; let imports = config.get("imports")?.as_object()?; Some(imports.keys().cloned().collect()) } #[cfg(test)] mod tests { use std::fs; use super::*; fn setup_dir() -> tempfile::TempDir { tempfile::tempdir().unwrap() } /// Write a minimal `package.json` declaring `@sveltejs/kit` so the SvelteKit /// detection gate (which requires positive SvelteKit evidence) fires. fn write_sveltekit_pkg(dir: &Path) { fs::write( dir.join("package.json"), r#"{"devDependencies":{"@sveltejs/kit":"^2.0.0"}}"#, ) .unwrap(); } #[test] fn no_framework_empty_dir() { let dir = setup_dir(); let result = detect_framework(dir.path()).unwrap(); assert!(result.is_none()); } // --- Config-file based detection --- #[test] fn detects_nextjs_with_config_js() { let dir = setup_dir(); fs::write(dir.path().join("next.config.js"), "").unwrap(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"next":"^15.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); assert_eq!(det.include_paths, vec![".next"]); assert!(det.entrypoint_code.contains("next@^15")); // No .next dir => build_command is set assert!(det.build_command.is_some()); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); } #[test] fn nextjs_always_builds() { let dir = setup_dir(); fs::write(dir.path().join("next.config.js"), "").unwrap(); fs::create_dir(dir.path().join(".next")).unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); assert!(det.build_command.is_some()); } #[test] fn detects_nextjs_with_config_mjs() { let dir = setup_dir(); fs::write(dir.path().join("next.config.mjs"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); } #[test] fn detects_nextjs_with_config_ts() { let dir = setup_dir(); fs::write(dir.path().join("next.config.ts"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); } #[test] fn nextjs_version_from_package_json() { let dir = setup_dir(); fs::write(dir.path().join("next.config.js"), "").unwrap(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"next":"^14.2.3"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert!(det.entrypoint_code.contains("next@^14")); } #[test] fn nextjs_defaults_to_v15() { let dir = setup_dir(); fs::write(dir.path().join("next.config.js"), "").unwrap(); // no package.json let det = detect_framework(dir.path()).unwrap().unwrap(); assert!(det.entrypoint_code.contains("next@^15")); } #[test] fn detects_fresh_gen_ts() { let dir = setup_dir(); fs::write(dir.path().join("fresh.gen.ts"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); assert!(det.include_paths.is_empty()); // no _fresh/server.js => Fresh 1.x assert!(det.entrypoint_code.contains("main.ts")); } #[test] fn detects_fresh2_with_server_js() { let dir = setup_dir(); fs::create_dir_all(dir.path().join("_fresh")).unwrap(); fs::write(dir.path().join("_fresh/server.js"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); assert!(det.entrypoint_code.contains("_fresh/server.js")); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); } #[test] fn fresh1_has_no_build_command() { let dir = setup_dir(); fs::write(dir.path().join("fresh.gen.ts"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); assert!(det.build_command.is_none()); } #[test] fn fresh1_with_deno_json_stays_fresh1() { // Regression: fresh.gen.ts + deno.json (without @fresh/core import) // should be treated as Fresh 1, not Fresh 2. let dir = setup_dir(); fs::write(dir.path().join("fresh.gen.ts"), "").unwrap(); fs::write( dir.path().join("deno.json"), r#"{"tasks":{"start":"deno run -A main.ts"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); // Fresh 1.x uses main.ts, not _fresh/server.js assert!(det.entrypoint_code.contains("main.ts")); assert!(det.build_command.is_none()); } #[test] fn fresh2_detected_via_fresh_core_import() { let dir = setup_dir(); fs::write(dir.path().join("fresh.gen.ts"), "").unwrap(); fs::write( dir.path().join("deno.json"), r#"{"imports":{"@fresh/core":"jsr:@fresh/core@^2"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); // Should be Fresh 2 because @fresh/core is in imports assert!(det.entrypoint_code.contains("_fresh/server.js")); assert!(det.build_command.is_some()); } #[test] fn detects_astro() { let dir = setup_dir(); fs::write(dir.path().join("astro.config.mjs"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Astro"); assert!(det.entrypoint_code.contains("dist/server/entry.mjs")); assert_eq!(det.include_paths, vec!["dist"]); assert!(det.build_command.is_some()); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); } #[test] fn detects_nuxt() { let dir = setup_dir(); fs::write(dir.path().join("nuxt.config.ts"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Nuxt"); assert_eq!(det.include_paths, vec![".output"]); // Nuxt runs a Vite-based dev server, so `deno desktop --hmr` is // supported — but `nuxi dev` is not plain `vite dev`, so it keeps the // external dev-server process rather than the in-runtime dev entrypoint. assert!(det.hmr_entrypoint_code().is_none()); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); let hmr = det.hmr_command.unwrap(); assert_eq!(hmr[1..], vec!["task", "dev"]); } #[test] fn detects_nuxt_with_ts_output() { let dir = setup_dir(); fs::write(dir.path().join("nuxt.config.ts"), "").unwrap(); fs::create_dir_all(dir.path().join(".output/server")).unwrap(); fs::write(dir.path().join(".output/server/index.ts"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert!(det.entrypoint_code.contains("index.ts")); } #[test] fn detects_sveltekit_deno_deploy() { let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write(dir.path().join("svelte.config.js"), "").unwrap(); fs::create_dir_all(dir.path().join(".deno-deploy")).unwrap(); fs::write(dir.path().join(".deno-deploy/server.ts"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert!(det.entrypoint_code.contains(".deno-deploy/server.ts")); assert_eq!(det.include_paths, vec![".deno-deploy"]); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); } #[test] fn detects_sveltekit_nitro_from_built_output() { let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write(dir.path().join("svelte.config.ts"), "").unwrap(); fs::create_dir_all(dir.path().join(".output/server")).unwrap(); fs::write(dir.path().join(".output/server/index.mjs"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert_eq!(det.include_paths, vec![".output"]); assert!(det.build_command.is_some()); } #[test] fn detects_sveltekit_adapter_deno_from_config() { // `svelte-adapter-deno` emits a `build/` directory (not `.output`). let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write( dir.path().join("svelte.config.js"), "import adapter from 'svelte-adapter-deno';\nexport default { kit: { adapter: adapter() } };\n", ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert!(det.entrypoint_code.contains("build/index.js")); assert_eq!(det.include_paths, vec!["build/client"]); } #[test] fn detects_sveltekit_adapter_deno_from_built_output() { // Post-build evidence: `build/index.js` + `build/handler.js` is the // `svelte-adapter-deno` output shape. Existing asset dirs are included. let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write(dir.path().join("svelte.config.js"), "").unwrap(); fs::create_dir_all(dir.path().join("build/client")).unwrap(); fs::create_dir_all(dir.path().join("build/prerendered")).unwrap(); fs::write(dir.path().join("build/index.js"), "").unwrap(); fs::write(dir.path().join("build/handler.js"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert!(det.entrypoint_code.contains("build/index.js")); assert_eq!(det.include_paths, vec!["build/client", "build/prerendered"]); assert!(det.build_command.is_some()); } #[test] fn detects_sveltekit_nitro_from_config() { let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write( dir.path().join("svelte.config.js"), "// uses a nitro-based adapter\nexport default { kit: {} };\n", ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert_eq!(det.include_paths, vec![".output"]); } #[test] fn detects_sveltekit_adapter_node_from_config() { // `@sveltejs/adapter-node` emits the `build/index.js` shape and runs under // Deno via Node.js compatibility — no Deno-specific adapter required. let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write( dir.path().join("svelte.config.js"), "import adapter from '@sveltejs/adapter-node';\nexport default { kit: { adapter: adapter() } };\n", ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert!(det.entrypoint_code.contains("build/index.js")); assert_eq!(det.include_paths, vec!["build/client"]); // SvelteKit's dev command is plain `vite dev`, so `--hmr` boots the dev // server inside the desktop runtime and server-side endpoints keep // `Deno.desktop` (#35899). assert!( det .hmr_entrypoint_code() .is_some_and(|code| code.contains("createServer")) ); } #[test] fn detects_sveltekit_adapter_node_from_mts_config() { // `svelte.config.mts` passes the presence gate, so its text must be read // too — otherwise a valid adapter-node config reads as empty and bails. let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write( dir.path().join("svelte.config.mts"), "import adapter from '@sveltejs/adapter-node';\nexport default { kit: { adapter: adapter() } };\n", ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert!(det.entrypoint_code.contains("build/index.js")); assert_eq!(det.include_paths, vec!["build/client"]); } #[test] fn errors_on_sveltekit_with_unsupported_adapter() { // A SvelteKit project using e.g. adapter-vercel must NOT fall through to // the generic Vite branch (which would bundle a non-existent `dist/`). // Instead we surface an actionable error. let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write( dir.path().join("svelte.config.js"), "import adapter from '@sveltejs/adapter-vercel';\nexport default { kit: { adapter: adapter() } };\n", ) .unwrap(); let err = detect_framework(dir.path()).unwrap_err().to_string(); assert!(err.contains("SvelteKit detected")); assert!(err.contains("adapter-node")); } #[test] fn errors_on_sveltekit_adapter_auto() { // `adapter-auto` is the `sv create` default and produces no local server // output, so we bail with a hint pointing at a concrete adapter. let dir = setup_dir(); write_sveltekit_pkg(dir.path()); fs::write( dir.path().join("svelte.config.js"), "import adapter from '@sveltejs/adapter-auto';\nexport default { kit: { adapter: adapter() } };\n", ) .unwrap(); let err = detect_framework(dir.path()).unwrap_err().to_string(); assert!(err.contains("adapter-auto")); assert!(err.contains("no local server output")); } #[test] fn plain_svelte_spa_falls_through_to_vite() { // The Vite Svelte template (`npm create vite -- --template svelte`) ships a // `svelte.config.js` with only `vitePreprocess()` and no `@sveltejs/kit`. // Without positive SvelteKit evidence it must NOT be taken over by // SvelteKit detection (which would bail on the missing adapter) — it stays // on the Vite path that bundles `dist/`. let dir = setup_dir(); fs::write( dir.path().join("svelte.config.js"), "import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';\nexport default { preprocess: vitePreprocess() };\n", ) .unwrap(); fs::write(dir.path().join("vite.config.js"), "").unwrap(); fs::write( dir.path().join("package.json"), r#"{"devDependencies":{"svelte":"^5.0.0","vite":"^5.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Vite"); assert_eq!(det.include_paths, vec!["dist"]); } // --- Package.json dependency-based detection --- #[test] fn detects_remix_from_deps() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"@remix-run/react":"^2.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Remix"); assert_eq!(det.include_paths, vec!["build"]); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); } #[test] fn detects_remix_from_dev_deps() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"devDependencies":{"@remix-run/dev":"^2.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Remix"); } #[test] fn detects_solidstart() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"@solidjs/start":"^1.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SolidStart"); assert_eq!(det.include_paths, vec![".output"]); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); } #[test] fn detects_tanstack_start_react() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"@tanstack/react-start":"^1.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "TanStack Start"); } #[test] fn detects_tanstack_start_solid() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"@tanstack/solid-start":"^1.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); 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"]); let hmr = det.hmr_command.unwrap(); assert_eq!(hmr[1..], vec!["task", "dev"]); } #[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"]); let hmr = det.hmr_command.unwrap(); assert_eq!(hmr[1..], vec!["task", "dev"]); } #[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] fn detects_vite_ssr_with_server_js() { let dir = setup_dir(); fs::write(dir.path().join("vite.config.js"), "").unwrap(); fs::write(dir.path().join("server.js"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Vite"); assert!(det.entrypoint_code.contains("server.js")); assert_eq!(det.include_paths, vec!["dist"]); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); // The Vite dev server drives HMR for `deno desktop --hmr`. let hmr = det.hmr_command.unwrap(); assert_eq!(hmr[1..], vec!["task", "dev"]); } #[test] fn detects_vite_ssr_with_server_ts() { let dir = setup_dir(); fs::write(dir.path().join("vite.config.ts"), "").unwrap(); fs::write(dir.path().join("server.ts"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Vite"); assert!(det.entrypoint_code.contains("server.ts")); } #[test] fn detects_vite_spa_from_config_without_server_file() { // A plain Vite SPA (config file, no server.{js,ts,mjs}) serves the static // `dist/` build over HTTP rather than importing a server entrypoint. let dir = setup_dir(); fs::write(dir.path().join("vite.config.js"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Vite"); assert!(det.entrypoint_code.contains("serveDir")); assert!(det.entrypoint_code.contains("/dist")); assert!(det.entrypoint_code.contains("Deno.serve")); // The remote `@std/http` dependency must be version-pinned so the compiled // binary is reproducible. assert!(det.entrypoint_code.contains("jsr:@std/http@^1/file-server")); assert_eq!(det.include_paths, vec!["dist"]); // A plain Vite SPA (e.g. the Vue template) supports `deno desktop --hmr`. // Vite's dev command is plain `vite dev`, so `--hmr` boots the dev server // inside the desktop runtime and server code keeps the desktop APIs // (#35899). assert!( det .hmr_entrypoint_code() .is_some_and(|code| code.contains("createServer")) ); let cmd = det.build_command.unwrap(); assert_eq!(cmd[1..], vec!["task", "build"]); let hmr = det.hmr_command.unwrap(); assert_eq!(hmr[1..], vec!["task", "dev"]); } #[test] fn vue_vite_spa_supports_hmr() { // A Vue SPA scaffolded with `npm create vue@latest` is a plain Vite project // (`vue` + `vite` in devDependencies, a `vite.config.ts`). It must resolve // to the Vite path with `deno desktop --hmr` enabled so the dev-server // workflow from issue #35745 works. let dir = setup_dir(); fs::write(dir.path().join("vite.config.ts"), "").unwrap(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"vue":"^3.0.0"},"devDependencies":{"vite":"^5.0.0","@vitejs/plugin-vue":"^5.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Vite"); let hmr = det.hmr_command.unwrap(); assert_eq!(hmr[1..], vec!["task", "dev"]); } #[test] fn detects_vite_from_package_dep() { // No config file, but `vite` in devDependencies — still a Vite project. let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"devDependencies":{"vite":"^5.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Vite"); assert!(det.entrypoint_code.contains("serveDir")); assert_eq!(det.include_paths, vec!["dist"]); } #[test] fn detects_vite_ssr_when_dep_and_server_present() { // `vite` dependency plus a server entrypoint resolves to the SSR variant. let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"vite":"^5.0.0"}}"#, ) .unwrap(); fs::write(dir.path().join("server.ts"), "").unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Vite"); assert!(det.entrypoint_code.contains("server.ts")); } // --- deno.json import-based detection --- #[test] fn detects_fresh_from_deno_json_imports() { let dir = setup_dir(); fs::write( dir.path().join("deno.json"), r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); } #[test] fn detects_fresh_from_deno_json_fresh_core() { let dir = setup_dir(); fs::write( dir.path().join("deno.json"), r#"{"imports":{"@fresh/core":"jsr:@fresh/core@^2"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); } #[test] fn detects_fresh_from_deno_jsonc() { let dir = setup_dir(); fs::write( dir.path().join("deno.jsonc"), r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); } #[test] fn detects_fresh_from_commented_deno_jsonc() { // Regression: deno.jsonc with comments should still be parsed correctly. let dir = setup_dir(); fs::write( dir.path().join("deno.jsonc"), "{\n // This is a comment\n \"imports\": {\n \"fresh\": \"jsr:@fresh/core\"\n }\n}\n", ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); } // --- Priority --- #[test] fn config_file_takes_priority_over_package_json() { let dir = setup_dir(); // Has both next.config.js and remix in package.json fs::write(dir.path().join("next.config.js"), "").unwrap(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"@remix-run/react":"^2.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); } #[test] fn package_json_takes_priority_over_vite_ssr() { let dir = setup_dir(); fs::write(dir.path().join("vite.config.js"), "").unwrap(); fs::write(dir.path().join("server.js"), "").unwrap(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"@solidjs/start":"^1.0.0"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "SolidStart"); } #[test] fn config_file_takes_priority_over_deno_json() { let dir = setup_dir(); fs::write(dir.path().join("astro.config.mjs"), "").unwrap(); fs::write( dir.path().join("deno.json"), r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#, ) .unwrap(); let det = detect_framework(dir.path()).unwrap().unwrap(); assert_eq!(det.name, "Astro"); } // --- Helper unit tests --- #[test] fn has_config_file_various_extensions() { let dir = setup_dir(); assert!(!has_config_file(dir.path(), "next.config")); fs::write(dir.path().join("next.config.mts"), "").unwrap(); assert!(has_config_file(dir.path(), "next.config")); } #[test] fn detect_package_version_parses_caret() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"next":"^16.1.6"}}"#, ) .unwrap(); assert_eq!(detect_package_version(dir.path(), "next"), Some(16)); } #[test] fn detect_package_version_parses_tilde() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"next":"~15.0.0"}}"#, ) .unwrap(); assert_eq!(detect_package_version(dir.path(), "next"), Some(15)); } #[test] fn detect_package_version_parses_exact() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"next":"14.2.3"}}"#, ) .unwrap(); assert_eq!(detect_package_version(dir.path(), "next"), Some(14)); } #[test] fn detect_package_version_from_dev_deps() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"devDependencies":{"next":"^13.0.0"}}"#, ) .unwrap(); assert_eq!(detect_package_version(dir.path(), "next"), Some(13)); } #[test] fn detect_package_version_missing_package() { let dir = setup_dir(); fs::write( dir.path().join("package.json"), r#"{"dependencies":{"react":"^18.0.0"}}"#, ) .unwrap(); assert_eq!(detect_package_version(dir.path(), "next"), None); } #[test] fn detect_package_version_no_package_json() { let dir = setup_dir(); assert_eq!(detect_package_version(dir.path(), "next"), None); } #[test] fn read_deno_json_imports_returns_keys() { let dir = setup_dir(); fs::write( dir.path().join("deno.json"), r#"{"imports":{"foo":"jsr:@foo/bar","baz":"npm:baz"}}"#, ) .unwrap(); let mut imports = read_deno_json_imports(dir.path()).unwrap(); imports.sort(); assert_eq!(imports, vec!["baz", "foo"]); } #[test] fn read_deno_json_imports_no_file() { let dir = setup_dir(); assert!(read_deno_json_imports(dir.path()).is_none()); } #[test] fn read_deno_json_imports_no_imports_key() { let dir = setup_dir(); fs::write(dir.path().join("deno.json"), r#"{"tasks":{}}"#).unwrap(); assert!(read_deno_json_imports(dir.path()).is_none()); } // --- Favicon discovery --- fn nextjs_detection() -> FrameworkDetection { FrameworkDetection { name: "Next.js", entrypoint_code: String::new(), include_paths: vec![], build_command: None, hmr_command: None, } } fn fresh_detection() -> FrameworkDetection { FrameworkDetection { name: "Fresh", entrypoint_code: String::new(), include_paths: vec![], build_command: None, hmr_command: None, } } #[test] fn finds_favicon_in_public_for_linux() { let dir = setup_dir(); fs::create_dir_all(dir.path().join("public")).unwrap(); fs::write(dir.path().join("public/favicon.png"), "").unwrap(); let det = nextjs_detection(); let p = find_framework_favicon(dir.path(), &det, "linux").unwrap(); assert_eq!(p, dir.path().join("public/favicon.png")); } #[test] fn finds_ico_for_windows_but_not_png() { let dir = setup_dir(); fs::create_dir_all(dir.path().join("public")).unwrap(); fs::write(dir.path().join("public/favicon.png"), "").unwrap(); let det = nextjs_detection(); assert!(find_framework_favicon(dir.path(), &det, "windows").is_none()); fs::write(dir.path().join("public/favicon.ico"), "").unwrap(); let p = find_framework_favicon(dir.path(), &det, "windows").unwrap(); assert_eq!(p, dir.path().join("public/favicon.ico")); } #[test] fn macos_prefers_icns_over_png() { let dir = setup_dir(); fs::create_dir_all(dir.path().join("public")).unwrap(); fs::write(dir.path().join("public/icon.png"), "").unwrap(); fs::write(dir.path().join("public/icon.icns"), "").unwrap(); let det = nextjs_detection(); let p = find_framework_favicon(dir.path(), &det, "macos").unwrap(); assert_eq!(p, dir.path().join("public/icon.icns")); } #[test] fn icon_name_preferred_over_favicon() { let dir = setup_dir(); fs::create_dir_all(dir.path().join("public")).unwrap(); fs::write(dir.path().join("public/favicon.png"), "").unwrap(); fs::write(dir.path().join("public/icon.png"), "").unwrap(); let det = nextjs_detection(); let p = find_framework_favicon(dir.path(), &det, "linux").unwrap(); assert_eq!(p, dir.path().join("public/icon.png")); } #[test] fn nextjs_app_router_favicon_in_app_dir() { let dir = setup_dir(); // No public/, but app/favicon.ico — Next 13+ App Router layout. fs::create_dir_all(dir.path().join("app")).unwrap(); fs::write(dir.path().join("app/favicon.ico"), "").unwrap(); let det = nextjs_detection(); let p = find_framework_favicon(dir.path(), &det, "windows").unwrap(); assert_eq!(p, dir.path().join("app/favicon.ico")); } #[test] fn fresh_uses_static_dir() { let dir = setup_dir(); fs::create_dir_all(dir.path().join("static")).unwrap(); fs::write(dir.path().join("static/favicon.png"), "").unwrap(); let det = fresh_detection(); let p = find_framework_favicon(dir.path(), &det, "linux").unwrap(); assert_eq!(p, dir.path().join("static/favicon.png")); } #[test] fn no_favicon_returns_none() { let dir = setup_dir(); fs::create_dir_all(dir.path().join("public")).unwrap(); let det = nextjs_detection(); assert!(find_framework_favicon(dir.path(), &det, "linux").is_none()); } #[test] fn public_takes_priority_over_app_for_nextjs() { let dir = setup_dir(); fs::create_dir_all(dir.path().join("public")).unwrap(); fs::create_dir_all(dir.path().join("app")).unwrap(); fs::write(dir.path().join("public/favicon.png"), "").unwrap(); fs::write(dir.path().join("app/icon.png"), "").unwrap(); let det = nextjs_detection(); let p = find_framework_favicon(dir.path(), &det, "linux").unwrap(); // public/ is checked before app/. assert_eq!(p, dir.path().join("public/favicon.png")); } // --- run_build_command --- #[test] fn run_build_command_none_is_noop() { let dir = setup_dir(); let mut det = nextjs_detection(); det.build_command = None; // No command to run, so this must succeed without doing anything. run_build_command(&det, dir.path()).unwrap(); } #[test] fn run_build_command_runs_in_dir() { // The build command runs with the detected directory as cwd. Use a // shell command that writes the framework's build output so we can assert // it ran in the right place (a regression test for #35535, where the // build step was skipped and the include path didn't exist). let dir = setup_dir(); let mut det = nextjs_detection(); det.include_paths = vec!["dist".into()]; det.build_command = if cfg!(windows) { Some(vec!["cmd".into(), "/C".into(), "mkdir dist".into()]) } else { Some(vec!["sh".into(), "-c".into(), "mkdir dist".into()]) }; run_build_command(&det, dir.path()).unwrap(); assert!(dir.path().join("dist").is_dir()); } #[test] fn run_build_command_propagates_failure() { let dir = setup_dir(); let mut det = nextjs_detection(); det.build_command = if cfg!(windows) { Some(vec!["cmd".into(), "/C".into(), "exit 1".into()]) } else { Some(vec!["sh".into(), "-c".into(), "exit 1".into()]) }; let err = run_build_command(&det, dir.path()).unwrap_err(); assert!(err.to_string().contains("build failed")); } }