License: Public Domain (CC0)
Version: 0.1.0-draft
PHP earned its place in web history not because it was elegant, but because it was immediately useful. Its defining virtues were:
These qualities created a development loop that felt like a REPL for the web. You didn't "build" an app — you tinkered with it, live, with instant feedback.
But PHP's strengths came bundled with limitations:
The goal of this project is to preserve the spirit of PHP while modernizing everything else, using Node + TypeScript as the foundation.
The core idea is simple:
Bring PHP's frictionless iteration and deployment model into the TypeScript + Node ecosystem, enhanced with modern tooling, interactive workflows, and programmable safety.
This means:
The result is a system that feels like:
All fused into a single, coherent developer experience.
Two ways to use tsweb:
Single Page Applications have their place, but they come with friction:
This is the opposite of PHP's "edit, refresh, done" simplicity.
The hypermedia approach returns to the web's original model:
Modern libraries like HTMX, Hotwire, and Unpoly make this approach feel modern without the SPA complexity.
tsweb is hypermedia-friendly, not hypermedia-required.
What tsweb provides:
// Server-rendered HTML with type-safe templates
import { html, render } from "tsweb/std/html";
export default async function handler(req: Request): Promise<Response> {
const users = await getUsers();
return render(html`
<ul id="user-list">
${users.map(u => html`<li>${u.name}</li>`)}
</ul>
<!-- HTMX: load more without page refresh -->
<button hx-get="/users?page=2"
hx-target="#user-list"
hx-swap="beforeend">
Load More
</button>
`);
}What tsweb does NOT provide:
If you want SPAs: Use passthrough mode with Next.js, Remix, or whatever. tsweb stays out of the way.
| PHP's wins | tsweb equivalent |
|---|---|
| Edit file, refresh browser | Hot reload, no build step |
echo "<div>$name</div>" | html\<div>${name}</div>`` |
| Server renders everything | Server renders everything |
| Simple mental model | Simple mental model |
The hypermedia approach is PHP's model with modern ergonomics. That's the point.
tsweb is closer to a toolkit than a framework:
| Frameworks (Rails, Laravel, Next) | Toolkits (PHP, tsweb) |
|---|---|
| "Put files here, name them this" | "Here are primitives, go" |
| Generators and scaffolding | Just write code |
| Convention over configuration | Explicit over magic |
| Learn the framework's way | Use what you need |
| Hard to escape when it doesn't fit | Easy to drop pieces |
tsweb provides:
tsweb does NOT prescribe:
routes/If you want more structure, add it. If you want less, ignore what you don't need.
For developers with a "PHP mindset" who want minimal abstraction, everything works at the lowest level:
// routes/users.ts — as simple as it gets
import { db } from "lib/db";
export default async function handler(req: Request): Promise<Response> {
// Just query the database
const result = await db.query("SELECT * FROM users WHERE active = true");
// Just build HTML
let html = "<html><body><h1>Users</h1><ul>";
for (const user of result.rows) {
html += `<li>${escapeHtml(user.name)}</li>`;
}
html += "</ul></body></html>";
// Just return a response
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!
);
}No template engine required. No ORM required. No abstractions required.
The helpers (html, json, db.query) are conveniences, not requirements. You can always drop down to:
Response objectsfetch() callsThe rule: Every abstraction is optional. Every layer can be bypassed.
SPAs moved state to the client, which created complexity:
┌─────────────────────────────────────────────────────────────┐
│ SPA State Hell │
├─────────────────────────────────────────────────────────────┤
│ Component A Component B Component C │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ state │◄────────►│ state │◄────────►│ state │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Global State (Redux/Zustand) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Server State (React Query) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ URL State (Router) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ... and it still needs to sync with ... │
│ THE DATABASE │
└─────────────────────────────────────────────────────────────┘Every SPA eventually invents mechanisms to sync client state back to the server. Why not just... use the server?
┌─────────────────────────────────────────────────────────────┐
│ Hypermedia State Model │
├─────────────────────────────────────────────────────────────┤
│ │
│ URL (query params, path) ──────► Server Handler │
│ │ │
│ ▼ │
│ Database │
│ │ │
│ ▼ │
│ HTML Response │
│ │ │
│ ▼ │
│ Browser (display) │
│ │
│ That's it. That's the whole model. │
│ │
└─────────────────────────────────────────────────────────────┘State lives in:
Pattern 1: URL as State
// routes/users.ts
export default async function handler(req: Request): Promise<Response> {
// State is in the URL
const page = parseInt(req.query.get("page") ?? "1");
const sort = req.query.get("sort") ?? "name";
const filter = req.query.get("filter") ?? "";
const users = await getUsers({ page, sort, filter });
return render(html`
<div id="user-list">
<!-- Sort controls update the URL -->
<select hx-get="/users" hx-target="#user-list" name="sort">
<option value="name" ${sort === "name" ? "selected" : ""}>Name</option>
<option value="date" ${sort === "date" ? "selected" : ""}>Date</option>
</select>
<!-- Search updates the URL -->
<input type="search" name="filter" value="${filter}"
hx-get="/users" hx-target="#user-list"
hx-trigger="keyup changed delay:300ms">
<!-- Content -->
<ul>
${users.map(u => html`<li>${u.name}</li>`)}
</ul>
<!-- Pagination links are just URLs -->
<a href="/users?page=${page - 1}&sort=${sort}">Previous</a>
<a href="/users?page=${page + 1}&sort=${sort}">Next</a>
</div>
`);
}Bookmarkable. Shareable. Back button works. No client-side state.
Pattern 2: Forms Submit to Server
// routes/POST.users.ts
export default async function handler(req: Request): Promise<Response> {
const form = await req.formData();
// Validate
const errors = validate(form);
if (errors.length > 0) {
// Return the form with errors (server re-renders)
return render(userForm({ values: form, errors }));
}
// Save
const user = await createUser({
name: form.get("name"),
email: form.get("email"),
});
// Redirect (or return partial for HTMX)
return redirect(`/users/${user.id}`);
}No useState. No onChange handlers. No controlled components. Just forms.
Pattern 3: Session for User State
// routes/cart.ts
export default async function handler(req: Request): Promise<Response> {
const session = await req.session();
const cart = session.get("cart") ?? [];
return render(html`
<div id="cart">
<h2>Your Cart (${cart.length} items)</h2>
${cart.map(item => html`
<div>
${item.name} - $${item.price}
<button hx-delete="/cart/${item.id}" hx-target="#cart">
Remove
</button>
</div>
`)}
</div>
`);
}
// routes/POST.cart.[id].ts
export default async function handler(req: Request): Promise<Response> {
const session = await req.session();
const cart = session.get("cart") ?? [];
const product = await getProduct(req.params.id);
cart.push(product);
session.set("cart", cart);
// Return updated cart HTML
return render(cartPartial(cart));
}Session is server-side. Cart state survives page refreshes. No localStorage sync issues.
Pattern 4: Ephemeral UI State with Alpine.js
For truly local, ephemeral state (dropdowns, modals, tabs), use a tiny client library:
<!-- Alpine.js for local UI state only -->
<script src="https://unpkg.com/alpinejs" defer></script>
<div x-data="{ open: false }">
<button @click="open = !open">Toggle Menu</button>
<nav x-show="open" @click.outside="open = false">
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
</div>This is ~15KB, no build step, and handles 90% of "I need a dropdown" cases.
The rule: If state needs to persist or be shared, it goes on the server. If it's purely visual and ephemeral (dropdown open/closed), use minimal client JS.
The hypermedia model struggles with:
For these, use passthrough mode with an appropriate SPA framework. tsweb won't fight you.
Since the server is the source of truth, the database schema is your state model:
-- This IS your state. Not a "model" of it. The actual state.
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID REFERENCES users(id),
title TEXT NOT NULL,
content TEXT NOT NULL,
published_at TIMESTAMPTZ, -- NULL = draft
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id UUID REFERENCES users(id),
data JSONB DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL
);No ORM required. No "entities" that mirror the schema. The schema is the source of truth.
tsweb is designed to be LLM-friendly from day one. This means:
Every tsweb project includes a SYSTEM.md that describes the project to LLMs:
# Project: my-app
## Stack
- tsweb (hypermedia-oriented TypeScript web toolkit)
- PostgreSQL
- HTMX for dynamic updates
## Structure
- routes/ — file-based routing (file = endpoint)
- lib/ — shared code (auto-imported)
- static/ — static files
## Conventions
- Handlers export a default async function: (req: Request) => Promise<Response>
- Use `html` tagged template for HTML (auto-escaped)
- Use `db.query()` for database access (raw SQL)
- State lives in: URL params, database, session
- No client-side state management
## Database Schema
[automatically generated from schema.sql or migrations]
## Current Routes
[automatically generated from routes/]
## Examples
[key patterns from the codebase]This file is:
tsweb asktsweb ask Command# Ask questions about your project
$ tsweb ask "How do I add authentication?"
# Based on your project (hypermedia + PostgreSQL), here's how to add auth:
1. Create a sessions table:
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
2. Create login/logout routes:
// routes/POST.login.ts
export default async function handler(req: Request): Promise<Response> {
const form = await req.formData();
const user = await verifyCredentials(form.get("email"), form.get("password"));
if (!user) {
return render(loginForm({ error: "Invalid credentials" }));
}
const session = await req.session();
await session.regenerate();
session.set("userId", user.id);
return redirect("/dashboard");
}
3. Create a middleware helper:
// lib/auth.ts
export async function requireAuth(req: Request): Promise<User | Response> {
const session = await req.session();
const userId = session.get("userId");
if (!userId) {
return redirect("/login");
}
return await getUser(userId);
}
Would you like me to generate these files?tsweb generate CommandLLM-powered scaffolding:
# Generate a CRUD resource
$ tsweb generate resource posts
Creating:
routes/posts/index.ts — list all posts
routes/posts/[id].ts — show single post
routes/posts/new.ts — new post form
routes/POST.posts.ts — create post
routes/PUT.posts.[id].ts — update post
routes/DELETE.posts.[id].ts — delete post
lib/posts.ts — database queries
Also adding to schema:
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Proceed? [y/N]# Generate from description
$ tsweb generate "a blog with posts and comments, posts have titles and markdown content, comments belong to posts and have author names"
Analyzing requirements...
I'll create:
Database:
- posts (id, title, content, published_at, created_at)
- comments (id, post_id, author_name, content, created_at)
Routes:
- /posts — list published posts
- /posts/:id — show post with comments
- /admin/posts — manage posts (drafts + published)
- /admin/posts/new — create post
- /admin/posts/:id/edit — edit post
- POST /posts/:id/comments — add comment
Shall I generate these files?tsweb explain CommandGet explanations of existing code:
$ tsweb explain routes/api/orders.ts
This handler processes POST requests to /api/orders.
Flow:
1. Parses JSON body expecting { items: [{ productId, quantity }] }
2. Validates that all products exist and are in stock
3. Creates an order record in the database
4. Creates order_item records for each item
5. Returns the created order as JSON
Dependencies:
- lib/db.ts — database connection
- lib/products.ts — getProduct(), decrementStock()
- lib/orders.ts — createOrder()
Potential issues:
- No authentication check (anyone can create orders)
- No transaction wrapping (partial failures possible)
- No input validation on quantity (could be negative)
Suggested fixes:
[code suggestions...]The LLM knows your database schema:
$ tsweb ask "How do I show a user's recent orders?"
Looking at your schema:
- users (id, email, name)
- orders (id, user_id, status, created_at)
- order_items (id, order_id, product_id, quantity, price)
- products (id, name, price)
Here's a handler that shows a user's recent orders with items:
// routes/account/orders.ts
import { requireAuth } from "lib/auth";
import { html, render } from "tsweb/std/html";
import { db } from "lib/db";
export default async function handler(req: Request): Promise<Response> {
const user = await requireAuth(req);
if (user instanceof Response) return user;
const orders = await db.query(`
SELECT
o.id, o.status, o.created_at,
json_agg(json_build_object(
'name', p.name,
'quantity', oi.quantity,
'price', oi.price
)) as items
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.user_id = $1
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT 10
`, [user.id]);
return render(html`
<h1>Your Recent Orders</h1>
${orders.rows.map(order => html`
<div class="order">
<h3>Order ${order.id.slice(0, 8)} — ${order.status}</h3>
<ul>
${order.items.map(item => html`
<li>${item.name} × ${item.quantity} — $${item.price}</li>
`)}
</ul>
</div>
`)}
`);
}// tsweb.config.ts
export default defineConfig({
llm: {
provider: "anthropic", // or "openai", "ollama"
model: "claude-sonnet-4-20250514",
// For air-gapped environments
// provider: "ollama",
// model: "codellama:13b",
// endpoint: "http://localhost:11434",
// What context to include
context: {
systemMd: true, // Include SYSTEM.md
schema: true, // Include database schema
routes: true, // Include route list
relevantFiles: true, // Include files related to query
},
},
});The goal: an LLM that knows tsweb can be a competent junior developer on your project.
Local iteration should feel like breathing:
tsweb dev starts everything--inspect)No build step. No bundler. No config.
Deployment should be:
Each request runs in a fresh VM context:
A cohesive TS-native stdlib:
Everything should "just work":
┌──────────────────────────────────────────────────────────────┐
│ tsweb CLI │
│ dev, deploy, repl, logs, diff, init │
└─────────────────────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Environment & Friction Engine │
│ staging/prod rules, hooks, confirmations │
└─────────────────────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Secure Deployment Channel │
│ signed payloads, key-based auth │
└─────────────────────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Remote TS Runtime │
│ VM pool, TS loader, hot reload │
└──────────────────────────────────────────────────────────────┘ HTTP Request
│
▼
┌───────────────────────────────────────┐
│ HTTP Server (Node) │
│ (long-lived process) │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ VM Context Pool │
│ acquire() → context → release() │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ TS Loader (swc) │
│ .ts → .js (cached, incremental) │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ Router Resolution │
│ URL → handler file → execute │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ Handler Execution │
│ (isolated VM, fresh globals) │
└───────────────────┬───────────────────┘
│
▼
ResponseA fast, production-grade TS loader using swc:
// internal: loader.ts
import { transformSync } from "@swc/core";
import { createHash } from "crypto";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
interface LoaderOptions {
cacheDir: string;
target: "es2022" | "es2023";
sourceMaps: boolean;
}
const defaultOptions: LoaderOptions = {
cacheDir: ".tsweb/cache",
target: "es2022",
sourceMaps: true,
};
export function loadTypeScript(
filePath: string,
options: Partial<LoaderOptions> = {}
): string {
const opts = { ...defaultOptions, ...options };
const source = readFileSync(filePath, "utf-8");
const hash = createHash("sha256").update(source).digest("hex").slice(0, 16);
const cachePath = join(opts.cacheDir, `${hash}.js`);
// Cache hit
if (existsSync(cachePath)) {
return readFileSync(cachePath, "utf-8");
}
// Transform
const result = transformSync(source, {
filename: filePath,
jsc: {
parser: { syntax: "typescript", tsx: false, decorators: true },
target: opts.target,
keepClassNames: true,
},
sourceMaps: opts.sourceMaps ? "inline" : false,
});
// Cache write
mkdirSync(opts.cacheDir, { recursive: true });
writeFileSync(cachePath, result.code);
return result.code;
}Key Properties:
Node's VM module provides per-request isolation:
// internal: vm-pool.ts
import { createContext, runInContext, Context } from "vm";
import { createRequire } from "module";
interface ContextOptions {
timeout: number;
memoryLimit: number; // MB
globals: Record<string, unknown>;
}
interface PooledContext {
context: Context;
id: string;
createdAt: number;
}
const defaultContextOptions: ContextOptions = {
timeout: 30_000,
memoryLimit: 128,
globals: {},
};
export class VMContextPool {
private pool: PooledContext[] = [];
private maxSize: number;
private options: ContextOptions;
constructor(maxSize = 10, options: Partial<ContextOptions> = {}) {
this.maxSize = maxSize;
this.options = { ...defaultContextOptions, ...options };
}
/**
* Acquire a fresh context for request handling.
* Contexts are NOT reused — each request gets a clean slate.
*/
acquire(): PooledContext {
const context = createContext({
// Standard globals
console: this.createScopedConsole(),
setTimeout,
setInterval,
clearTimeout,
clearInterval,
Buffer,
URL,
URLSearchParams,
TextEncoder,
TextDecoder,
fetch, // Node 18+ native fetch
crypto: globalThis.crypto,
// tsweb globals (injected per-request)
Request: null, // populated at request time
Response: null,
Headers: null,
// User-provided globals
...this.options.globals,
});
const pooled: PooledContext = {
context,
id: crypto.randomUUID(),
createdAt: Date.now(),
};
this.pool.push(pooled);
return pooled;
}
/**
* Release and destroy a context after request completion.
*/
release(pooled: PooledContext): void {
const index = this.pool.indexOf(pooled);
if (index !== -1) {
this.pool.splice(index, 1);
}
// Context will be garbage collected
}
/**
* Execute code in a context with timeout protection.
*/
execute(pooled: PooledContext, code: string, filename: string): unknown {
return runInContext(code, pooled.context, {
filename,
timeout: this.options.timeout,
displayErrors: true,
});
}
private createScopedConsole() {
// Returns a console that prefixes output with context ID
// and can be captured for logging
return {
log: (...args: unknown[]) => console.log("[req]", ...args),
error: (...args: unknown[]) => console.error("[req]", ...args),
warn: (...args: unknown[]) => console.warn("[req]", ...args),
info: (...args: unknown[]) => console.info("[req]", ...args),
};
}
}Key Properties:
Every route handler exports a single function:
// routes/hello.ts
import { Request, Response, json } from "tsweb/std";
export default async function handler(req: Request): Promise<Response> {
const name = req.query.get("name") ?? "World";
return json({ message: `Hello, ${name}!` });
}Handler Signature:
type Handler = (req: Request) => Response | Promise<Response>;Why a single default export?
my-app/
├── tsweb.config.ts # Optional configuration
├── routes/ # File-based routing
│ ├── index.ts # GET /
│ ├── hello.ts # GET /hello
│ ├── api/
│ │ ├── users.ts # GET /api/users
│ │ └── users/
│ │ └── [id].ts # GET /api/users/:id
│ └── POST.login.ts # POST /login
├── lib/ # Shared code (auto-imported)
│ └── db.ts
├── static/ # Static files (served directly)
│ └── style.css
└── .tsweb/ # Generated (gitignored)
├── cache/ # Compiled TS cache
└── keys/ # Deployment keys| File Path | HTTP Method | URL Pattern |
|---|---|---|
routes/index.ts | GET | / |
routes/about.ts | GET | /about |
routes/api/users.ts | GET | /api/users |
routes/api/users/[id].ts | GET | /api/users/:id |
routes/POST.login.ts | POST | /login |
routes/PUT.api.users.[id].ts | PUT | /api/users/:id |
routes/api/[...path].ts | GET | /api/* (catch-all) |
Method Prefixes:
POST. = POSTPUT. = PUTDELETE. = DELETEPATCH. = PATCHDynamic Segments:
[param] = named parameter[...param] = catch-all (rest) parameterlib/ DirectoryFiles in lib/ are automatically available to all handlers without explicit imports:
// lib/db.ts
import { Pool } from "pg";
export const db = new Pool({
connectionString: process.env.DATABASE_URL,
});
export async function getUser(id: string) {
const result = await db.query("SELECT * FROM users WHERE id = $1", [id]);
return result.rows[0];
}// routes/api/users/[id].ts
import { Request, Response, json, notFound } from "tsweb/std";
import { getUser } from "lib/db"; // auto-resolved
export default async function handler(req: Request): Promise<Response> {
const user = await getUser(req.params.id);
if (!user) return notFound();
return json(user);
}Why auto-import lib/?
tsweb.config.tsConfiguration is optional. Sensible defaults cover most cases.
// tsweb.config.ts
import { defineConfig } from "tsweb";
export default defineConfig({
// Server settings
server: {
port: 3000,
host: "0.0.0.0",
// Request limits
bodyLimit: "10mb",
timeout: 30_000,
},
// TypeScript loader settings
typescript: {
target: "es2022",
strict: true,
paths: {
"~/*": ["./src/*"],
},
},
// VM context settings
vm: {
timeout: 30_000,
memoryLimit: 128, // MB per request
poolSize: 10,
},
// Environments and deployment
environments: {
staging: {
url: "https://staging.example.com",
friction: ["confirm_environment", "show_diff"],
},
prod: {
url: "https://example.com",
friction: [
"confirm_environment",
"show_diff",
"require_clean_working_tree",
"require_on_main",
"double_confirm",
"type_environment_name",
],
},
},
// Static file serving
static: {
dir: "static",
prefix: "/static",
maxAge: 86400,
},
// Development settings
dev: {
open: true, // Open browser on start
liveReload: true,
},
});# .env (local development, gitignored)
DATABASE_URL=postgres://localhost/myapp
SESSION_SECRET=dev-secret-change-me
DEBUG=tsweb:*
# .env.staging
DATABASE_URL=postgres://staging-db.example.com/myapp
# .env.prod
DATABASE_URL=postgres://prod-db.example.com/myappEnvironment files are loaded automatically based on the current environment.
If no tsweb.config.ts exists:
const defaults = {
// Mode: "full" uses tsweb stdlib, "passthrough" uses your framework
mode: "full",
server: {
port: process.env.PORT ?? 3000,
host: "0.0.0.0",
bodyLimit: "1mb",
timeout: 30_000,
},
typescript: {
target: "es2022",
strict: true,
},
vm: {
timeout: 30_000,
memoryLimit: 128,
poolSize: 10,
},
environments: {
staging: {
friction: ["confirm_environment"],
},
prod: {
friction: ["confirm_environment", "show_diff", "double_confirm"],
},
},
static: {
dir: "static",
prefix: "/static",
},
dev: {
open: false,
liveReload: true,
},
};For using your own framework (Express, Fastify, Hono, etc.):
// tsweb.config.ts
import { defineConfig } from "tsweb";
export default defineConfig({
mode: "passthrough",
// Your framework's entry point
entry: "./server.ts",
// tsweb stays out of the way, but you still get:
// - TypeScript compilation (no build step)
// - Hot reload
// - Deployment system
// - --inspect for Chrome DevTools
// Optional: opt into specific tsweb features
features: {
metrics: true, // Expose /_tsweb/metrics
health: true, // Expose /_tsweb/health
logging: false, // Let your framework handle logging
tracing: false, // Let your framework handle tracing
},
environments: {
staging: { /* ... */ },
prod: { /* ... */ },
},
});A cohesive, batteries-included API that feels natural to TypeScript developers.
Based on the WHATWG Fetch API with ergonomic extensions:
// tsweb/std/request.ts
export interface Request {
// Standard properties
readonly method: string;
readonly url: string;
readonly headers: Headers;
readonly body: ReadableStream<Uint8Array> | null;
// Convenience accessors
readonly params: Map<string, string>; // Route params (/users/:id)
readonly query: URLSearchParams; // Query string
readonly path: string; // URL path without query
readonly host: string;
readonly protocol: "http" | "https";
// Body parsing (cached after first call)
json<T = unknown>(): Promise<T>;
text(): Promise<string>;
formData(): Promise<FormData>;
arrayBuffer(): Promise<ArrayBuffer>;
// Cookies
readonly cookies: CookieJar;
// Session (lazy-loaded)
session<T = Record<string, unknown>>(): Promise<Session<T>>;
// Context (per-request storage)
readonly ctx: Map<string, unknown>;
}// tsweb/std/response.ts
// Response factory functions (preferred over `new Response()`)
export function json<T>(data: T, init?: ResponseInit): Response;
export function html(content: string, init?: ResponseInit): Response;
export function text(content: string, init?: ResponseInit): Response;
export function redirect(url: string, status?: 301 | 302 | 303 | 307 | 308): Response;
// Error responses
export function notFound(message?: string): Response;
export function badRequest(message?: string): Response;
export function unauthorized(message?: string): Response;
export function forbidden(message?: string): Response;
export function serverError(message?: string): Response;
// Streaming
export function stream(
body: ReadableStream | AsyncIterable<Uint8Array>,
init?: ResponseInit
): Response;
// File downloads
export function file(
path: string,
options?: { filename?: string; contentType?: string }
): Promise<Response>;// routes/api/users.ts
import { Request, Response, json, badRequest } from "tsweb/std";
import { db } from "lib/db";
export default async function handler(req: Request): Promise<Response> {
const limit = parseInt(req.query.get("limit") ?? "10");
if (limit > 100) {
return badRequest("Limit cannot exceed 100");
}
const users = await db.query("SELECT * FROM users LIMIT $1", [limit]);
return json({
users: users.rows,
count: users.rowCount,
});
}// routes/POST.upload.ts
import { Request, Response, json, badRequest } from "tsweb/std";
import { saveFile } from "lib/storage";
export default async function handler(req: Request): Promise<Response> {
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return badRequest("No file provided");
}
if (file.size > 10 * 1024 * 1024) {
return badRequest("File too large (max 10MB)");
}
const url = await saveFile(file);
return json({ url }, { status: 201 });
}// tsweb/std/cookies.ts
export interface CookieJar {
get(name: string): string | undefined;
getAll(): Record<string, string>;
set(name: string, value: string, options?: CookieOptions): void;
delete(name: string, options?: CookieDeleteOptions): void;
}
export interface CookieOptions {
maxAge?: number; // Seconds
expires?: Date;
path?: string;
domain?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: "strict" | "lax" | "none";
}// Usage in handler
export default async function handler(req: Request): Promise<Response> {
const theme = req.cookies.get("theme") ?? "light";
// Setting cookies happens on the response
const res = json({ theme });
res.cookies.set("lastVisit", new Date().toISOString(), {
maxAge: 60 * 60 * 24 * 365, // 1 year
httpOnly: true,
});
return res;
}// tsweb/std/session.ts
export interface Session<T = Record<string, unknown>> {
readonly id: string;
data: T;
get<K extends keyof T>(key: K): T[K] | undefined;
set<K extends keyof T>(key: K, value: T[K]): void;
delete<K extends keyof T>(key: K): void;
clear(): void;
// Explicit save (auto-saved at request end if modified)
save(): Promise<void>;
// Destroy session entirely
destroy(): Promise<void>;
// Regenerate session ID (for security after login)
regenerate(): Promise<void>;
}// Session storage backends (configured in tsweb.config.ts)
export interface SessionStore {
get(id: string): Promise<Record<string, unknown> | null>;
set(id: string, data: Record<string, unknown>, maxAge: number): Promise<void>;
delete(id: string): Promise<void>;
touch(id: string, maxAge: number): Promise<void>;
}
// Built-in stores
export class MemoryStore implements SessionStore { /* ... */ }
export class RedisStore implements SessionStore { /* ... */ }
export class PostgresStore implements SessionStore { /* ... */ }// routes/POST.login.ts
import { Request, Response, json, redirect, unauthorized } from "tsweb/std";
import { verifyPassword, getUser } from "lib/auth";
interface SessionData {
userId?: string;
isAdmin?: boolean;
}
export default async function handler(req: Request): Promise<Response> {
const { email, password } = await req.json<{ email: string; password: string }>();
const user = await getUser(email);
if (!user || !await verifyPassword(password, user.passwordHash)) {
return unauthorized("Invalid credentials");
}
const session = await req.session<SessionData>();
await session.regenerate(); // Prevent session fixation
session.set("userId", user.id);
session.set("isAdmin", user.role === "admin");
return json({ success: true, user: { id: user.id, email: user.email } });
}A simple, secure templating system:
// tsweb/std/html.ts
// Tagged template literal for safe HTML
export function html(
strings: TemplateStringsArray,
...values: unknown[]
): SafeHtml;
// Explicitly mark string as safe (use with caution)
export function raw(content: string): SafeHtml;
// Escape HTML entities
export function escape(content: string): string;
// Render to Response
export function render(template: SafeHtml, init?: ResponseInit): Response;// routes/index.ts
import { Request, Response } from "tsweb/std";
import { html, render } from "tsweb/std/html";
import { getLatestPosts } from "lib/posts";
export default async function handler(req: Request): Promise<Response> {
const posts = await getLatestPosts(5);
return render(html`
<!DOCTYPE html>
<html>
<head>
<title>My Blog</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>Latest Posts</h1>
<ul>
${posts.map(post => html`
<li>
<a href="/posts/${post.slug}">${post.title}</a>
<span class="date">${post.createdAt.toLocaleDateString()}</span>
</li>
`)}
</ul>
</body>
</html>
`);
}Security:
raw() must be explicitly used for unescaped contenteval() or dynamic code executionThe stdlib includes a streaming abstraction inspired by Haskell's Conduit library. Unlike RxJS (which is push-based, has complex scheduling, and feels like a separate language), this is:
Design Philosophy:
The streaming API is designed to feel like native TypeScript, not a DSL. Key principles:
If you know for await...of and yield, you already understand 80% of it.
Source<T> ──▶ Pipe<T,U> ──▶ Pipe<U,V> ──▶ Sink<V,R>
│ │ │ │
│ │ │ │
produces transforms transforms consumes
data data data returns RThree types:
import { pipe } from "tsweb/std/stream";
// Unix-style composition
const result = await pipe(
source,
transform1,
transform2,
sink
);
// Equivalent to: sink(transform2(transform1(source)))
// But with proper resource management and backpressureimport {
fromIterable,
fromArray,
fromReadable,
fromFile,
fromRequest,
fromQuery,
range,
repeat,
empty,
} from "tsweb/std/stream";
// From array
const nums = fromArray([1, 2, 3, 4, 5]);
// From any iterable
const items = fromIterable(mySet.values());
// From Node readable stream
const bytes = fromReadable(fs.createReadStream("data.bin"));
// From file (auto-closes on completion or error)
const lines = fromFile("data.txt", { encoding: "utf-8" });
// From HTTP request body
const chunks = fromRequest(req);
// From database query (streaming rows)
const users = fromQuery(db, "SELECT * FROM users WHERE active = true");
// Generators
const numbers = range(1, 1000); // 1, 2, 3, ..., 1000
const pings = repeat("ping", 5); // "ping" x 5import {
map,
filter,
take,
drop,
takeWhile,
dropWhile,
chunk,
flatten,
flatMap,
tap,
scan,
buffer,
debounce,
throttle,
timeout,
retry,
catchError,
finalize,
} from "tsweb/std/stream";
// Basic transforms
map((x: number) => x * 2) // 1,2,3 → 2,4,6
filter((x: number) => x % 2 === 0) // 1,2,3,4 → 2,4
take(3) // 1,2,3,4,5 → 1,2,3
drop(2) // 1,2,3,4,5 → 3,4,5
// Chunking (critical for batch processing)
chunk(100) // items → batches of 100
chunk(100, { timeout: 1000 }) // also flush after 1s
// Flattening
flatten() // [[1,2],[3,4]] → 1,2,3,4
flatMap(async (id) => fetchUser(id)) // ids → users (concurrent)
// Side effects (doesn't modify stream)
tap(console.log) // log each item
tap((x) => metrics.count("processed")) // metrics
// Accumulation
scan((acc, x) => acc + x, 0) // running sum: 1,3,6,10,...
// Error handling
catchError((err) => fromArray([fallback])) // recover from errors
retry(3, { delay: 1000 }) // retry on failure
timeout(5000) // error if no item in 5s
// Resource cleanup
finalize(() => cleanup()) // always runs at endimport {
collect,
collectArray,
collectMap,
collectSet,
first,
last,
reduce,
forEach,
count,
sum,
toFile,
toResponse,
toWritable,
drain,
discard,
} from "tsweb/std/stream";
// Collect all items
const arr = await pipe(source, collectArray()); // T[]
const set = await pipe(source, collectSet()); // Set<T>
const map = await pipe(source, collectMap(x => x.id)); // Map<K,T>
// Single item
const one = await pipe(source, first()); // T | undefined
const end = await pipe(source, last()); // T | undefined
// Aggregation
const total = await pipe(nums, sum()); // number
const cnt = await pipe(items, count()); // number
const result = await pipe(nums, reduce((a,b) => a+b, 0)); // number
// Side effects
await pipe(source, forEach(processItem)); // void
await pipe(source, drain()); // consume, discard
// Output
await pipe(source, toFile("output.txt")); // write to file
await pipe(source, toWritable(nodeStream)); // write to stream
// HTTP response (streaming)
return pipe(source, toResponse({ contentType: "application/json" }));CSV Processing Pipeline:
// routes/api/import.ts
import { pipe, fromRequest, map, chunk, flatMap, forEach, count } from "tsweb/std/stream";
import { parseCSVLine } from "tsweb/std/csv";
import { db } from "lib/db";
export default async function handler(req: Request): Promise<Response> {
const imported = await pipe(
fromRequest(req), // raw bytes
decodeUTF8(), // string chunks
splitLines(), // individual lines
drop(1), // skip header
map(parseCSVLine), // { name, email, ... }
filter(row => row.email.includes("@")), // validate
chunk(100), // batch for efficiency
flatMap(batch => db.insertMany(batch)), // bulk insert
count(), // count inserted
);
return json({ imported });
}Streaming JSON API Response:
// routes/api/users/export.ts
import { pipe, fromQuery, map, toResponse } from "tsweb/std/stream";
import { jsonLines } from "tsweb/std/json";
export default async function handler(req: Request): Promise<Response> {
// Stream 1M users without loading all in memory
return pipe(
fromQuery(db, "SELECT * FROM users"), // streaming cursor
map(user => ({ ...user, exportedAt: new Date() })),
jsonLines(), // JSON Lines format
toResponse({
contentType: "application/x-ndjson",
headers: { "X-Total-Count": "async" } // can't know ahead
}),
);
}File Transformation:
// Compress and encrypt a file
import { pipe, fromFile, toFile } from "tsweb/std/stream";
import { gzip } from "tsweb/std/compress";
import { encrypt } from "tsweb/std/crypto";
await pipe(
fromFile("data.json"),
gzip(),
encrypt(secretKey),
toFile("data.json.gz.enc"),
);Parallel Processing with Controlled Concurrency:
import { pipe, fromArray, flatMap, collectArray } from "tsweb/std/stream";
const results = await pipe(
fromArray(urls),
flatMap(
async (url) => {
const res = await fetch(url);
return res.json();
},
{ concurrency: 10 } // max 10 concurrent fetches
),
collectArray(),
);Real-time Log Processing:
// Stream logs, filter errors, alert on threshold
import { pipe, fromFile, filter, buffer, forEach } from "tsweb/std/stream";
await pipe(
fromFile("/var/log/app.log", { follow: true }), // tail -f
splitLines(),
map(JSON.parse),
filter(log => log.level === "error"),
buffer({ time: 60_000 }), // collect for 1 min
forEach(async (errors) => {
if (errors.length > 10) {
await alertOps(`${errors.length} errors in last minute`);
}
}),
);Pipes are just functions from async iterable to async iterable:
import { Pipe } from "tsweb/std/stream";
// Simple custom pipe
const double: Pipe<number, number> = async function* (source) {
for await (const n of source) {
yield n * 2;
}
};
// Stateful pipe (e.g., dedupe)
function dedupe<T>(keyFn: (item: T) => string): Pipe<T, T> {
return async function* (source) {
const seen = new Set<string>();
for await (const item of source) {
const key = keyFn(item);
if (!seen.has(key)) {
seen.add(key);
yield item;
}
}
};
}
// Usage
await pipe(
fromArray([{ id: 1 }, { id: 2 }, { id: 1 }, { id: 3 }]),
dedupe(x => String(x.id)),
collectArray(),
); // [{ id: 1 }, { id: 2 }, { id: 3 }]Resources are automatically cleaned up when pipelines complete or error:
import { bracket } from "tsweb/std/stream";
// bracket(acquire, release) creates a resource-safe source
const dbSource = bracket(
() => db.connect(), // acquire
(conn) => conn.release(), // release (always runs)
(conn) => conn.query("SELECT * ..."), // use
);
// Or use the built-in helpers which handle this:
const rows = fromQuery(db, "SELECT ..."); // auto-releases connection
const bytes = fromFile("data.bin"); // auto-closes file handle// Core types
type Source<T> = AsyncIterable<T>;
type Pipe<A, B> = (source: Source<A>) => Source<B>;
type Sink<T, R> = (source: Source<T>) => Promise<R>;
// The pipe function (overloaded for type inference)
function pipe<A>(source: Source<A>): Source<A>;
function pipe<A, B>(source: Source<A>, p1: Pipe<A, B>): Source<B>;
function pipe<A, B, C>(source: Source<A>, p1: Pipe<A, B>, p2: Pipe<B, C>): Source<C>;
function pipe<A, B, C, D>(source: Source<A>, p1: Pipe<A, B>, p2: Pipe<B, C>, p3: Pipe<C, D>): Source<D>;
// ... up to 10 pipes
// With sink (returns Promise<R>)
function pipe<A, R>(source: Source<A>, sink: Sink<A, R>): Promise<R>;
function pipe<A, B, R>(source: Source<A>, p1: Pipe<A, B>, sink: Sink<B, R>): Promise<R>;
// ... etc| RxJS | tsweb/stream |
|---|---|
| Push-based (producer controls flow) | Pull-based (consumer controls flow) |
| Complex scheduler system | No schedulers (just async/await) |
| Hot/cold observable distinction | Just async iterables |
| 100+ operators to learn | ~30 focused operators |
| Subscription management | Automatic cleanup |
| Graph-based (merge, combine, etc.) | Linear pipelines |
| Requires mental model shift | Feels like native TypeScript |
The goal is not to replace RxJS for all use cases — RxJS excels at complex event-driven UIs. But for server-side request/response processing, the Conduit model is simpler and sufficient.
| Haskell Conduit | tsweb/stream |
|---|---|
Source m a | Source<T> (AsyncIterable) |
Conduit a m b | Pipe<A, B> |
Sink a m r | Sink<T, R> |
| `. | ` operator |
runConduit | await pipe(...) |
bracketP | bracket() |
yield | yield (generators) |
await | for await |
The translation is quite direct. The main difference is that Haskell's type system enforces more invariants at compile time, while TypeScript relies more on runtime behavior.
// tsweb/std/db.ts
// Connection pool with automatic cleanup
export function createPool(config: PoolConfig): Pool;
// Simple query builder (not an ORM)
export function sql(
strings: TemplateStringsArray,
...values: unknown[]
): Query;
// Transaction helper
export function transaction<T>(
pool: Pool,
fn: (client: PoolClient) => Promise<T>
): Promise<T>;// lib/db.ts
import { createPool, sql, transaction } from "tsweb/std/db";
export const db = createPool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeout: 30_000,
});
export async function createOrder(userId: string, items: OrderItem[]) {
return transaction(db, async (client) => {
const order = await client.query(sql`
INSERT INTO orders (user_id, status)
VALUES (${userId}, 'pending')
RETURNING *
`);
for (const item of items) {
await client.query(sql`
INSERT INTO order_items (order_id, product_id, quantity)
VALUES (${order.rows[0].id}, ${item.productId}, ${item.quantity})
`);
}
return order.rows[0];
});
}The Conduit-inspired streaming model isn't just another feature — it's the unifying abstraction that makes the entire stdlib concise and consistent.
// HTTP request body
fromRequest(req) // Source<Uint8Array>
// Database query
fromQuery(db, "SELECT ...") // Source<Row>
// File
fromFile("data.csv") // Source<Uint8Array>
// WebSocket messages
fromWebSocket(ws) // Source<Message>
// Server-sent events
fromSSE(eventSource) // Source<Event>
// Redis pub/sub
fromRedis(client, "channel") // Source<Message>
// Kafka consumer
fromKafka(consumer, "topic") // Source<Record>// HTTP response
toResponse() // Sink<Uint8Array, Response>
// File
toFile("out.txt") // Sink<Uint8Array, void>
// Database bulk insert
toTable(db, "users") // Sink<User, number>
// WebSocket
toWebSocket(ws) // Sink<Message, void>
// S3 upload
toS3(bucket, key) // Sink<Uint8Array, void>// Same pattern everywhere
await pipe(
anySource,
anyTransforms,
catchError(err => {
logger.error(err);
return fallbackSource;
}),
finalize(() => cleanup()),
anySink,
);Because it's pull-based, backpressure is automatic:
// This WON'T blow up memory, even with 10GB file
await pipe(
fromFile("huge.csv"), // reads chunk only when needed
splitLines(), // processes one line at a time
map(parseLine), // transforms incrementally
chunk(1000), // batches for DB efficiency
flatMap(insertBatch), // DB insert controls the pace
drain(),
);The entire request/response cycle can be expressed as a pipeline:
// Conceptually, every handler is:
export default async function handler(req: Request): Promise<Response> {
return pipe(
fromRequest(req), // Source: request body
...transformations, // Pipes: your business logic
toResponse(), // Sink: response
);
}
// Example: JSON echo with validation
export default async function handler(req: Request): Promise<Response> {
return pipe(
fromRequest(req),
decodeUTF8(),
collectString(), // collect all chunks to string
map(JSON.parse),
map(validateAndTransform),
map(JSON.stringify),
toResponse({ contentType: "application/json" }),
);
}This is the "low friction TypeScript" vision realized: a small set of composable primitives that work uniformly across all I/O boundaries.
The router maps URLs to files in routes/:
// internal: router.ts
interface Route {
pattern: URLPattern;
method: string;
filePath: string;
params: string[];
}
export class Router {
private routes: Route[] = [];
constructor(routesDir: string) {
this.scanRoutes(routesDir);
}
private scanRoutes(dir: string, prefix = "") {
// Recursively scan routes/ directory
// Convert file paths to URL patterns
// Register method prefixes (POST., PUT., etc.)
}
match(method: string, url: string): RouteMatch | null {
for (const route of this.routes) {
if (route.method !== method) continue;
const match = route.pattern.exec(url);
if (match) {
return {
filePath: route.filePath,
params: this.extractParams(match, route.params),
};
}
}
return null;
}
}Middleware is defined as wrapper functions:
// lib/middleware/auth.ts
import { Request, Response, unauthorized } from "tsweb/std";
export function requireAuth(
handler: (req: Request) => Promise<Response>
): (req: Request) => Promise<Response> {
return async (req: Request) => {
const session = await req.session();
if (!session.get("userId")) {
return unauthorized("Please log in");
}
return handler(req);
};
}// routes/api/me.ts
import { Request, Response, json } from "tsweb/std";
import { requireAuth } from "lib/middleware/auth";
import { getUser } from "lib/db";
export default requireAuth(async function handler(req: Request): Promise<Response> {
const session = await req.session();
const user = await getUser(session.get("userId")!);
return json(user);
});For middleware that applies to all routes:
// tsweb.config.ts
import { defineConfig } from "tsweb";
import { cors } from "tsweb/std/middleware";
import { requestLogger } from "lib/middleware/logger";
export default defineConfig({
middleware: [
requestLogger(),
cors({ origin: "*" }),
],
});// lib/middleware/errors.ts
import { Request, Response, serverError, json } from "tsweb/std";
export function errorBoundary(
handler: (req: Request) => Promise<Response>
): (req: Request) => Promise<Response> {
return async (req: Request) => {
try {
return await handler(req);
} catch (error) {
console.error("Unhandled error:", error);
if (process.env.NODE_ENV === "development") {
return json(
{ error: String(error), stack: (error as Error).stack },
{ status: 500 }
);
}
return serverError();
}
};
}# Create new project
npx tsweb init my-app
cd my-app
# Start development server
npx tsweb devThat's it. No configuration required.
$ npx tsweb dev
╭─────────────────────────────────────────╮
│ │
│ tsweb v1.0.0 │
│ │
│ → Local: http://localhost:3000 │
│ → Network: http://192.168.1.5:3000 │
│ │
│ Ready in 142ms │
│ │
╰─────────────────────────────────────────╯
Watching for changes...
[12:34:56] GET / 200 12ms
[12:34:57] routes/index.ts changed, reloading...
[12:34:57] GET / 200 8msWhat tsweb dev does:
Use Chrome DevTools for a full interactive JS console:
$ npx tsweb dev --inspect
tsweb v1.0.0 → http://localhost:3000
Debugger listening on ws://127.0.0.1:9229/...
Open Chrome and navigate to chrome://inspectIn Chrome DevTools console:
> const { db } = await import("./lib/db.js")
> await db.query("SELECT COUNT(*) FROM users")
{ rows: [{ count: '42' }], rowCount: 1 }
> const res = await fetch("http://localhost:3000/api/users")
> await res.json()
{ users: [...], count: 42 }What you get:
Errors appear in multiple places simultaneously:
Terminal:
[12:35:01] TypeScript error in routes/api/users.ts:15:3
Property 'naem' does not exist on type 'User'. Did you mean 'name'?Browser (dev mode):
┌─────────────────────────────────────────────────────┐
│ TypeScript Error │
│ │
│ routes/api/users.ts:15:3 │
│ Property 'naem' does not exist on type 'User'. │
│ Did you mean 'name'? │
│ │
│ 15 │ return json({ name: user.naem }); │
│ │ ~~~~ │
└─────────────────────────────────────────────────────┘VSCode (if extension installed):
naemDeployments are:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Compute │ ──▶ │ Run │ ──▶ │ Sign │
│ Diff │ │ Friction │ │ Payload │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Swap │ ◀── │ Health │ ◀── │ Upload │
│ Traffic │ │ Check │ │ & Verify │
└─────────────┘ └─────────────┘ └─────────────┘# Generate deployment keys
$ npx tsweb keys generate
Generated key pair:
Public: tsweb_pub_a1b2c3d4e5f6...
Private: tsweb_prv_x9y8z7w6v5u4...
Public key saved to .tsweb/keys/deploy.pub
Private key saved to .tsweb/keys/deploy.key
⚠️ Keep your private key secure!
Add .tsweb/keys/*.key to .gitignore
# Add public key to remote server
$ npx tsweb keys add staging tsweb_pub_a1b2c3d4e5f6...// Deployment payload structure
interface DeploymentPayload {
version: string; // tsweb version
timestamp: number; // Unix timestamp
environment: string; // "staging" or "prod"
files: {
added: FileEntry[];
modified: FileEntry[];
deleted: string[]; // paths only
};
manifest: {
totalFiles: number;
checksum: string; // SHA-256 of all file checksums
};
signature: string; // Ed25519 signature
}
interface FileEntry {
path: string;
content: string; // Base64 encoded
checksum: string; // SHA-256
mode: number; // File permissions
}// On the remote server
import { verifyDeployment, applyDeployment, rollback } from "tsweb/server";
// Deployment endpoint (protected, internal only)
app.post("/_tsweb/deploy", async (req, res) => {
const payload = await req.json();
// 1. Verify signature
const isValid = await verifyDeployment(payload, allowedPublicKeys);
if (!isValid) {
return res.status(403).json({ error: "Invalid signature" });
}
// 2. Apply deployment atomically
const deploymentId = await applyDeployment(payload);
// 3. Health check
const healthy = await healthCheck();
if (!healthy) {
await rollback(deploymentId);
return res.status(500).json({ error: "Health check failed, rolled back" });
}
// 4. Swap traffic
await swapTraffic(deploymentId);
return res.json({ success: true, deploymentId });
});deployments/
├── current -> v42/ # Symlink to active version
├── v41/ # Previous version (for rollback)
├── v42/ # Current version
│ ├── routes/
│ ├── lib/
│ └── tsweb.config.ts
└── v43-pending/ # In-progress deploymentDeployment steps:
v43-pending/current -> v43/# Immediate rollback to previous version
$ npx tsweb rollback prod
Rolling back prod to v41...
✓ Swapped symlink
✓ Reloaded VM contexts
✓ Health check passed
Rolled back to v41 in 1.2sFriction is a first-class concept. The idea: make safe deployments feel safe, and risky deployments feel risky.
// tsweb.config.ts
export default defineConfig({
environments: {
staging: {
url: "https://staging.example.com",
friction: [
"confirm_environment",
"show_diff",
],
},
prod: {
url: "https://example.com",
friction: [
"confirm_environment",
"show_diff",
"require_clean_working_tree",
"require_on_main",
"double_confirm",
"type_environment_name",
],
},
},
});| Hook | Description |
|---|---|
confirm_environment | "You're deploying to prod. Continue? [y/N]" |
show_diff | Display changed files and line counts |
require_clean_working_tree | Fail if there are uncommitted changes |
require_on_main | Fail if not on main/master branch |
require_on_branch | Fail if not on specified branch |
double_confirm | "Are you sure? This is production. [y/N]" |
type_environment_name | "Type 'prod' to confirm:" |
require_recent_pull | Fail if local is behind remote |
business_hours_only | Fail outside 9am-5pm local time |
no_friday_deploys | Fail on Fridays |
// friction/slack-notify.ts
import { FrictionHook, FrictionContext } from "tsweb";
export const slackNotify: FrictionHook = {
name: "slack_notify",
async run(ctx: FrictionContext): Promise<void> {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `🚀 ${ctx.user} is deploying to ${ctx.environment}`,
attachments: [{
fields: [
{ title: "Files Changed", value: String(ctx.diff.total), short: true },
{ title: "Branch", value: ctx.git.branch, short: true },
],
}],
}),
});
},
};// friction/require-jira-ticket.ts
import { FrictionHook, FrictionContext, FrictionError } from "tsweb";
export const requireJiraTicket: FrictionHook = {
name: "require_jira_ticket",
async run(ctx: FrictionContext): Promise<void> {
const commitMessage = ctx.git.lastCommitMessage;
const ticketPattern = /[A-Z]+-\d+/;
if (!ticketPattern.test(commitMessage)) {
throw new FrictionError(
"Last commit must reference a Jira ticket (e.g., PROJ-123)"
);
}
},
};// tsweb.config.ts
import { slackNotify } from "./friction/slack-notify";
import { requireJiraTicket } from "./friction/require-jira-ticket";
export default defineConfig({
environments: {
prod: {
friction: [
"confirm_environment",
"show_diff",
requireJiraTicket,
slackNotify,
"double_confirm",
],
},
},
});interface FrictionHook {
name: string;
// Return void to pass, throw FrictionError to fail
run(ctx: FrictionContext): Promise<void>;
// Optional: custom prompt (for interactive hooks)
prompt?: (ctx: FrictionContext) => Promise<boolean>;
}
interface FrictionContext {
environment: string;
user: string;
timestamp: Date;
git: {
branch: string;
commit: string;
lastCommitMessage: string;
isClean: boolean;
isBehindRemote: boolean;
};
diff: {
added: string[];
modified: string[];
deleted: string[];
total: number;
};
config: ResolvedConfig;
// For interactive prompts
prompt: {
confirm(message: string): Promise<boolean>;
input(message: string): Promise<string>;
select<T>(message: string, choices: T[]): Promise<T>;
};
}tsweb doesn't reinvent observability. Instead, it integrates cleanly with tools you already know:
| Concern | tsweb's Role | You Use |
|---|---|---|
| JS debugging | Expose Node's inspector protocol | Chrome DevTools |
| Metrics | Export Prometheus format | Grafana, Datadog, Cloud Monitoring |
| Logs | Structured JSON to stdout | Cloud Logging, ELK, Loki, journald |
| Tracing | OpenTelemetry export | Jaeger, Zipkin, Cloud Trace |
| Shell access | Nothing (not our job) | SSH |
Why not build a custom dashboard?
Node.js has built-in V8 inspector support. We just expose it securely.
# Start with inspector enabled (dev only by default)
$ tsweb dev --inspect
# For remote debugging (staging), use SSH tunnel
$ ssh -L 9229:localhost:9229 staging.example.com
$ google-chrome chrome://inspectOr configure it in tsweb.config.ts:
export default defineConfig({
inspector: {
// Local dev: always enabled
dev: {
enabled: true,
port: 9229,
},
// Staging: enabled but bound to localhost (use SSH tunnel)
staging: {
enabled: true,
port: 9229,
host: "127.0.0.1", // NOT 0.0.0.0
},
// Prod: disabled (use logs and metrics instead)
prod: {
enabled: false,
},
},
});What you get with Chrome DevTools:
This is the "remote REPL" — it's just Chrome DevTools over an SSH tunnel. No custom protocol needed.
tsweb follows the 12-factor app principle: logs are just stdout. Your infrastructure handles the rest.
// tsweb emits structured JSON logs
{"level":"info","time":"2024-01-15T10:30:00.000Z","msg":"GET /api/users","status":200,"duration_ms":12,"request_id":"abc123"}
{"level":"error","time":"2024-01-15T10:30:01.000Z","msg":"Database error","error":"connection refused","request_id":"def456","stack":"..."}Where they go depends on your deployment:
# Google Cloud Run / Cloud Logging (automatic)
# Logs to stdout are automatically ingested
# Kubernetes + Loki
# Use promtail sidecar, or fluentd
# Systemd
$ journalctl -u tsweb -f
# Docker
$ docker logs -f myapp
# Old school
$ tsweb start >> /var/log/myapp.log 2>&1Log configuration:
export default defineConfig({
logging: {
// Output format
format: "json", // or "pretty" for local dev
// Minimum level
level: process.env.LOG_LEVEL ?? "info",
// Include in every log line
defaultFields: {
service: "my-app",
version: process.env.APP_VERSION,
},
// Redact sensitive fields
redact: ["password", "authorization", "cookie"],
},
});Per-request logging:
// routes/api/users.ts
import { Request, Response, json } from "tsweb/std";
import { log } from "tsweb/std/log";
export default async function handler(req: Request): Promise<Response> {
// log is automatically scoped to this request (includes request_id)
log.info("Fetching users", { limit: req.query.get("limit") });
const users = await getUsers();
log.info("Found users", { count: users.length });
return json(users);
}Expose a /metrics endpoint in Prometheus format. Scrape it with whatever you use.
export default defineConfig({
metrics: {
enabled: true,
path: "/_tsweb/metrics", // Internal, firewall from public
},
});Default metrics exposed:
# HELP tsweb_requests_total Total HTTP requests
# TYPE tsweb_requests_total counter
tsweb_requests_total{method="GET",path="/api/users",status="200"} 1842
# HELP tsweb_request_duration_seconds Request latency
# TYPE tsweb_request_duration_seconds histogram
tsweb_request_duration_seconds_bucket{le="0.01"} 1500
tsweb_request_duration_seconds_bucket{le="0.05"} 1800
tsweb_request_duration_seconds_bucket{le="0.1"} 1830
tsweb_request_duration_seconds_bucket{le="+Inf"} 1842
# HELP tsweb_vm_contexts_active Active VM contexts
# TYPE tsweb_vm_contexts_active gauge
tsweb_vm_contexts_active 3
# HELP nodejs_heap_size_bytes Node.js heap size
# TYPE nodejs_heap_size_bytes gauge
nodejs_heap_size_bytes{space="old"} 52428800Custom metrics:
// lib/metrics.ts
import { Counter, Histogram } from "tsweb/std/metrics";
export const ordersCreated = new Counter({
name: "orders_created_total",
help: "Total orders created",
labels: ["payment_method"],
});
export const paymentDuration = new Histogram({
name: "payment_processing_seconds",
help: "Payment processing time",
buckets: [0.1, 0.5, 1, 2, 5],
});
// routes/api/POST.orders.ts
import { ordersCreated, paymentDuration } from "lib/metrics";
export default async function handler(req: Request): Promise<Response> {
const end = paymentDuration.startTimer();
await processPayment(order);
end(); // Record duration
ordersCreated.inc({ payment_method: order.paymentMethod });
return json(order);
}For distributed tracing, export spans via OpenTelemetry:
export default defineConfig({
tracing: {
enabled: true,
exporter: "otlp", // or "jaeger", "zipkin"
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
serviceName: "my-app",
// Sample rate (1.0 = all requests, 0.1 = 10%)
sampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
},
});tsweb automatically creates spans for:
tsweb/std/db)A simple, standard health check:
export default defineConfig({
health: {
path: "/_tsweb/health",
// Custom checks
checks: {
database: async () => {
await db.query("SELECT 1");
return { status: "ok" };
},
redis: async () => {
await redis.ping();
return { status: "ok" };
},
},
},
});$ curl http://localhost:3000/_tsweb/health
{
"status": "ok",
"checks": {
"database": { "status": "ok", "latency_ms": 2 },
"redis": { "status": "ok", "latency_ms": 1 }
},
"uptime_seconds": 86400,
"version": "1.2.3"
}tsweb dev OutputFor local dev, we do provide nice terminal output (not a custom dashboard, just good defaults):
$ tsweb dev
tsweb v1.0.0 → http://localhost:3000
14:23:01 GET / 200 12ms
14:23:02 GET /api/users 200 8ms
14:23:03 POST /api/orders 500 45ms
└─ TypeError: Cannot read property 'id' of undefined
at routes/api/orders.ts:23:15
14:23:04 GET /health 200 1msThat's it. For anything more, use real tools.
If you want an interactive JS console connected to your running app:
Local:
$ tsweb dev --inspect
# Then: chrome://inspect in ChromeRemote (staging):
$ ssh -L 9229:localhost:9229 user@staging.example.com
# Then: chrome://inspect in ChromeThis gives you a full debugger, console, profiler — everything Chrome DevTools offers. We don't need to build anything.
When you attach Chrome DevTools (via --inspect), tsweb injects a $tsweb object into the global scope with useful debugging helpers:
// In Chrome DevTools console:
// Recent requests (ring buffer)
$tsweb.requests() // last 100 requests
$tsweb.requests({ status: 500 }) // filter by status
$tsweb.requests({ path: /api/ }) // filter by path regex
$tsweb.requests({ last: "5m" }) // last 5 minutes
// Specific request details
$tsweb.request("abc123") // by request ID
$tsweb.lastError() // most recent error with full context
// Logs (in-memory ring buffer, not a replacement for real log tooling)
$tsweb.logs() // last 1000 log entries
$tsweb.logs({ level: "error" })
$tsweb.logs({ request: "abc123" }) // logs for specific request
// App state
$tsweb.config() // resolved tsweb config (redacted secrets)
$tsweb.env() // environment variables (redacted)
$tsweb.health() // run health checks
$tsweb.metrics() // current metric values
// Database (if using tsweb/std/db)
$tsweb.db.pools() // connection pool status
$tsweb.db.slow() // recent slow queries
// VM contexts
$tsweb.vm.active() // currently active contexts
$tsweb.vm.stats() // context creation/destruction statsThese utilities are:
Example debugging session:
// "Why did I just get a 500?"
> $tsweb.lastError()
{
requestId: "abc123",
timestamp: "2024-01-15T14:23:03.012Z",
method: "POST",
path: "/api/orders",
error: {
name: "TypeError",
message: "Cannot read property 'id' of undefined",
stack: "TypeError: Cannot read property 'id'...",
source: "routes/api/orders.ts:23:15"
},
request: {
headers: { "content-type": "application/json", ... },
body: { items: [{ sku: "ABC", qty: 2 }] }
},
logs: [
{ level: "info", msg: "Processing order", ... },
{ level: "error", msg: "TypeError: Cannot read...", ... }
]
}
// Check if it's a pattern
> $tsweb.requests({ status: 500, last: "1h" }).length
3
// What's common?
> $tsweb.requests({ status: 500 }).map(r => r.path)
["/api/orders", "/api/orders", "/api/orders"]For natural language debugging, tsweb can optionally integrate with an LLM:
// tsweb.config.ts
export default defineConfig({
assistant: {
enabled: true,
provider: "anthropic", // or "openai"
model: "claude-sonnet-4-20250514",
// What context the assistant can access
context: {
logs: true,
requests: true,
errors: true,
config: true, // redacted
sourceCode: true, // for stack trace context
},
},
});In Chrome DevTools:
// Natural language queries
> await $tsweb.ask("Why did I just get a 500 error?")
`Based on the last error (request abc123):
The error occurred in routes/api/orders.ts at line 23. The code tried to
access 'id' on an undefined value.
Looking at the request body, you sent:
{ items: [{ sku: "ABC", qty: 2 }] }
But line 23 expects 'items[0].productId', not 'items[0].sku'.
The mismatch suggests either:
1. The client is sending the wrong field name
2. The handler expects a different schema than documented
Here's the relevant code:
const productId = order.items[0].productId; // line 23
^^^ undefined
Suggested fix: Either update the client to send 'productId' or update
the handler to use 'sku'.`
> await $tsweb.ask("Show me all errors in the last hour grouped by cause")
`Found 3 errors in the last hour:
1. TypeError: Cannot read property 'id' of undefined (2 occurrences)
- Both in /api/orders, same root cause as above
2. ECONNREFUSED: Connection refused (1 occurrence)
- Database connection failed at 14:15:03
- Recovered after 2 seconds (likely transient)
No other error patterns detected.`
> await $tsweb.ask("Is my database connection pool healthy?")
`Pool status looks good:
- 8 of 20 connections active
- No connections waiting
- Average query time: 12ms (last 5 minutes)
- No connection errors in the last hour
The spike at 14:15 was a brief network blip, fully recovered.`Privacy considerations:
sourceCode: true in config// Air-gapped / local model config
export default defineConfig({
assistant: {
enabled: true,
provider: "ollama",
model: "codellama:13b",
endpoint: "http://localhost:11434",
},
});tsweb operates differently depending on how much of its stdlib you use:
Mode 1: Full tsweb stdlib ("framework mode")
When you use tsweb/std for routing, requests, database, etc.:
// routes/api/users.ts
import { Request, Response, json } from "tsweb/std";
import { db } from "tsweb/std/db";
export default async function handler(req: Request): Promise<Response> {
const users = await db.query("SELECT * FROM users");
return json(users);
}You get:
$tsweb debug utilitiesMode 2: Bring Your Own Framework ("passthrough mode")
When you use Express, Fastify, Hono, or your own setup:
// server.ts
import express from "express";
const app = express();
app.get("/api/users", async (req, res) => {
const users = await myDb.query("SELECT * FROM users");
res.json(users);
});
export default app;// tsweb.config.ts
export default defineConfig({
mode: "passthrough",
entry: "./server.ts", // Your framework's entry point
});tsweb provides:
--inspect flag for Chrome DevToolstsweb does NOT interfere with:
The principle: In passthrough mode, tsweb is just a TypeScript runtime with nice deployment. Your framework's tools work exactly as documented.
Mode 3: Hybrid
You can mix modes — use your own framework but opt into specific tsweb features:
// server.ts
import express from "express";
import { tswebMiddleware } from "tsweb/express"; // Adapter
import { log } from "tsweb/std/log";
const app = express();
// Opt into tsweb request tracking
app.use(tswebMiddleware());
app.get("/api/users", async (req, res) => {
log.info("Fetching users"); // Uses tsweb logging
const users = await myDb.query("SELECT * FROM users");
res.json(users);
});
export default app;This gives you tsweb's logging and $tsweb.requests() while keeping Express's everything else.
| Task | Full Mode | Passthrough Mode | External Tool |
|---|---|---|---|
| TS → JS (no build) | ✓ | ✓ | — |
| Hot reload | ✓ | ✓ | — |
| Deployment (sync + friction) | ✓ | ✓ | — |
--inspect for DevTools | ✓ | ✓ | Chrome DevTools |
$tsweb debug utilities | ✓ | minimal | Chrome DevTools |
| Structured logging | ✓ | optional | Cloud Logging, etc. |
| Prometheus metrics | ✓ | basic | Grafana, Datadog |
| OpenTelemetry tracing | ✓ | optional | Jaeger, Cloud Trace |
| LLM debug assistant | ✓ | limited | — |
| Framework routing | ✓ (file-based) | yours | — |
| Framework middleware | ✓ | yours | — |
| Dashboards | ✗ | ✗ | Grafana, Datadog |
| Shell access | ✗ | ✗ | SSH |
The principle: tsweb emits standard formats. You plug in standard tools. In passthrough mode, your framework's tools work unchanged.
The tsweb VSCode extension provides:
lib/ auto-importsCmd+Shift+D opens deploy picker// .vscode/settings.json
{
"tsweb.autoStart": true,
"tsweb.showDeployDiff": true,
"tsweb.defaultEnvironment": "staging",
"tsweb.logLevel": "info"
}| Command | Description |
|---|---|
tsweb: Start Dev Server | Start local development |
tsweb: Stop Dev Server | Stop local development |
tsweb: Deploy to Staging | Deploy to staging |
tsweb: Deploy to Production | Deploy to production |
tsweb: View Logs | Stream logs from environment |
tsweb: Rollback | Rollback to previous version |
When you run "Deploy to Production":
// What's NOT available in request contexts:
// No access to process
process.exit() // ❌ undefined
process.env // ❌ only whitelisted vars via config
// No access to filesystem (directly)
require("fs") // ❌ throws
// No access to child processes
require("child_process") // ❌ throws
// No access to native addons
require("./native.node") // ❌ throws
// What IS available:
fetch() // ✓ for external APIs
console.log() // ✓ scoped to request
Buffer // ✓ for binary data
crypto // ✓ for cryptography
TextEncoder // ✓ for encoding
URL // ✓ for URL parsingModules are explicitly whitelisted:
// tsweb.config.ts
export default defineConfig({
vm: {
allowedModules: [
// Always allowed
"tsweb/std",
"lib/*",
// Common packages (opt-in)
"pg",
"redis",
"zod",
"date-fns",
// Dangerous — disabled by default
// "fs",
// "child_process",
// "net",
],
},
});// tsweb.config.ts
export default defineConfig({
env: {
// Explicitly expose these to request handlers
expose: [
"DATABASE_URL",
"REDIS_URL",
"API_KEY",
],
// Never expose (even if in system env)
deny: [
"AWS_SECRET_ACCESS_KEY",
"STRIPE_SECRET_KEY",
],
},
});For extra security, clients can sign requests:
// Client-side
const signature = await sign(requestBody, clientPrivateKey);
const response = await fetch("/api/sensitive", {
method: "POST",
headers: {
"X-Signature": signature,
"X-Client-Id": clientId,
},
body: requestBody,
});
// Server-side (handler)
import { verifyRequestSignature } from "tsweb/std/security";
export default async function handler(req: Request): Promise<Response> {
const isValid = await verifyRequestSignature(req, allowedClientKeys);
if (!isValid) {
return unauthorized("Invalid signature");
}
// ...
}// Problem: First request after deploy is slow
// Solution: Prewarming
// tsweb.config.ts
export default defineConfig({
vm: {
prewarm: {
enabled: true,
count: 5, // Prewarm 5 contexts
routes: ["/", "/api/health"], // Prewarm these routes
},
},
});.tsweb/cache/
├── a1b2c3d4.js # Compiled routes/index.ts
├── e5f6g7h8.js # Compiled routes/api/users.ts
└── manifest.json # Source hash → compiled file mapping// tsweb.config.ts
export default defineConfig({
vm: {
// Strategy 1: Fresh context per request (default)
// Safest, slight overhead
pooling: "none",
// Strategy 2: Context pooling with reset
// Faster, contexts are reset between requests
pooling: "reset",
// Strategy 3: Context pooling with snapshot
// Fastest, contexts restored from V8 snapshot
pooling: "snapshot",
},
});| Scenario | Requests/sec | Latency (p50) |
|---|---|---|
| Hello World (fresh context) | 8,000 | 2ms |
| Hello World (pooled) | 15,000 | 1ms |
| JSON API + DB query | 3,000 | 8ms |
| HTML template + DB | 2,000 | 12ms |
Benchmarked on M1 MacBook Pro, local PostgreSQL
// tsweb.config.ts
export default defineConfig({
vm: {
memoryLimit: 128, // MB per context
maxContexts: 50, // Max concurrent contexts
contextTimeout: 30000, // Kill context after 30s
// V8 flags for memory control
v8Flags: [
"--max-old-space-size=256",
"--gc-interval=100",
],
},
});# Initialize new project
tsweb init [name] [--template <template>]
# Development
tsweb dev [--port <port>] [--host <host>] [--open]
# Deployment
tsweb deploy <environment> [--force] [--skip-friction]
tsweb rollback <environment> [--version <version>]
tsweb diff <environment>
# Logs (convenience wrapper around stdout)
tsweb logs <environment> [--follow] [--lines <n>]
# Keys
tsweb keys generate
tsweb keys add <environment> <public-key>
tsweb keys list
tsweb keys revoke <key-id>
# Utilities
tsweb check # Type check all files
tsweb routes # List all routes
tsweb config # Show resolved config
tsweb version # Show version info--config <path> # Path to config file
--verbose # Verbose output
--quiet # Minimal output
--color / --no-color# Start dev server on port 8080
$ tsweb dev --port 8080
# Deploy to staging with diff preview
$ tsweb diff staging
$ tsweb deploy staging
# Stream production logs
$ tsweb logs prod --follow
# Stream production logs
$ tsweb logs prod --follow
# Deploy to staging with diff preview
$ tsweb diff staging
$ tsweb deploy staging
# Generate and add deployment keys
$ tsweb keys generate
$ tsweb keys add prod tsweb_pub_xxx...my-blog/
├── tsweb.config.ts
├── package.json
├── routes/
│ ├── index.ts # GET /
│ ├── posts/
│ │ ├── index.ts # GET /posts
│ │ └── [slug].ts # GET /posts/:slug
│ ├── api/
│ │ ├── posts.ts # GET /api/posts
│ │ └── POST.posts.ts # POST /api/posts
│ └── POST.contact.ts # POST /contact
├── lib/
│ ├── db.ts
│ ├── posts.ts
│ └── email.ts
├── static/
│ ├── style.css
│ └── logo.png
└── .env// tsweb.config.ts
import { defineConfig } from "tsweb";
export default defineConfig({
server: { port: 3000 },
environments: {
staging: {
url: "https://staging.myblog.com",
friction: ["confirm_environment", "show_diff"],
},
prod: {
url: "https://myblog.com",
friction: [
"confirm_environment",
"show_diff",
"require_on_main",
"double_confirm",
],
},
},
});// lib/db.ts
import { createPool } from "tsweb/std/db";
export const db = createPool({
connectionString: process.env.DATABASE_URL,
});// lib/posts.ts
import { db } from "./db";
export interface Post {
id: string;
slug: string;
title: string;
content: string;
publishedAt: Date | null;
createdAt: Date;
}
export async function getPublishedPosts(): Promise<Post[]> {
const result = await db.query(`
SELECT * FROM posts
WHERE published_at IS NOT NULL
ORDER BY published_at DESC
`);
return result.rows;
}
export async function getPostBySlug(slug: string): Promise<Post | null> {
const result = await db.query(
"SELECT * FROM posts WHERE slug = $1",
[slug]
);
return result.rows[0] ?? null;
}
export async function createPost(
data: Pick<Post, "title" | "slug" | "content">
): Promise<Post> {
const result = await db.query(
`INSERT INTO posts (title, slug, content)
VALUES ($1, $2, $3)
RETURNING *`,
[data.title, data.slug, data.content]
);
return result.rows[0];
}// routes/index.ts
import { Request, Response } from "tsweb/std";
import { html, render } from "tsweb/std/html";
import { getPublishedPosts } from "lib/posts";
export default async function handler(req: Request): Promise<Response> {
const posts = await getPublishedPosts();
return render(html`
<!DOCTYPE html>
<html>
<head>
<title>My Blog</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<img src="/static/logo.png" alt="Logo">
<h1>My Blog</h1>
</header>
<main>
<h2>Recent Posts</h2>
${posts.length === 0
? html`<p>No posts yet.</p>`
: html`
<ul class="posts">
${posts.map(post => html`
<li>
<a href="/posts/${post.slug}">${post.title}</a>
<time>${post.publishedAt?.toLocaleDateString()}</time>
</li>
`)}
</ul>
`
}
</main>
<footer>
<a href="/contact">Contact</a>
</footer>
</body>
</html>
`);
}// routes/posts/[slug].ts
import { Request, Response, notFound } from "tsweb/std";
import { html, render } from "tsweb/std/html";
import { getPostBySlug } from "lib/posts";
export default async function handler(req: Request): Promise<Response> {
const post = await getPostBySlug(req.params.get("slug")!);
if (!post || !post.publishedAt) {
return notFound("Post not found");
}
return render(html`
<!DOCTYPE html>
<html>
<head>
<title>${post.title} - My Blog</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<article>
<h1>${post.title}</h1>
<time>${post.publishedAt.toLocaleDateString()}</time>
<div class="content">
${post.content}
</div>
</article>
<a href="/">← Back to home</a>
</body>
</html>
`);
}// routes/api/posts.ts
import { Request, Response, json } from "tsweb/std";
import { getPublishedPosts } from "lib/posts";
export default async function handler(req: Request): Promise<Response> {
const posts = await getPublishedPosts();
return json({ posts });
}// routes/api/POST.posts.ts
import { Request, Response, json, badRequest, unauthorized } from "tsweb/std";
import { createPost } from "lib/posts";
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
slug: z.string().regex(/^[a-z0-9-]+$/),
content: z.string().min(1),
});
export default async function handler(req: Request): Promise<Response> {
// Check auth
const session = await req.session();
if (!session.get("isAdmin")) {
return unauthorized();
}
// Validate input
const body = await req.json();
const parsed = CreatePostSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.message);
}
// Create post
const post = await createPost(parsed.data);
return json(post, { status: 201 });
}# Start development
$ cd my-blog
$ npm install
$ tsweb dev
# Edit routes/index.ts, save, browser auto-refreshes
# Test API
$ curl http://localhost:3000/api/posts
# Deploy to staging
$ tsweb deploy staging
Deploying to staging...
→ 3 files changed
→ Continue? [y/N] y
✓ Deployed in 2.1s
# Check staging
$ open https://staging.myblog.com
# Deploy to production
$ tsweb deploy prod
Deploying to prod...
→ 3 files changed
→ You're deploying to PRODUCTION. Continue? [y/N] y
→ Are you sure? [y/N] y
✓ Deployed in 2.3stsweb brings the best of PHP's developer experience to TypeScript:
| PHP's Magic | tsweb's Solution |
|---|---|
| Edit → Refresh → See | Hot reload with instant TS compilation |
| Stateless requests | VM context isolation |
| Copy files to deploy | Sync-based deployment with signing |
| No build step | TS loader with caching |
| Forgiving errors | Errors isolated to single request |
Plus modern enhancements:
$tsweb.*)The result: a web development experience that's both delightfully simple and production-ready.
This is a living document. Contributions welcome.
License: Public Domain (CC0)