All files / plainweb/src file-router.ts

41.91% Statements 70/167
89.47% Branches 17/19
40% Functions 2/5
41.91% Lines 70/167

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 2121x 1x 1x 1x 1x   1x                                                                                                                                                                                                     1x 10x 10x 10x     10x 10x 8x 10x 2x 2x 8x 8x 8x 8x 8x 8x 27x 1x 1x 27x 3x 3x 23x 8x 8x 8x 8x 8x   1x 1x 1x 1x     1x 1x   1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x     2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x     1x 1x 2x 1x 1x                                                                  
import fs from "node:fs/promises";
import path from "node:path";
import express, { type Router } from "express";
import { getLogger } from "log";
import { type Handler, handleResponse } from "./handler";
 
const log = getLogger("router");
 
interface FileRouteHandler {
  GET?: Handler;
  POST?: Handler;
}
 
export type FileRoute = { filePath: string; routePath: string };
 
export type LoadedFileRoute = {
  filePath: string;
  GET?: Handler;
  POST?: Handler;
};
 
async function readRoutesFromFs(opts: {
  baseDir: string;
  ignorePatterns?: string[];
  currentDir?: string;
}): Promise<FileRoute[]> {
  const { baseDir, currentDir } = opts;
  const routes: FileRoute[] = [];
 
  let files: string[] = [];
  try {
    files = await fs.readdir(currentDir || baseDir);
  } catch (e) {
    log.error(`error reading directory: ${currentDir || baseDir}`);
    log.error("see the error below");
    throw e;
  }
 
  for (const file of files) {
    const fullFilePath = path.join(currentDir || baseDir, file);
    const stat = await fs.stat(fullFilePath);
 
    if (stat.isDirectory()) {
      log.debug(`found directory: ${fullFilePath}`);
      const subRoutes = await readRoutesFromFs({
        baseDir,
        currentDir: fullFilePath,
      });
      routes.push(...subRoutes);
    } else if (stat.isFile() && file.endsWith(".tsx")) {
      const relativePath = path.relative(baseDir, fullFilePath);
      log.debug(`discovered file route: ${relativePath}`);
      routes.push({ filePath: fullFilePath, routePath: relativePath });
    }
  }
 
  return routes;
}
 
async function loadFileRoutes(routes: FileRoute[]): Promise<LoadedFileRoute[]> {
  const loadedRoutes: LoadedFileRoute[] = [];
 
  for (const { filePath } of routes) {
    let loadedFileRoute: LoadedFileRoute | undefined;
    try {
      const module = (await import(filePath)) as
        | FileRouteHandler
        | { default: FileRouteHandler };
      const handler = (module as { default: FileRouteHandler })?.default
        ? (module as { default: FileRouteHandler }).default
        : (module as FileRouteHandler);
      if (handler?.GET) {
        if (typeof handler.GET !== "function") {
          throw new Error(`GET export in route ${filePath} is not a function`);
        }
        loadedFileRoute = { filePath, GET: handler.GET };
      }
 
      if (handler?.POST) {
        if (typeof handler.POST !== "function") {
          throw new Error(`POST export in route ${filePath} is not a function`);
        }
        if (loadedFileRoute) {
          loadedFileRoute.POST = handler.POST;
        } else {
          loadedFileRoute = { filePath, POST: handler.POST };
        }
      }
 
      if (!loadedFileRoute) {
        log.error(`no exported GET or POST functions found in ${filePath}`);
      } else {
        loadedRoutes.push(loadedFileRoute);
      }
    } catch (e) {
      log.error(e);
      throw new Error(
        `double check the route at ${filePath}. Make sure to export a GET or POST function.`,
      );
    }
  }
 
  return loadedRoutes;
}
 
export function getExpressRoutePath({
  dir,
  filePath,
}: {
  dir: string;
  filePath: string;
}): string {
  if (filePath === `${dir}/index.tsx`) return "/";
  let relativeFilePath = filePath.replace(dir, "");
  if (!dir.startsWith("/")) {
    relativeFilePath = filePath.replace(`/${dir}`, "");
  }
  const expressPath = relativeFilePath
    .replace(/\/index.tsx$/, "")
    .replace(/index.tsx$/, "")
    .replace(/\.tsx$/, "")
    .split("/")
    .map((part) => {
      if (part.startsWith("[...") && part.endsWith("]")) {
        return `:${part.slice(4, -1)}(*)`;
      }
      if (part.startsWith("[") && part.endsWith("]")) {
        return `:${part.slice(1, -1)}`;
      }
      return part;
    })
    .join("/");
  log.debug(`"${dir}": ${relativeFilePath} -> ${expressPath}`);
  return expressPath;
}
 
export function expressRouter({
  loadedFileRoutes,
  dir,
}: {
  loadedFileRoutes: LoadedFileRoute[];
  dir: string;
}): Router {
  const router = express.Router();
 
  for (const route of loadedFileRoutes) {
    const routePath = getExpressRoutePath({
      dir,
      filePath: route.filePath,
    });
    if (route.GET) {
      router.get(routePath, async (req, res, next) => {
        try {
          const userResponse = await (route as { GET: Handler }).GET({
            req,
            res,
          });
          await handleResponse(res, userResponse);
        } catch (e) {
          next(e);
        }
      });
    }
    if (route.POST) {
      router.post(routePath, async (req, res, next) => {
        try {
          const userResponse = await (route as { POST: Handler }).POST({
            req,
            res,
          });
          await handleResponse(res, userResponse);
        } catch (e) {
          next(e);
        }
      });
    }
  }
  return router;
}
 
type FileRouterOpts = {
  dir: string;
  ignorePatterns?: string[];
  fileRoutes?: FileRoute[];
  loadedFileRoutes?: LoadedFileRoute[];
};
 
export async function fileRouter(
  opts: FileRouterOpts,
): Promise<express.RequestHandler> {
  const dir = opts.dir;
  if (opts.loadedFileRoutes?.length) {
    return expressRouter({
      loadedFileRoutes: opts.loadedFileRoutes,
      dir,
    });
  }
  if (opts.fileRoutes?.length) {
    const loadedFileRoutes = await loadFileRoutes(opts.fileRoutes);
    return expressRouter({
      loadedFileRoutes: loadedFileRoutes,
      dir,
    });
  }
  const fileRoutes = await readRoutesFromFs({
    baseDir: dir,
    ignorePatterns: opts.ignorePatterns,
  });
  const loadedFileRoutes = await loadFileRoutes(fileRoutes);
  return expressRouter({ loadedFileRoutes, dir });
}