-
Notifications
You must be signed in to change notification settings - Fork 0
Manual Installation
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 libNext, 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"
},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.