DEV Community

Nathan
Nathan

Posted on

Vite & Remix Shenanigans

Here's a problem: You're using a metaframework, like Remix, but you'd like to serve some static HTML files that require bundled JavaScript. Maybe it's relic from an old code base that you're too lazy to refactor into React, or maybe it's actually genuinely hard to refactor.

For my case, I was trying to create a hub that would list and embed a series of old JavaScript/Vite projects or "experiments" as I call them. The hub itself would be built with React, but ideally

  1. I wouldn't have to change the code base of each individual experiment too much, and
  2. all of the experiments would live on a single website and Vercel deployment for convenience

One my experiments was built with a now deprecated game engine called KaboomJS. Initially I was optimistic about directly including each of the experiments directly in React - the standard approach for using non-React libraries simply involves a useEffect and possibly some refs. If you don't know that I'm talking about, here's what I tried to achieve:

const Game = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    assert(canvasRef.current, "Expected canvas ref");
    const game = kaboom({ canvas: canvasRef.current }

    return () => {
        // global function provided by kaboom 
        // I should probably use game.quit() here but whatever
        quit()
    };
  }, []);

  return <canvas ref={canvasRef} />
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, although Kaboom does offer a quit function, apparently using it prevents the WebGL context from being obtained (source: Kaplay (former Kaboom) Discord). This is a huge problem. At first, I thought I could simply disable React Strict Mode to prevent the effect from running twice. This strategy does not work, because page navigations powered by Remix would eventually trigger the effect. For example, the first time someone visits the experiment, Kaboom would run just fine. Leaving the page (such as going back home) and then returning to the experiment would NOT be fine, because the quit function would be called as the component unmounts.

An alternative to the quit function is using an empty Kaboom scene, but this does not actually remove the Kaboom instance. Every page navigation would simply add a new one!

Rather than try to make Kaboom work with React, I decided to use an iframe. Since the website I'm building is just to showcase a few self-contained and isolated games to the user, an iframe would allow me to simultaneously keep the old JavaScript codebase and stop worrying about how to integrate Kaboom with React cleanup functions.

How do we achieve this?

One possibility is to use Remix loader functions in some way to present an HTML file. These are known as resource routes. Even at a first glance this approach appears too limited and expensive. I would likely need to include some kind of bundler that would combine all of the necessary JavaScript files into an inline string to be served through a resource route. Even if I were to use a CDN like https://esm.sh, I would still need to build the rest of the local JavaScript files into a single source, and doing this for every request (even with caching) seems terribly inefficient.

Another possibility is to simply create a separate deployment for each game and then use an iframe. This is honestly the easiest and most straightforward approach, but I would prefer to keep all of the projects in a single monorepository-ish to prevent needless deployments that could incur more costs.

A final possibility is the simply stick a built version of the static HTML files in the public directory, which would ultimately be served by Vite. Combining this approach with the previous one involving iframes seems the most ideal - I would not need separate deployments for each experiment, and I would keep the isolated nature of iframes that would effectively separate and decouple the JavaScript experiments from React/Remix.

The simplest and most annoying way to achieve the last option would involve manually building the files in a separate repository and then drag & dropping them into the public directory, or perhaps using a custom script of some kind to automate the process. I wanted something completely Vite native that would deploy completely on Vercel's end, not some hack job.

And then I realized...

In addition to targetting specific HTML files as entrypoints directly in Vite's config (this enables multiple pages, see this StackOverflow question), you can also specify where Vite deposits the finished build. All I had to do was

  1. create a new Vite config specifically for the JavaScript experiments
  2. put the static files wherever Remix puts its "public" directory in the final build

It's that easy. Static HTML files and React/Remix in one code-base.

We have a problem!

Vite automatically clears out the build directory. Fortunately, this is trivial to fix - just tell Vite not to do that by setting build.emptyOutDir equal to false.

And another problem!

While this approach seemingly works in production builds, what about development? Two Vite configs (one for Remix and the other for building static HTML) would create two servers, not one!

For example, I might have the Remix development server on http://localhost:5173, but the static HTML files are on http://localhost:3000. The iframe src would then have to include http://localhost:3000/path-to-game, which would work in development but not in production.

Yes, we could build a different path depending on the mode specified by environment variables (something like isProduction() ? "/path-to-game" : "http://localhost:3000/path-to-game"), but there's an easier fix.

Just use Vite's proxy, which only applies in development mode. Now, http://localhost:5173/path-to-game will be the same as http://localhost:3000/path-to-game, allowing us to use absolute paths in both development and production.

The Vite Configs:

Here's the relevant components of each Vite config.

For Remix, in vite.config.ts

export default defineConfig({
  plugins: [
    remix({ ... }), // this is autogenerated
    tsconfigPaths(),
  ],
  // make paths work in development
  server: {
    proxy: { "/app-iframe": "http://localhost:3000" },
  },
  // do not empty out build, the other Vite config will do that for us
  build: {
    emptyOutDir: false,
  },
});
Enter fullscreen mode Exit fullscreen mode

For the static HTML builds, in vite.config.vanilla.ts

const PROJECTS_DIR = resolve(__dirname, "./app-iframe");

export default defineConfig({
  plugins: [tsconfigPaths()],
  server: {
    port: 3000,
    strictPort: true,
  },
  build: {
    outDir: "build/client",
    // target the "index.html" files in the "app-iframe" directory
    rollupOptions: {
      input: readdirSync(PROJECTS_DIR)
        .filter((sketch) =>
          lstatSync(resolve(PROJECTS_DIR, sketch)).isDirectory(),
        )
        .reduce(
          (acc, project) => ({
            ...acc,
            [project]: resolve(PROJECTS_DIR, project, "index.html"),
          }),
          {},
        ),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

You'd think two Vite configs means writing two commands in the terminal, but this too is rather trivial to fix. Using npm-run-all, we can run the build process sequentially to ensure the static files are built first & then put into the build directory (we could actually reverse the order and let Remix build first - as long as keep consistent and only let one config clear the build directory). As mentioned previously, development mode would create two servers, so it doesn't really matter if they run in parallel.

In package.json

"scripts": {
    "build": "run-s build:vanilla build:remix",
    "build:remix": "remix vite:build",
    "build:vanilla": "vite build --config vite.config.vanilla.ts",
    "dev": "run-p dev:vanilla dev:remix",
    "dev:remix": "remix vite:dev",
    "dev:vanilla": "vite dev --config vite.config.vanilla.ts",
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

If this is still really confusing to you, check out the GitHub repository to see what I did in full detail, as well as the deployed version on Vercel.

Top comments (0)