Skip to content

Manual Installation

oopsio edited this page Mar 7, 2026 · 2 revisions

To manually create a Jen.js app, add the following code to your package.json:

"dependencies": {
    "@jenjs/master": "^1.2.5",
    "@vue/compiler-sfc": "^3.5.28",
    "esbuild": "^0.25.0",
    "glob": "^13.0.5",
    "preact": "^10.25.4",
    "preact-render-to-string": "^6.5.13",
    "sass": "^1.97.3",
    "sirv": "^3.0.1",
    "svelte": "^5.51.3",
    "tsx": "^4.21.0",
    "vite": "^7.3.1"
  },
"devDependencies": {
    "@types/node": "^22.10.0",
    "typescript": "^5.7.2"
},

Then run npm i, pnpm i, yarn i or bun i depending on your installed package manager.

Alternatively, you can download this zip: lib.zip or run this command (needs Git installed) in your directory where you have installed the packages.

git clone https://github.com/oopsio/jenjs-lib.git lib

Next, create a file called server.js:

/*
 * This file is part of Jen.js.
 * Copyright (C) 2026 oopsio
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

import { createServer as createHttpServer } from "node:http";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import esbuild from "esbuild";

import { createApp } from "./lib/src/server/app.js";
import { log } from "./lib/src/shared/log.js";
import { printBanner } from "./lib/src/cli/banner.js";
import { createServer as createViteServer, build as buildWithVite } from "vite";
import { injectFonts } from "./lib/src/fonts/inject.js";
import { GracefulShutdown } from "./lib/src/core/lifecycle.js";
import { createTelemetry } from "./lib/src/telemetry/client.js";

// Console colors
const colors = {
  reset: "\x1b[0m",
  dim: "\x1b[2m",
  cyan: "\x1b[36m",
  red: "\x1b[31m",
};

// Timestamp helper
const ts = () => new Date().toISOString().replace("T", " ").slice(0, 19);

const __filename = fileURLToPath(import.meta.url);
const currentDir = dirname(__filename);
const rootDir = join(currentDir, ".");

// Server mode
const mode = process.argv[2] ?? "dev";
const isDev = mode === "dev";

// Embedded Minifier Logic
const Minifier = {
  html(input) {
    return input
      .replace(/<!--[\s\S]*?-->/g, "")
      .replace(/>\s+</g, "><")
      .replace(/\s+/g, " ")
      .replace(/\s*([{};:,=])\s*/g, "$1")
      .trim();
  },
  css(input) {
    return input
      .replace(/\/\*[\s\S]*?\*\//g, "")
      .replace(/\/\/.*/g, "")
      .replace(/\s*([{}:;,])\s*/g, "$1")
      .replace(/\s+/g, " ")
      .replace(/;\s*}/g, "}")
      .trim();
  },
};

// Telemetry
const telemetryDisabled =
  process.env.CI !== "true" && process.env.TELEMETRY_ENABLED !== "1";

const telemetry = createTelemetry("0.1.0", {
  endpoint: "https://telemetry-six.vercel.app/telemetry",
  disabled: telemetryDisabled,
});

if (!telemetryDisabled) {
  console.log(
    "\nJen.js collects anonymous telemetry data to improve the framework.\n" +
      "to opt-out: TELEMETRY_ENABLED=1 npm run dev\n"
  );
}

// Global config
let config = null;

async function loadConfig() {
  try {
    const cwdConfigPath = resolve(process.cwd(), "jen.config.js");
    if (existsSync(cwdConfigPath)) {
      config = (await import(cwdConfigPath)).default;
    } else {
      config = (await import(resolve(process.cwd(), "../../jen.config.js"))).default;
    }
  } catch (e) {
    config = (await import("./jen.config.js")).default;
  }
}

// Build config via esbuild if needed
async function buildConfig() {
  const configPath = join(currentDir, "jen.config.ts");
  const outdir = join(currentDir, ".esbuild");

  await esbuild.build({
    entryPoints: [configPath],
    outdir,
    format: "esm",
    platform: "node",
    target: "es2022",
    minify: true,
    jsx: "automatic",
    jsxImportSource: "preact",
    bundle: true,
    loader: { ".ts": "ts" },
    logLevel: "silent",
  });

  const configFile = join(outdir, "jen.config.js");
  config = (await import(pathToFileURL(configFile).href)).default;
}

// Main server function
async function main() {
  await loadConfig();
  telemetry.track({ command: "dev", os: process.platform });

  injectFonts(config);

  let viteServer = null;
  if (isDev) {
    viteServer = await createViteServer({
      server: {
        middlewareMode: true,
        hmr: {
          protocol: "ws",
          host: config.server.hostname,
          port: config.server.port,
        },
      },
      appType: "spa",
    });
  }

  const app = await createApp({ config, mode: isDev ? "dev" : "prod", viteServer });

  const shutdown = new GracefulShutdown();

  const server = createHttpServer(async (req, res) => {
    if (shutdown.isShuttingDown_()) {
      res.statusCode = 503;
      res.setHeader("content-type", "text/plain; charset=utf-8");
      res.end("Server is shutting down");
      return;
    }

    shutdown.trackRequest(req);
    res.on("finish", () => shutdown.releaseRequest(req));
    res.on("close", () => shutdown.releaseRequest(req));

    // Intercept response for minification
    const originalEnd = res.end;
    res.end = function (chunk, encoding, callback) {
      if (chunk) {
        const type = res.getHeader("content-type");
        if (typeof type === "string") {
          if (type.includes("text/html")) {
            try {
              chunk = Minifier.html(chunk.toString());
              res.removeHeader("content-length");
            } catch (e) {
              console.error(
                `${colors.dim}[${ts()}]${colors.reset} ${colors.red}[ERROR]${colors.reset} HTML Minification failed:`,
                e
              );
            }
          } else if (type.includes("text/css")) {
            try {
              chunk = Minifier.css(chunk.toString());
              res.removeHeader("content-length");
            } catch (e) {
              console.error(
                `${colors.dim}[${ts()}]${colors.reset} ${colors.red}[ERROR]${colors.reset} CSS Minification failed:`,
                e
              );
            }
          }
        }
      }
      return originalEnd.call(this, chunk, encoding, callback);
    };

    try {
      if (isDev && viteServer) {
        viteServer.middlewares(req, res, () => {
          app.handle(req, res).catch((err) => {
            res.statusCode = 500;
            res.setHeader("content-type", "text/plain; charset=utf-8");
            res.end("Internal Server Error\n\n" + (err?.stack ?? String(err)));
          });
        });
      } else {
        await app.handle(req, res);
      }
    } catch (err) {
      res.statusCode = 500;
      res.setHeader("content-type", "text/plain; charset=utf-8");
      res.end("Internal Server Error\n\n" + (err?.stack ?? String(err)));
    }
  });

  server.listen(config.server.port, config.server.hostname, () => {
    const addr = server.address();
    const actualPort = typeof addr === "object" && addr ? addr.port : config.server.port;
    printBanner(actualPort, isDev ? "development" : "production");
  });

  shutdown.registerSignalHandlers(async () => {
    try {
      await telemetry.flush();
      log.info("[Graceful Shutdown] Stopping HTTP server");
      server.close();
      log.info("[Graceful Shutdown] Closing app resources");
      if (app.close) await app.close();
      if (viteServer) {
        log.info("[Graceful Shutdown] Closing Vite server");
        await viteServer.close();
      }
      log.info("[Graceful Shutdown] All resources closed");
    } catch (err) {
      log.warn(`[Graceful Shutdown] Error during shutdown: ${err.message}`);
    }
  });
}

// Production build function
async function buildOnly() {
  await loadConfig();
  injectFonts(config);

  const buildStartTime = Date.now();
  telemetry.track({ command: "build", os: process.platform });

  try {
    log.info("Building with Vite...");
    await buildWithVite({
      build: {
        outDir: config.distDir || "dist",
        minify: "terser",
        sourcemap: false,
        rollupOptions: { output: { manualChunks: { vendor: ["preact"] } } },
      },
    });
    log.info("Build complete!");
    const duration = Date.now() - buildStartTime;
    telemetry.track({ command: "build", success: true, duration: Math.round(duration / 1000), os: process.platform });
    await telemetry.flush();
  } catch (err) {
    const duration = Date.now() - buildStartTime;
    telemetry.track({ command: "build", success: false, duration: Math.round(duration / 1000), error: err.message, os: process.platform });
    await telemetry.flush();
    log.error(`Build failed: ${err.message}`);
    process.exit(1);
  }
}

// Entry point
if (mode === "build") {
  buildOnly();
} else {
  main().catch((err) => {
    telemetry.track({ command: mode, error: err.message, os: process.platform });
    telemetry.flush().finally(() => process.exit(1));
  });
}

Then create a file called build.js:

/*
 * This file is part of Jen.js.
 * Copyright (C) 2026 oopsio
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { readdir, stat, readFile, writeFile } from "node:fs/promises";
import esbuild from "esbuild";

const __filename = fileURLToPath(import.meta.url);
const currentDir = dirname(__filename);
const rootDir = join(currentDir, ".");

// Embedded Minifier Logic
const Minifier = {
  html(input) {
    return input
      .replace(/<!--[\s\S]*?-->/g, "")
      .replace(/>\s+</g, "><")
      .replace(/\s+/g, " ")
      .replace(/\s*([{};:,=])\s*/g, "$1")
      .trim();
  },
  css(input) {
    return input
      .replace(/\/\*[\s\S]*?\*\//g, "")
      .replace(/\/\/.*/g, "")
      .replace(/\s*([{}:;,])\s*/g, "$1")
      .replace(/\s+/g, " ")
      .replace(/;\s*}/g, "}")
      .trim();
  },
};

async function main() {
  console.log("[BUILD] Starting build...");

  const configPath = join(currentDir, "jen.config.ts");
  const outdir = join(currentDir, ".esbuild");

  await esbuild.build({
    entryPoints: [configPath],
    outdir,
    format: "esm",
    platform: "node",
    target: "es2022",
    bundle: true,
    minify: true,
    sourcemap: true,
    loader: { ".ts": "ts" },
    logLevel: "silent",
  });

  const configFile = join(outdir, "jen.config.js");
  const config = (await import(pathToFileURL(configFile).href)).default;

  const buildPath = pathToFileURL(join(rootDir, "lib/build/build.js")).href;
  const { buildSite } = await import(buildPath);

  await buildSite({ config });

  console.log("[BUILD] Minifying output...");

  async function minifyDir(dir) {
    try {
      const files = await readdir(dir);
      for (const file of files) {
        const path = join(dir, file);
        const s = await stat(path);
        if (s.isDirectory()) {
          await minifyDir(path);
        } else if (file.endsWith(".html")) {
          const content = await readFile(path, "utf-8");
          await writeFile(path, Minifier.html(content));
        } else if (file.endsWith(".css")) {
          const content = await readFile(path, "utf-8");
          await writeFile(path, Minifier.css(content));
        }
      }
    } catch (e) {
      if (e.code !== "ENOENT") console.warn("Minification warning:", e.message);
    }
  }

  await minifyDir(join(currentDir, config.distDir || "dist"));

  console.log("Site built successfully!");
}

main().catch(console.error);

Then, create a file called jen.config.js:

const config = {
  siteDir: "site",
  distDir: "dist",
  routes: {
    fileExtensions: [".tsx", ".jsx", ".ts", ".js"],
    routeFilePattern: /^\(([^)]+)\)\.(tsx|jsx|ts|js)$/,
    enableIndexFallback: true,
  },
  rendering: {
    defaultMode: "ssg",
    defaultRevalidateSeconds: 0,
  },
  inject: {
    head: [],
    bodyEnd: [],
  },
  css: {
    globalScss: "site/styles/global.scss",
  },
  assets: {
    publicDir: "site/assets",
    cacheControl: "public, max-age=3600",
  },
  server: {
    port: process.env.PORT ? parseInt(process.env.PORT) : 0,
    hostname: "localhost",
  },
  dev: {
    liveReload: true,
  },
};

export default config;

Then add these fields to your package.json:

"type": "module",
"scripts": {
    "dev": "node server.js dev",
    "build": "node build.js"
},

Creating the site directory

Jen.js uses file-system routing, which changes based on your file's name in the site/ folder. Then, after creating the site directory, create site/layout.tsx. This file is required and must contain the <html> and <body> tags.

export default function Layout({ children }: { children: any }) {
  return (
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>My Site</title>
      </head>
      <body>
        {children}
      </body>
    </html>
  );
}

Create a home page site/(home).tsx with some initial content:

import Layout from "./layout";

export default function Home() {
  return (
    <Layout>
      <h1>Hello World</h1>
    </Layout>
  );
}

Then create a global styles file site/styles/global.scss. SCSS is an CSS preprocessor, it adds on top of CSS, all CSS code compatible with it:

/* Global styles */
body {
  font-family:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  margin: 0;
  padding: 0;
}

Now create a site/assets directory. This is the public directory, similar with how would you use it in Next.js. You can change this in the jen.config.js file.

Clone this wiki locally