Export react-router routes for use in Java servlet

This blog post describes a vite plugin that can export static react router routes into a text file, one route per line, that can be easily parsed in a Java servlet to make it possible to reload the react router paths.

Summary

When the react-router is used to navigate in a reactjs web application, the local path in the web browser is adjusted as if navigating between react components had been navigating between web pages. This means it is expected by users to be able to reload a particular URL, or share a URL with someone else, and end up in the same place.

When a react application is packaged with “npx vite build” and the results are served by a Java servlet as a static index.html file that loads the packaged react application as a static JS file, then when the react app loads react-router will look at the local path and navigate to the react component associated by the path.

That means that if the servlet can return the index.html file of the react app for the paths used by the router, react-router will take care of the rest.

To be able to return index.html for the router paths, the servlet needs to have the list of the router paths.

The way I’ve done it up until now is copy the list manually.

I.e. if the react app has this router definition

export default function App(props) {
    const { history, basename } = props;

    return (
        <Router history={history} basename={basename}>
            <Routes>
                <Route exact path="/" element={<Home/>} />
                <Route exact path="/counter" element={<Counter/>} />
                <Route exact path="/login" element={<Login/>} />
                <Route exact path="/unauthorized" element={<Unauthorized/>} />
            </Routes>
        </Router>
    );
}

the list of paths must be replicated in the servlet

public class SampleappServlet extends FrontendServlet {
    public SampleappServlet() {
        super();
        // The paths used by the react router
        setRoutes(
            "/",
            "/counter",
            "/login",
            "/unauthorized");
    }
}

Simple enough!

But the list of routes in a real application quickly gets more than 4 entries, and keeping the Java list in sync when the router definition changes is often forgotten and not discovered until a reload, or a shared URL, gets an unexpected 404.

So it would be nice if the Java servlet could pick up the react router path automatically.

The solution: a vite plugin

The solution was to add a vite plugin looks through all .js files of a react app and transforms the list of routs defined by <Route/> elements into a text file with one path per line, like e.g. so:

/
/counter
/login
/unauthorized

and then put the resulting file into the assets directory created by “npx vite build”.

Note: This solution is only for the static routes found in the JSX source code of the react app. If routes are built dynamically based on the data in the applications database (as is the case for 1990-ies picture archives in modern skin), then the servlet serving the frontend need to replicate the logic setting up the dynamic routes in the frontend.

Here is the vite.config.js file of Convert react app built with frontend-maven-plugin from webpack to vite with a plugin to extract router paths added (explanations follow the example):

import { defineConfig } from 'vite';
import eslintPlugin from "@nabla/vite-plugin-eslint";
import path from 'path';
import fs from 'fs';
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from "@babel/types";

export default defineConfig({
    plugins: [eslintPlugin(), exportRoutesPlugin()],
    build: {
        minify: false,
        sourcemap: true,
        manifest: true,
        rollupOptions: {
            // overwrite default .html entry
            input: 'src/index.js',
            output: {
                entryFileNames: `assets/[name].js`,
                chunkFileNames: `assets/[name].js`,
                assetFileNames: `assets/[name].[ext]`
            }
        },
        // Relative to the root
        outDir: '../../../target/classes',
    },
    // Treat .js files as jsx
    esbuild: {
        include: /\.js$/,
        exclude: [],
        loader: 'jsx',
    },
});

function exportRoutesPlugin() {
    const routePaths = new Set();

    return {
        name: 'export-routes',

        async transform(src, id) {
            if (!id.includes('node_modules') && !id.includes('commonjsHelpers') && id.includes('.js')) {
                // This is a rollup plugin that runs after esbuild, so code in src has already been processed
                // from JSX to plain JS (i.e. the JSX stuff is gone...).
                // Have to read the raw file from disk to find the JSX tags.
                fs.readFile(id, 'utf-8', (err, data) => {
                    const ast = parse(data, {
                        sourceType: 'module',
                        plugins: ['jsx'],
                    });

                    traverse(ast, {
                        enter(path) {
                            if (t.isJSXElement(path.node)) {
                                const elementName = path.node.openingElement.name.name;
                                if (elementName === 'Route') {
                                    path.node.openingElement.attributes.forEach((attribute) => {
                                        if (attribute.name.name === 'path') {
                                            routePaths.add( attribute.value.value);
                                        }
                                    });
                                }
                            }
                        }
                    });
                });
            }
        },

        generateBundle(options, bundle) {
            const outputDirectory = options.dir || 'dist';
            const assetsDirectory = path.join(outputDirectory, 'assets');
            fs.mkdirSync(assetsDirectory, { recursive: true });
            const filePath = path.join(assetsDirectory, 'routes.txt');
            const fileContent = Array.from(routePaths).join('\n');
            fs.writeFileSync(filePath, fileContent);
        },
    };
};

Explanations of the code:

  1. This adds the plugin to the vite build

    export default defineConfig({
        plugins: [eslintPlugin(), exportRoutesPlugin()],
    
  2. This function will be called for each source file in the webapp (.js files, XML files, CSS files) and src will contain the content of the file and id will contain the full path filename of the file:

    async transform(src, id) {
    
  3. This if-statement weeds out node_modules and a file that doesn’t exist as a source file and limit to .js files (skips e.g. .css and other files)

    if (!id.includes('node_modules') && !id.includes('commonjsHelpers') && id.includes('.js')) {
    
  4. Since JSX has been flattened to plain JS when transform() is called, we can’t feed src to parse() and find JSX nodes, therefore each js file must be read from disk

    // This is a rollup plugin that runs after esbuild, so code in src has already been processed
    // from JSX to plain JS (i.e. the JSX stuff is gone...).
    // Have to read the raw file from disk to find the JSX tags.
    fs.readFile(id, 'utf-8', (err, data) => {
        const ast = parse(data, {
            sourceType: 'module',
            plugins: ['jsx'],
        });
    
  5. The traverse() function will walk the AST of each parsed file and will match all JSX elements of type <Route/> and extract the value of the path attribute of the element into a Set
  6. The generateBundle() function will create a file routes.txt in the assets directory and will write each Set member as a line in the file

Picking up the results

If you have a java servlet that serves the vite packaged react webapp from static files in its classpath, like e.g.

public class SampleappServlet extends FrontendServlet {
    public SampleappServlet() {
        super();
        // The paths used by the react router
        setRoutes(
            "/",
            "/counter",
            "/login",
            "/unauthorized");
    }
}

then you can read the routes.txt file created by exportRoutesPlugin from the jar file’s classpath, in the following way:

public class SampleappServlet extends FrontendServlet {
    private static final long serialVersionUID = -3496606785818930881L;

    public SampleappServlet() {
        super();
        // The paths used by the react router
        setRoutes(readLinesFromClasspath("assets/routes.txt"));
    }

    String[] readLinesFromClasspath(String fileName) {
        try (var reader = new BufferedReader(new InputStreamReader(this.getClass().getClassLoader().getResourceAsStream(fileName)))) {
            var lines = reader.lines().toList();
            return lines.toArray(new String[0]);
        } catch (Exception e) {
            throw new SampleappException("Failed to read routes list from classpath resource", e);
        }
    }
}

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.