Skip to content
LeandrodaSilvaPublic

About

A minimalist Deno web microframework with file-based routing, HTML templating, nested layouts, and a plugin system

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Ten.net

CI JSR JSR Score Coverage License: MIT

A minimalist, extensible web microframework for TypeScript runtimes.

Features

  • File-based routing — directories in app/ map directly to URL paths
  • HTML templating — {{mustache}} placeholders populated from route handler data
  • Nested layouts — layout.html and document.html wrap pages hierarchically
  • Dynamic route parameters — [param]/ directories for URL segments
  • Plugin system — extensible architecture via abstract Plugin class and AdminPluginLike interface
  • Lifecycle hooks & events — onRequest/onResponse/onError/onShutdown hooks 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

Vision

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.

Design Principles

  • 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

Plugin Ecosystem

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

Roadmap

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)

Installation

deno add jsr:@leproj/tennet

Or import directly:

import { Ten } from "jsr:@leproj/tennet";

Quick Start

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.ts

Visit http://localhost:8000/hello to see "Hello World!".

File-Based Routing

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)

Route types

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

Directory-to-URL mapping

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 Handlers

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}` } },
  );
}

Dynamic Parameters

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" }.

Templates

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>

Layouts

Document layout

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.

Nested layouts

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

Plugins

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();

Lifecycle Hooks & Events

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 default

Custom Template Renderer

By 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();

Running on Node.js

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 in

For 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);

Building for Production

Compile your entire application — routes, templates, and static assets — into a single packaged binary:

  1. Collects all routes, layouts, and assets from app/
  2. Compresses with gzip
  3. Obfuscates the manifest with AES-256-GCM packaging
  4. 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.

CLI

deno run -A jsr:@leproj/tennet/cli build

Add as a task:

{
  "tasks": {
    "build": "deno run -A jsr:@leproj/tennet/cli build"
  }
}

CLI options

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

Programmatic API

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`);

Development

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 check

Performance

Benchmarks 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

License

MIT

About

A minimalist Deno web microframework with file-based routing, HTML templating, nested layouts, and a plugin system

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages