A minimalist, extensible web microframework for TypeScript runtimes.
- File-based routing — directories in
app/map directly to URL paths - HTML templating —
{{mustache}}placeholders populated from route handler data - Nested layouts —
layout.htmlanddocument.htmlwrap pages hierarchically - Dynamic route parameters —
[param]/directories for URL segments - Plugin system — extensible architecture via abstract
Pluginclass andAdminPluginLikeinterface - Lifecycle hooks & events —
onRequest/onResponse/onError/onShutdownhooks plus an event bus for decoupled plugin communication - Graceful shutdown — SIGINT/SIGTERM draining of in-flight requests
- Multi-runtime — the runtime-agnostic core runs on Deno, Node.js, and Service Workers
- Self-contained binary — compile your entire app into a single deployable binary
- Dev mode — file watcher with automatic route reload
Ten.net is a production-ready microframework focused on reliability, performance, flexibility, and extensibility. The core is intentionally minimal — advanced features like CMS, blog, media, and audit are available as independent plugins.
- Minimal core — routing, templating, and plugin infrastructure only
- Production-ready — reliable, performant, battle-tested
- Extensible — plugin system for adding any functionality
- Multi-runtime — designed to support multiple JS/TS runtimes
The admin panel and CMS features are separate packages:
| Package | Description |
|---|---|
@leproj/tennet-cms |
Admin dashboard, page builder, widgets, auth, RBAC |
@leproj/tennet-blog |
Blog posts, categories, RSS/JSON feeds |
@leproj/tennet-media |
Media library with chunked KV storage |
@leproj/tennet-audit |
Audit logging with TTL |
Delivered:
- ✅ Reliability — custom error handler (
onError), graceful shutdown, and connection draining - ✅ Performance — route-match caching and template-shell precompilation
- ✅ Flexibility — middleware composition, response interceptors
(
onResponse), and pluggable custom renderers (setRenderer) - ✅ Extensibility — lifecycle hooks (
onRequest/onResponse/onShutdown) and an event bus for plugin communication - ✅ Node.js runtime support — run the same app on Node.js via
@leproj/tennet/node - ✅ Code obfuscation — AES-256-GCM packaging for compiled binaries
- ✅ Coverage — enforced line-coverage floor at 90% (see docs/coverage-plan.md)
In progress:
- Performance — zero-allocation hot paths (ongoing micro-optimization)
deno add jsr:@leproj/tennetOr import directly:
import { Ten } from "jsr:@leproj/tennet";Create a server entry point:
// main.ts
import { Ten } from "@leproj/tennet";
const app = Ten.net();
await app.start();Create a route with a page template:
// app/hello/route.ts
export function GET(_req: Request): Response {
return new Response(
JSON.stringify({ name: "World" }),
{ headers: { "Content-Type": "application/json" } },
);
}<!-- app/hello/page.html -->
<h1>Hello {{name}}!</h1>Run the server:
deno run --allow-all --unstable-raw-imports main.tsVisit http://localhost:8000/hello to see "Hello World!".
The app/ directory defines your routes. Each subdirectory can contain:
| File | Purpose |
|---|---|
route.ts |
Exports HTTP method handlers (GET, POST, PUT, DELETE, etc.) |
page.html |
HTML template with {{placeholder}} syntax |
layout.html |
Wrapping layout — nests via {{content}} |
document.html |
Root HTML document (typically at app/ root only) |
| Structure | Behavior |
|---|---|
route.ts + page.html |
View route — GET renders the page with data from the handler |
route.ts only |
API route — returns the raw Response from the handler |
page.html only |
Static page — renders the HTML as-is |
app/
├── page.html → /
├── hello/
│ ├── route.ts → /hello
│ └── page.html
├── form/
│ ├── route.ts → /form (POST)
│ ├── page.html → /form (GET)
│ └── congrats/
│ ├── route.ts → /form/congrats
│ └── page.html
└── api/
└── hello/
├── route.ts → /api/hello
└── [name]/
└── route.ts → /api/hello/:name
Route files export functions named after HTTP methods:
// app/api/hello/route.ts
export function GET(_req: Request): Response {
return new Response("Hello World");
}// app/form/route.ts
export async function POST(req: Request): Promise<Response> {
const formData = await req.formData();
const name = formData.get("name");
return new Response(
JSON.stringify({ name }),
{ status: 302, headers: { Location: `/form/congrats?name=${name}` } },
);
}Use [param] directories for dynamic URL segments:
// app/api/hello/[name]/route.ts
export function GET(_req: Request, ctx: {
params: { name: string };
}): Response {
return new Response(
JSON.stringify({ message: `Hello ${ctx.params.name}` }),
{ headers: { "Content-Type": "application/json" } },
);
}GET /api/hello/John returns { "message": "Hello John" }.
Page templates use {{key}} placeholders that are replaced with values from the
route handler's JSON response:
<!-- app/hello/page.html -->
<h1>Hello {{name}}!</h1>// app/hello/route.ts — returns { name: "Leandro" }
export function GET(_req: Request): Response {
return new Response(
JSON.stringify({ name: "Leandro" }),
{ headers: { "Content-Type": "application/json" } },
);
}Result: <h1>Hello Leandro!</h1>
Place a document.html at the app/ root to define the outer HTML shell. Use
{{content}} as the injection point:
<!-- app/document.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My App</title>
</head>
<body>
{{content}}
</body>
</html>If no document.html is provided, a default one is used.
Add layout.html files at any directory level. They nest from root to leaf,
each using {{content}} to wrap inner content:
app/
├── layout.html ← outermost layout
├── dashboard/
│ ├── layout.html ← wraps dashboard pages
│ └── settings/
│ └── page.html ← innermost content
Extend Ten.net with plugins:
import { Ten } from "@leproj/tennet";
const app = Ten.net();
await app.useAdmin(myAdminPlugin); // register an admin plugin
await app.start();With the CMS plugin:
import { Ten } from "@leproj/tennet";
import { AdminPlugin, PagePlugin } from "@leproj/tennet-cms";
const app = Ten.net();
await app.useAdmin(new AdminPlugin({ plugins: [PagePlugin] }));
await app.start();Hooks let you observe and shape the request pipeline; the event bus lets plugins talk to each other without direct references.
import { Ten } from "@leproj/tennet";
const app = Ten.net();
// Run before middleware/routing; return a Response to short-circuit.
app.onRequest((req) => {
if (new URL(req.url).pathname === "/health") return new Response("ok");
});
// Transform every response (response interceptor) — success, 404, and errors.
app.onResponse((_req, res) => {
const headers = new Headers(res.headers);
headers.set("X-Frame-Options", "DENY");
return new Response(res.body, { status: res.status, headers });
});
// Customize error responses; falls back to a plain 500 if it throws.
app.onError((_req, error) => {
console.error(error);
return new Response("Something went wrong", { status: 500 });
});
// Release resources during graceful shutdown (after requests drain).
app.onShutdown(async () => {
await closeDatabase();
});
// Decoupled plugin communication.
app.events.on("page:published", (slug) => console.log("published", slug));
await app.events.emit("page:published", "/about");
await app.start(); // SIGINT/SIGTERM trigger graceful shutdown by defaultBy default, view routes use {{key}}/{{{key}}} mustache substitution. Swap in
your own engine (e.g. one with loops/conditionals) with setRenderer. The
renderer receives the assembled template (page + layouts + document) and the
route handler's JSON data; i18n and Tailwind injection still run on the result.
import { Ten } from "@leproj/tennet";
const app = Ten.net();
app.setRenderer((template, data, ctx) => {
// `ctx` is { route, locale }. Return the rendered HTML (sync or async).
return template.replaceAll("[[name]]", String(data.name));
});
await app.start();The runtime-agnostic core (TenCore) runs anywhere the Fetch API is available.
Build the app's manifest on Deno (deno task build) and serve it on Node via
@leproj/tennet/node:
import { TenCore } from "@leproj/tennet/core";
import { serve } from "@leproj/tennet/node";
import manifest from "./dist/manifest.json" with { type: "json" };
const core = new TenCore({ embedded: manifest });
serve(core, { port: 3000 }); // SIGINT/SIGTERM graceful shutdown built inFor finer control, compose the lower-level helpers with your own server:
import { createServer } from "node:http";
import { createRequestListener } from "@leproj/tennet/node";
createServer(createRequestListener(core)).listen(3000);Compile your entire application — routes, templates, and static assets — into a single packaged binary:
- Collects all routes, layouts, and assets from
app/ - Compresses with gzip
- Obfuscates the manifest with AES-256-GCM packaging
- Compiles into a standalone Deno binary via
deno compile
The resulting binary has zero runtime dependencies. The generated artifact embeds the key needed to decrypt the manifest at runtime, so this is obfuscation and packaging rather than strong secret protection.
deno run -A jsr:@leproj/tennet/cli buildAdd as a task:
{
"tasks": {
"build": "deno run -A jsr:@leproj/tennet/cli build"
}
}| Option | Default | Description |
|---|---|---|
--secret |
(auto) | Obfuscation secret (auto-generated if omitted) |
--output |
./dist |
Output directory |
--app-path |
./app |
Application root directory |
--public-path |
./public |
Public/static assets directory |
--no-compile |
false |
Generate compiled TS only, skip binary |
import { Ten } from "@leproj/tennet";
const result = await Ten.build({
appPath: "./app",
output: "./dist",
secret: Deno.env.get("BUILD_SECRET"),
});
console.log(`Built ${result.stats.routes} routes`);deno task test # run tests
deno task bench:run # run benchmarks
deno task fmt # format code
deno task lint # lint code
deno task check # type checkBenchmarks run with deno task bench.
| Benchmark | Avg | Min | Max | p75 | p99 | Iterations |
|---|---|---|---|---|---|---|
| findDocumentLayoutRoot | 9.7us | 8.7us | 10.3us | 10.2us | 10.3us | 16 |
| findOrderedLayouts | 10.5us | 2.4us | 9.83ms | 10.8us | 34.1us | 47802 |
| getRegexRoute_dynamic | 927ns | 904ns | 1.5us | 921ns | 1.5us | 65 |
| getRegexRoute_static | 815ns | 789ns | 1.2us | 804ns | 1.2us | 72 |
| paramsEngine | 595ns | 530ns | 1.1us | 584ns | 1.1us | 94 |
| pathNamedParams | 337ns | 283ns | 634ns | 352ns | 569ns | 161 |
| regex_test_match | 22ns | 22ns | 42ns | 22ns | 23ns | 2302 |
| regex_test_nomatch | 17ns | 16ns | 29ns | 16ns | 19ns | 3026 |
| routerEngine_full | 7.41ms | 5.14ms | 12.63ms | 7.27ms | 12.63ms | 7 |
| toSlug | 746ns | 725ns | 1.3us | 740ns | 1.3us | 77 |
| viewEngine_data | 312.8us | 249.5us | 2.52ms | 324.3us | 444.6us | 1609 |
| viewEngine_static | 208.2us | 154.8us | 6.28ms | 218.3us | 308.0us | 2411 |
Full history tracked in
benchmarks/history.json