Skip to content

cyco130/smf

main
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Code

Latest commit

 

Git stats

Files

Permalink
Failed to load latest commit information.
Type
Name
Latest commit message
Commit time
smf
 
 
src
 
 
 
 
 
 
 
 
 
 

SMF - Build Your Own Metaframework with Vite

Recently, in one of my tweets, I claimed that building a metaframework (a.k.a. a Next.js clone πŸ˜…) with Vite is easy. This article is an attempt to prove that somewhat bold claim by creating a reasonably usable Preact1 metaframework called SMF. SMF may stand for either "Simple MetaFramework" or the name of the fan club of Twisted Sister, my favorite band in my teenage years. You decide.

In this article, I will be assuming that you are reasonably familiar with Preact, Vite, and the concept of server-side rendering (SSR). I will use pnpm but it's not a requirement. You can use npm or yarn if you prefer.

01. Project setup

I started with the following files:

.gitignore

node_modules
dist

package.json

{
  "name": "smf",
  "private": true,
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "es2022",
    "module": "ESNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "jsx": "react-jsx",
    "jsxImportSource": "preact"
  }
}

I also copied my .prettierrc from another project. Omitted here for brevity.

Then I installed the dependencies.

pnpm i -S preact preact-render-to-string
pnpm i -D prettier vite @preact/preset-vite @types/node

Since we're using a single repo, the code for SMF and the code for the example app will live side by side. The src directory will contain the example app and the smf directory will contain the code for SMF. SMF will be a set of Vite plugins. Here's the initial version:

smf/smf.ts

import type { Plugin } from "vite";

export function smf(): Plugin[] {
  // A list of plugins, empty for now
  return [];
}

And the initial Vite config:

vite.config.ts

import { defineConfig } from "vite";
import { smf } from "./smf";
import { preact } from "@preact/preset-vite";

export default defineConfig({
  plugins: [smf(), preact()],
});

Finally, I added a dev script to package.json:

  "type": "module",
+ "scripts": {
+   "dev": "vite"
+ },
  "dependencies": {

Now we can run pnpm dev and see that Vite is working. It will start serving our "app" on localhost:5173 but you'll get a 404 since we haven't created an app yet.

βœ… Checkpoint: You can find the progress so far in the chapter-01 tag.

02. Using ssrLoadModule to run server code

Vite's own SSR guide recommends using Vite in "middleware mode". If we followed it, we would use vite.createServer with { server: { middlewareMode: true } } to create a Vite development middleware, then create a Node server with, e.g. Express, and add the Vite middleware to it. Then we would use viteDevServer.ssrLoadModule to load our server code.

This flow poses a chicken-and-egg problem if we want to use TypeScript or JSX: We need Vite's dev server to compile our server code but we need the server code to create the Vite middleware. We could use a separate tool like ts-node but to me, it doesn't make sense to use a separate tool when Vite is already there. This approach also requires us to give up on Vite's CLI and handle all options ourselves. That's not ideal.

To solve this, we will take the recommended flow and turn it around: Instead of using Vite as a middleware in our server, we will use our server code as a middleware in Vite's dev server. Vite plugins can define a configureServer hook to hook into Vite's dev server. We will use it to load our server code with the ssrLoadModule method of the Vite dev server and inject it as a middleware. Vite uses connect under the hood, you can think of it as Express minus the router. We can add a middleware with server.middlewares.use just like adding an Express middleware. Here's how, read the comments for details:

smf/smf.ts

import type { Plugin } from "vite";

export function smf(): Plugin[] {
  return [
    {
      name: "smf/load-handler",
      enforce: "post",
      apply: "serve",
      configureServer(server) {
        // Instead of adding the middleware here, we return a function that Vite
        // will call after adding its own middlewares. We want our code to run after
        // Vite's transform middleware so that we can focus on handling the requests
        // we're interested in.
        return () => {
          server.middlewares.use(async (req, res, next) => {
            try {
              const handlerModule = await server.ssrLoadModule(
                // This is where we'll put our handler
                server.config.root + "/src/entry-handler.ts",
                {
                  // This is required to make errors thrown in the handler
                  // to have the correct stack trace.
                  fixStacktrace: true,
                },
              );

              await handlerModule.default(req, res);

              // We're not calling `next` because our handler will always be
              // the last one in the chain. If it didn't send a response, we
              // will treat it as an error since there will be no one else to
              // handle it in production.
              if (!res.writableEnded) {
                next(new Error("SSR handler didn't send a response"));
              }
            } catch (error) {
              // Forward the error to Vite
              next(error);
            }
          });
        };
      },
    },
  ];
}

And our first handler will look like this:

src/entry-handler.ts

import type { RequestListener } from "node:http";

const handler: RequestListener = async (req, res) => {
  // res.send is not available in plain Node.js, it's added by Express.
  // We have to use res.write and/or res.end instead.
  res.end(`Received a ${req.method} request to ${req.url}.`);
};

export default handler;

If you run pnpm dev now, you should see the following output in the browser:

Received a GET request to /index.html.

That's weird. We requested / but Vite rewrote it to /index.html when its transform middleware couldn't find anything to serve. We don't want that, we need to set Vite's appType configuration option to "custom" to tell Vite that we will be serving our own dynamically generated HTML, not an index.html file from the file system. We could set it in vite.config.ts but that file belongs to the user. We should do it in our plugin's config hook instead so that when the user adds our plugin, they don't have to worry about it:

smf/smf.ts

      apply: "serve",
+     config() {
+       return {
+         appType: "custom",
+       };
+     },
      configureServer(server) {

Now visiting localhost:5173/ should give us the expected output:

Received a GET request to /.

If you change the handler code and refresh the page, you will see the changes reflected (we will cover automatic reloading and HMR later). It works because ssrLoadModule will invalidate its cache when a file or its dependencies change. This is not exactly hot module replacement (HMR), which Vite doesn't support for server code yet, but it's similar in that it allows us to change the code and see the changes without restarting the server.

This exact "the app as middleware" trick is used by many Vite-based metaframeworks such as SvelteKit, Astro, SolidStart, and Qwik. My vavite package, a set of tools for developing and building server-side applications with Vite that Rakkas uses under the hood, makes it even easier and solves a bunch of other challenges we will face later. Ordinarily, I would start with vavite and skip this section and several others but I promised a "from scratch" tutorial so here we are.

Some other metaframeworks (most notably Nuxt) use vite-node which is currently part of the Vitest project but it is expected to be merged into Vite core in the future. It is more difficult to set up but it has additional features like customizable HMR behavior and the ability to run Vite's server and the app code in separate VM contexts or processes. Nevertheless, we will stick with our simpler approach that has been proven to be more than adequate by so many successful metaframeworks.

βœ… Checkpoint: You can find the progress so far in the chapter-02 tag.

03. API routes

This may come as a surprise that we're implementing API routes before page routes but API routes are a more fundamental concept if you think about it. A page route is just a special kind of API route that returns HTML.

At this point, we have a pretty neat setup for running server code with Vite. The default export of src/entry-handler.ts is simply a Node HTTP request listener that all Node HTTP frameworks are ultimately based on: In Express, the app itself is a request listener; in Koa, you can get one with app.callback(); in Fastify, you can use (req, res) => fastify.server.emit("request", req, res) to create one once the server is ready; and so on. So we are in a position to use any popular Node HTTP framework we want. But we won't, because "from scratch" means "from scratch" πŸ˜….

We'll start by implementing a simple router. The router will map paths to API modules. API modules will export a set of functions named after HTTP methods and they will be called when a request with the corresponding method is received. The signature will be method(ctx: RequestContext): void | Promise<void> where RequestContext is a simple object with req, res, and params properties. params will hold dynamic path parameters.

Most Node server frameworks use a builder pattern for the router. You create a router and you add routes to it in an imperative manner. We'll use a simpler declarative router instead. Building an imperative one on top of it is trivial if we need it later. Most Node server frameworks use a variation of the /path/to/:param/*rest syntax for route patterns. We'll use a slightly modified one because we want to support file system routing later but : and * are hard to use in file names on Linux and Mac, and flat-out forbidden on Windows. I'll go with a loosely Remix-inspired /path/to/$param/$$rest syntax. The usage will look something like this:

export default buildHandler({
  "/foo": () => import("./routes/foo"),
  "/bar": () => import("./routes/bar"),
  "/baz/$id": () => import("./routes/baz"),
  "/qux/$$rest": () => import("./routes/qux"),
});

There are some neat TypeScript tricks to infer the type of params object from the route pattern but we won't go into that here. Also, for simplicity, we will use a linear regexp search. I didn't test the following thoroughly but it seemed to work with a few cases I tried it with. Our MF is called SMF, so good enough for Rock'n'Roll 🀘.

This will be part of SMF's server runtime so we'll put it in smf/server.ts:

smf/server.ts

import type { IncomingMessage, RequestListener, ServerResponse } from "http";

export type RequestHandler<P = Record<string, string>> = (
  ctx: RequestContext<P>,
) => void | Promise<void>;

export interface RequestContext<P = Record<string, string>> {
  req: IncomingMessage;
  res: ServerResponse;
  params: P;
}

export interface ApiModule<P = Record<string, string>> {
  get?: RequestHandler<P>;
  post?: RequestHandler<P>;
  put?: RequestHandler<P>;
  delete?: RequestHandler<P>;
  patch?: RequestHandler<P>;
  options?: RequestHandler<P>;
  head?: RequestHandler<P>;
  all?: RequestHandler<P>; // Fallback for all methods
}

export function buildHandler(
  apiRoutes: Record<string, () => Promise<ApiModule>>,
): RequestListener {
  // Convert into an array of [RegExp, () => Promise<ApiModule>] tuples
  const routes = Object.entries(apiRoutes).map(
    ([path, importer]) => [patternToRegExp(path), importer] as const,
  );

  return async function handler(req, res) {
    // These are typed as optional for some reason
    const { url = "/", method = "GET" } = req;

    // Remove query string and hash
    const path = url.match(/^[^?#]*/)![0];
    const match = routes.find(([pattern]) => pattern.exec(path));

    if (!match) {
      res.statusCode = 404;
      res.end("Not found");
      return;
    }

    const importer = match[1];
    try {
      const module = await importer();
      const handler =
        module[method.toLowerCase() as keyof ApiModule] ?? module.all;

      if (!handler) {
        // Look ma, I'm HTTP-ly correct!
        res.statusCode = 405;
        res.end("Method not allowed");
        return;
      }

      const params = match[0].exec(path)?.groups ?? {};
      await handler({ req, res, params });
    } catch (error) {
      console.error(error);
      res.statusCode = 500;
      res.end("Internal server error");
    }
  };
}

function patternToRegExp(path: string) {
  return RegExp(
    "^" +
      path
        .replace(/[\\^*+?.()|[\]{}]/g, (x) => `\\${x}`) // Escape special characters except $
        .replace(/\$\$(\w+)$/, "(?<$1>.*)") // $$rest to named capture group
        .replace(/\$(\w+)(\?)?(\.)?/g, "$2(?<$1>[^/]+)$2$3") + // $param to named capture groups
      "/?$", // Optional trailing slash and end of string
  );
}

Now let's create a couple of API route modules in src/routes. I will use the .api.ts convention for API routes, it will come in handy when we implement file system routing later. I prefer an explicit naming convention to exposing every file in a directory as a route.

src/routes/foo.api.ts

import type { RequestHandler } from "../../smf/server";

export const get: RequestHandler = ({ res }) => {
  res.end("Hello from foo!");
};

src/routes/bar.api.ts

import type { RequestHandler } from "../../smf/server";

export const get: RequestHandler = ({ res }) => {
  res.end("Hello from bar!");
};

Finally, let's update src/entry-handler.ts to use our new router:

src/entry-handler.ts

import { buildHandler } from "../smf/server";

export default buildHandler({
  "/foo": () => import("./routes/foo.api"),
  "/bar": () => import("./routes/bar.api"),
});

If you run pnpm dev now, you will get a 404 for / because we don't have a handler for it yet. But /foo and /bar should work as expected. So here we go, we have API routes.

βœ… Checkpoint: You can find the progress so far in the chapter-03 tag.

04. File system routing

Developers have a love-hate relationship with file system routing. It's simple and intuitive at first but file names make a poor configuration language. As more and more features are added, you risk ending up with monstrosities like /foo/bar/@top-bar/(..)(..)[...slug].tsx. Nevertheless, file system routing is one of the staples of metaframework design. So we'll implement it too but we'll take a hybrid approach: It will be strictly optional and the user will be able to modify automatically generated routes as they see fit.

Vite has a import.meta.glob feature that allows us to implement file system routing with very little effort. The docs say:

const modules = import.meta.glob("./dir/*.js");

The above will be transformed into the following:

// code produced by vite
const modules = {
  "./dir/foo.js": () => import("./dir/foo.js"),
  "./dir/bar.js": () => import("./dir/bar.js"),
};

This is the exact format our router expects. What a coincidence! πŸ˜„

First, we'll add the following to tsconfig.json to have Vite-specific types (like the types for import.meta.glob) available in our code:

    "jsxImportSource": "preact",
+   "types": ["vite/client"]
  }
}

Now we can add const apiRoutes = import.meta.glob("./routes/**/*.api.ts"); to src/entry-handler.ts. According to the docs, the outcome will be the same as if we had written:

const apiRoutes = {
  "./routes/foo.api.ts": () => import("./routes/foo.api.ts"),
  "./routes/bar.api.ts": () => import("./routes/bar.api.ts"),
};

As far as I can remember Vite takes care of normalizing path separators to forward slashes on Windows. So all we need to do is trim the ./routes prefix and the .api.ts suffix to convert the file names into route patterns. We will also trim /index from the end of the route patterns to make /foo.api.ts and /foo/index.api.ts equivalent (we need it at least for the root route!). We'll implement this in a prepareApiRoutes function that we'll add to the bottom of smf/server.ts. The types are lenient because import.meta.glob is not strongly typed anyway:

smf/server.ts

export function prepareApiRoutes(
  apiRoutes: Record<string, () => Promise<any>>,
): Record<string, () => Promise<ApiModule>> {
  return Object.fromEntries(
    Object.entries(apiRoutes).map(([path, importer]) => {
      // This is a bit fragile as it doesn't allow different file extensions
      // but again, good enough for Rock'n'Roll.
      let pattern = path.slice("./routes".length, -".api.ts".length);
      if (pattern.endsWith("/index")) {
        pattern = pattern.slice(0, -"/index".length);
      }

      return [pattern, importer];
    }),
  );
}

We will also need to sort the routes so that more specific routes come first. When the user was adding routes manually, we could dump this responsibility on them like Express does but since the order of the routes is not under the user's control anymore, we'll have to do it ourselves. Unfortunately, there are no universally applicable rules here: Should /foo/bar match /foo/$param or /$param/bar first? I'm inclined to say the former but it's more of an opinion than a fact. However, I'm sure everyone will agree that /foo/$a-$b/bar should come before /foo/$a/bar. I'll adapt a simplified version of the sorting rules used by Rakkas. Feel free to disagree and modify it to your liking:

function compareRoutePatterns(a: string, b: string): number {
  // Non-catch-all routes first: /foo before /$$rest
  const catchAll =
    Number(a.match(/\$\$(\w+)$/)) - Number(b.match(/\$\$(\w+)$/));
  if (catchAll) return catchAll;

  // Split into segments
  const aSegments = a.split("/");
  const bSegments = b.split("/");

  // Routes with fewer dynamic segments first: /foo/bar before /foo/$bar
  const dynamicSegments =
    aSegments.filter((segment) => segment.includes("$")).length -
    bSegments.filter((segment) => segment.includes("$")).length;
  if (dynamicSegments) return dynamicSegments;

  // Routes with fewer segments first: /foo/bar before /foo/bar
  const segments = aSegments.length - bSegments.length;
  if (segments) return segments;

  // Routes with earlier dynamic segments first: /foo/$bar before /$foo/bar
  for (let i = 0; i < aSegments.length; i++) {
    const aSegment = aSegments[i];
    const bSegment = bSegments[i];
    const dynamic =
      Number(aSegment.includes("$")) - Number(bSegment.includes("$"));
    if (dynamic) return dynamic;

    // Routes with more dynamic subsegments at this position first: /foo/$a-$b before /foo/$a
    const subsegments = aSegment.split("$").length - bSegment.split("$").length;
    if (subsegments) return subsegments;
  }

  // Equal as far as we can tell
  return 0;
}

Then we'll have to update the first few lines of buildHandler to use our comparison function:

export function buildHandler(
  apiRoutes: Record<string, () => Promise<ApiModule>>,
): RequestListener {
  // Convert into an array of [RegExp, () => Promise<ApiModule>] tuples
  const routes = Object.keys(apiRoutes)
    .sort(compareRoutePatterns)
    .map((pattern) => [patternToRegExp(pattern), apiRoutes[pattern]] as const);
  // ...

Now we can use prepareApiRoutes in src/entry-handler.ts:

src/entry-handler.ts

import { buildHandler, prepareApiRoutes } from "../smf/server";

const apiRoutes = import.meta.glob("./routes/**/*.api.ts");

export default buildHandler(prepareApiRoutes(apiRoutes));

If you run pnpm dev now, you will see that /foo and /bar still work. If you delete or rename one of them, it will be reflected in the app. That's it, we have file system routing! The user can change the contents of the apiRoutes object before passing it to buildHandler or simply not use it at all and define their own routes. Best of both worlds.

Although import.meta.glob is pretty flexible, some Vite-based metaframeworks use a custom file system routing solution. Rakkas, for example, generates a set of virtual modules for extra flexibility. SvelteKit follows a similar approach. But some do use import.meta.glob and it's a perfectly good solution for most use cases.

βœ… Checkpoint: You can find the progress so far in the chapter-04 tag.

05. Page routes

Finally, without further ado, we'll implement page routes. We'll use the .page.tsx convention for page route modules and each page route module will default export a Preact component. We'll modify our buildHandler function to accept a { apiRoutes, pageRoutes, Document, App } object instead of just apiRoutes. Document and App will be Preact components to render the HTML document and the layout around the page content respectively. The difference between Document and App is that Document will only render static HTML while App can be interactive.

Let's rename smf/server.ts to smf/server.tsx for starters so that we can use JSX in it. Then we'll add the following:

smf/server.tsx

// Somewhere near the top
import { type ComponentType, type ComponentChildren } from "preact";
import { render } from "preact-render-to-string";

// Just before `buildHandler`
export interface PageModule {
  default: ComponentType;
}

export interface HandlerOptions {
  apiRoutes: Record<string, () => Promise<ApiModule>>;
  pageRoutes: Record<string, () => Promise<PageModule>>;
  Document: ComponentType<DocumentProps>;
  App: ComponentType<AppProps>;
}

export interface DocumentProps {
  children?: ComponentChildren;
}

export interface AppProps {
  children: ComponentChildren;
}

Then we'll update buildHandler and add a renderPage helper function right after it:

smf/server.tsx

export function buildHandler(options: HandlerOptions): RequestListener {
  const pageRoutes = Object.keys(options.pageRoutes)
    .sort(compareRoutePatterns)
    .map(
      (pattern) =>
        [
          patternToRegExp(pattern),
          options.pageRoutes[pattern],
          "page",
        ] as const,
    );

  const apiRoutes = Object.keys(options.apiRoutes)
    .sort(compareRoutePatterns)
    .map(
      (pattern) =>
        [patternToRegExp(pattern), options.apiRoutes[pattern], "api"] as const,
    );

  return async function handler(req, res) {
    // These are typed as optional for some reason
    const { url = "/", method = "GET" } = req;

    // Remove query string and hash
    const path = url.match(/^[^?#]*/)![0];
    // Page routes before API routes
    const match =
      pageRoutes.find(([pattern]) => pattern.exec(path)) ??
      apiRoutes.find(([pattern]) => pattern.exec(path));

    if (!match) {
      res.statusCode = 404;
      res.end("Not found");
      return;
    }

    try {
      // Page or API?
      if (match[2] === "page") {
        const importer = match[1];
        const module = await importer();
        const html = renderPage(module.default, options.Document, options.App);
        res.statusCode = 200;
        res.setHeader("Content-Type", "text/html; charset=utf-8");
        res.end(html);
        return;
      }

      const importer = match[1];
      const module = await importer();
      const handler =
        module[method.toLowerCase() as keyof ApiModule] ?? module.all;

      if (!handler) {
        // Look ma, I'm HTTP-ly correct!
        res.statusCode = 405;
        res.end("Method not allowed");
        return;
      }

      const params = match[0].exec(path)?.groups ?? {};
      await handler({ req, res, params });
    } catch (error) {
      console.error(error);
      res.statusCode = 500;
      res.end("Internal server error");
    }
  };
}

function renderPage(
  Page: ComponentType,
  Document: ComponentType<DocumentProps>,
  App: ComponentType<AppProps>,
) {
  const document = render(
    <Document>
      <App>
        <Page />
      </App>
    </Document>,
  );
  return "<!DOCTYPE html>" + document;
}

We'll also need a preparePageRoutes function, the page route equivalent of prepareApiRoutes:

smf/server.tsx

export function preparePageRoutes(
  pageRoutes: Record<string, () => Promise<any>>,
): Record<string, () => Promise<PageModule>> {
  return Object.fromEntries(
    Object.entries(pageRoutes).map(([path, importer]) => {
      let pattern = path.slice("./routes".length, -".page.tsx".length);
      if (pattern.endsWith("/index")) {
        pattern = pattern.slice(0, -"/index".length);
      }

      return [pattern, importer];
    }),
  );
}

Then our src/App.tsx and src/Document.tsx will look like this:

src/App.tsx

import { AppProps } from "../smf/server";

export function App(props: AppProps) {
  return <div>{props.children}</div>;
}

src/Document.tsx

import { DocumentProps } from "../smf/server";

export function Document(props: DocumentProps) {
  return (
    <html>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width" />
        <title>My SMF App</title>
      </head>
      <body>
        <div id="app">{props.children}</div>
      </body>
    </html>
  );
}

Then we'll update src/entry-handler.ts to use our new buildHandler:

src/entry-handler.ts

import {
  buildHandler,
  prepareApiRoutes,
  preparePageRoutes,
} from "../smf/server";
import { Document } from "./Document";
import { App } from "./App";

const apiRoutes = prepareApiRoutes(import.meta.glob("./routes/**/*.api.ts"));
const pageRoutes = preparePageRoutes(
  import.meta.glob("./routes/**/*.page.tsx"),
);

export default buildHandler({
  apiRoutes,
  pageRoutes,
  Document,
  App,
});

Finally, we'll create our first page route:

src/routes/index.page.tsx

export default function HomePage() {
  return <h1>Hello, world!</h1>;
}

That was a lot of code but most of it was just boilerplate. If you run pnpm dev now, you will finally see our first page rendered at localhost:5173/. We're not sending any client-side JavaScript yet and we don't have a way to pass data to our pages but our router now supports both pages and API routes. Pretty cool, no?

βœ… Checkpoint: You can find the progress so far in the chapter-05 tag.

06. Hydration

We have a working SSR setup now but we need to send some client-side JavaScript and hydrate our page to make it interactive. First, let's update our home page to add interactivity:

src/routes/index.page.tsx

import { useState } from "preact/hooks";

export default function HomePage() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>Hello, world!</h1>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

When you run pnpm dev now, you will see that the page is not interactive. Let's make it interactive by first adding a script tag to src/Document.tsx:

src/Document.tsx

        <div id="app">{props.children}</div>
+       <script type="module" src="/src/entry-client.tsx" />
      </body>

Then we'll need to create src/entry-client.tsx that we just referenced:

src/entry-client.ts

alert("Hello from entry-client.tsx!");

If you run pnpm dev now, you will see the alert. Yay, our first few bytes of client-side JavaScript! We're web developers so obviously we will keep adding more until our bundle size exceeds the mass of the observable universe. But we'll get there in small increments.

Since we have only one page, we know exactly what's going to be rendered on the client. Let's start with a simple hard-coded version. Note that we're not hydrating the whole Document component, but just App:

src/entry-client.tsx

import { hydrate } from "preact";
import { App } from "./App";
import HomePage from "./routes/index.page";

hydrate(
  <App>
    <HomePage />
  </App>,
  document.getElementById("app")!,
);

If you run pnpm dev now, you will see that the page is now interactive!

Let's tackle HMR next. We'll just update our Document component to inject Vite's client script:

src/Document.tsx

        <title>My SMF App</title>
+       <script type="module" src="@vite/client" />
      </head>

That's it! Refresh the page one last time and now Vite's client script and the Preact plugin will take care of the rest. Now you can update the index.page.tsx file and the changes will be reflected without a full page reload. You can even click on the "Increment" button a few times and the counter will keep its state between refreshes. This comes in extremely handy when developing complex UIs.

Let's fix the hard-coded routing next. Since page routes need to be shared between the server and the client, we'll move them to src/page-routes.ts page first:

src/page-routes.ts

import { preparePageRoutes } from "../smf/server";

export const pageRoutes = preparePageRoutes(
  import.meta.glob("./routes/**/*.page.tsx"),
);

Our handler entry will now become:

src/entry-handler.ts

import { buildHandler, prepareApiRoutes } from "../smf/server";
import { Document } from "./Document";
import { App } from "./App";
import { pageRoutes } from "./page-routes";

const apiRoutes = prepareApiRoutes(import.meta.glob("./routes/**/*.api.ts"));

export default buildHandler({
  apiRoutes,
  pageRoutes,
  Document,
  App,
});

Then we'll start creating SMF's client runtime. But we should move the bits that will be shared between the client and the server into a smf/shared.ts file first:

smf/shared.ts

import type { ComponentType, ComponentChildren } from "preact";

export interface PageModule {
  // ...
}

export interface AppProps {
  // ...
}

export function patternToRegExp(path: string) {
  // ...
}

export function compareRoutePatterns(a: string, b: string): number {
  // ...
}

We will of course delete them from smf/server.tsx and import from smf/shared.ts instead.

Now we're good to go with smf/client.tsx:

smf/client.tsx

import { hydrate, type ComponentType } from "preact";
import {
  patternToRegExp,
  compareRoutePatterns,
  type PageModule,
  type AppProps,
} from "./shared";

export interface ClientOptions {
  App: ComponentType<AppProps>;
  pageRoutes: Record<string, () => Promise<PageModule>>;
}

export async function startClient(options: ClientOptions) {
  const { App, pageRoutes } = options;
  const routes = Object.keys(pageRoutes)
    .sort(compareRoutePatterns)
    .map((pattern) => [patternToRegExp(pattern), pageRoutes[pattern]] as const);

  console.log(routes);

  const path = location.pathname;
  const match = routes.find(([pattern]) => pattern.exec(path));

  if (!match) {
    throw new Error(`No route found for ${path}`);
  }

  const importer = match[1];
  const module = await importer();

  const Page = module.default;

  hydrate(
    <App>
      <Page />
    </App>,
    document.getElementById("app")!,
  );
}

And now our src/entry-client.tsx will become simply:

src/entry-client.tsx

import { startClient } from "../smf/client";
import { App } from "./App";
import { pageRoutes } from "./page-routes";

startClient({ App, pageRoutes }).catch((error) => {
  console.error(error);
});

We'll add another page route and a navigation menu to test our client-side routing:

src/routes/about.page.tsx

export default function AboutPage() {
  return <h1>About</h1>;
}

src/App.tsx

import type { AppProps } from "../smf/shared";

export function App(props: AppProps) {
  return (
    <div>
      <nav>
        <a href="/">Home</a> | <a href="/about">About</a>
      </nav>
      <main>{props.children}</main>
    </div>
  );
}

If everything goes well, you should be able to navigate between the two pages now! We have now something approaching a real metaframework. It could even be called useful if we had a way to pass data to our pages.

βœ… Checkpoint: You can find the progress so far in the chapter-06 tag.

Footnotes

  1. The reason I picked Preact is that there doesn't seem to be a good Vite-based Preact metaframework as of yet. ↩

About

WIP: Vite metaframework walkthrough

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published