Content is user-generated and unverified.

Design Document: tsweb — A Modern PHP‑Inspired Web Runtime for Node + TypeScript

License: Public Domain (CC0)
Version: 0.1.0-draft


Table of Contents

  1. Introduction: The Spirit of PHP
  2. Vision: Expanding and Modernizing PHP's Wins
  3. Philosophy: Hypermedia, Not SPAs
  4. State Management: The Server is the Source of Truth
  5. LLM-First Development
  6. High-Level Goals
  7. Architecture Overview
  8. Core Components
  9. Project Structure & Conventions
  10. Configuration
  11. Web Standard Library (tsweb/std)
  12. Routing
  13. Developer Workflow
  14. Deployment System
  15. Programmable Friction Pipeline
  16. Observability & Debugging
  17. VSCode Integration
  18. Security Model
  19. Performance Considerations
  20. CLI Reference
  21. Appendix: Example Project

1. Introduction: The Spirit of PHP

PHP earned its place in web history not because it was elegant, but because it was immediately useful. Its defining virtues were:

  • Zero-friction iteration — edit a file, refresh the browser, see the result.
  • Stateless execution — every request starts fresh, no global state leaks.
  • Trivial deployment — copy a file to a server and it runs.
  • Beginner-friendly failure model — crashes affect only the current request.
  • Massive accessibility — no build steps, no daemons, no containers.

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:

  • No type safety
  • Inconsistent standard library
  • Weak module system
  • Poor tooling
  • Limited modern ergonomics
  • No interactive console, no notebook-style exploration
  • Primitive deployment safety

The goal of this project is to preserve the spirit of PHP while modernizing everything else, using Node + TypeScript as the foundation.


2. Vision: Expanding and Modernizing PHP's Wins

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:

  • TypeScript as the first-class language
  • Node as the long-lived host
  • VM contexts as the per-request sandbox
  • A TS loader that eliminates build steps
  • A local development loop that feels like a notebook
  • Standard observability hooks (logs, metrics, traces)
  • A sync-based deployment model
  • A programmable friction pipeline for safe deploys
  • VSCode integration for diagnostics and deploy UX
  • A batteries-included web standard library

The result is a system that feels like:

  • PHP's simplicity
  • Node's ecosystem
  • TypeScript's safety
  • Cloudflare Workers' isolation model
  • Jupyter's iterative workflow
  • Git's extensibility

All fused into a single, coherent developer experience.

Two ways to use tsweb:

  1. Full mode — Use tsweb's stdlib for routing, requests, database, streaming. You get maximum convenience and integrated debugging.
  2. Passthrough mode — Bring your own framework (Express, Fastify, Hono, etc.). tsweb provides TypeScript compilation, hot reload, and deployment. Your framework's tools work unchanged.

3. Philosophy: Hypermedia, Not SPAs

3.1 The Case Against SPAs (For This Project)

Single Page Applications have their place, but they come with friction:

  • Client-side bundlers (webpack, vite, esbuild)
  • Client-side routing
  • Client-side state management
  • Client-side data fetching
  • Two mental models (server + client)
  • Hydration complexity
  • SEO workarounds
  • Accessibility challenges

This is the opposite of PHP's "edit, refresh, done" simplicity.

3.2 The Hypermedia Alternative

The hypermedia approach returns to the web's original model:

  1. Server renders HTML — just like PHP
  2. Links and forms — the browser's native interaction model
  3. Progressive enhancement — sprinkle interactivity where needed
  4. No client build step — HTML is the product

Modern libraries like HTMX, Hotwire, and Unpoly make this approach feel modern without the SPA complexity.

3.3 tsweb's Position

tsweb is hypermedia-friendly, not hypermedia-required.

What tsweb provides:

typescript
// 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:

  • React/Vue/Svelte integration
  • Client-side bundling
  • SSR hydration
  • Client-side routing

If you want SPAs: Use passthrough mode with Next.js, Remix, or whatever. tsweb stays out of the way.

3.4 Why This Fits

PHP's winstsweb equivalent
Edit file, refresh browserHot reload, no build step
echo "<div>$name</div>"html\<div>${name}</div>``
Server renders everythingServer renders everything
Simple mental modelSimple mental model

The hypermedia approach is PHP's model with modern ergonomics. That's the point.

3.5 Toolkit, Not Framework

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 scaffoldingJust write code
Convention over configurationExplicit over magic
Learn the framework's wayUse what you need
Hard to escape when it doesn't fitEasy to drop pieces

tsweb provides:

  • Request/response primitives
  • HTML templating
  • Database helpers
  • Streaming
  • Routing (file-based, but simple)

tsweb does NOT prescribe:

  • ORM or data layer design
  • Authentication strategy
  • Form validation approach
  • File organization beyond routes/
  • Business logic patterns

If you want more structure, add it. If you want less, ignore what you don't need.

3.6 The Low-Level Escape Hatch

For developers with a "PHP mindset" who want minimal abstraction, everything works at the lowest level:

typescript
// 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 => 
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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:

  • Raw SQL strings
  • Raw HTML strings
  • Raw Response objects
  • Raw fetch() calls

The rule: Every abstraction is optional. Every layer can be bypassed.


4. State Management: The Server is the Source of Truth

4.1 The Problem with Client-Side State

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?

4.2 The Hypermedia Answer: Server is Truth

┌─────────────────────────────────────────────────────────────┐
│                  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:

  1. The URL — query params, path segments
  2. The database — persistent state
  3. The session — user-specific server-side state
  4. Nowhere else — no client-side stores

4.3 Practical State Patterns

Pattern 1: URL as State

typescript
// 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

typescript
// 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

typescript
// 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:

html
<!-- 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.

4.4 When This Doesn't Work

The hypermedia model struggles with:

  • Real-time collaboration (Google Docs) — need WebSockets + client state
  • Complex drag-and-drop — need client-side position tracking
  • Offline-first apps — need client-side persistence
  • Games — need client-side game loop

For these, use passthrough mode with an appropriate SPA framework. tsweb won't fight you.

4.5 Database as the State Store

Since the server is the source of truth, the database schema is your state model:

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


5. LLM-First Development

5.1 The Vision

tsweb is designed to be LLM-friendly from day one. This means:

  1. Clear conventions that are easy to describe in a prompt
  2. A system prompt (SYSTEM.md) that any LLM can use
  3. CLI integration for scaffolding and guidance
  4. Self-describing patterns that LLMs can recognize and generate

5.2 The SYSTEM.md File

Every tsweb project includes a SYSTEM.md that describes the project to LLMs:

markdown
# 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:

  • Auto-generated and kept in sync with the project
  • Included in LLM context when using tsweb ask
  • Designed to be copy-pasted into any LLM chat

5.3 The tsweb ask Command

bash
# 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?

5.4 The tsweb generate Command

LLM-powered scaffolding:

bash
# 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]
bash
# 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?

5.5 The tsweb explain Command

Get explanations of existing code:

bash
$ 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...]

5.6 Schema-Aware Generation

The LLM knows your database schema:

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

5.7 LLM Provider Configuration

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

5.8 Why LLM-First Matters

  1. Lower barrier to entry — Beginners can ask "how do I..." instead of reading docs
  2. Faster scaffolding — Generate boilerplate from descriptions
  3. Contextual help — LLM knows your schema, routes, and patterns
  4. Living documentation — SYSTEM.md is always up to date
  5. Natural language migrations — "Add a published_at column to posts"

The goal: an LLM that knows tsweb can be a competent junior developer on your project.


6. High-Level Goals

6.1 Zero-Friction Local Development

Local iteration should feel like breathing:

  • tsweb dev starts everything
  • Hot reload on file change
  • Instant TS → JS transform
  • Automatic context reload
  • Browser auto-refresh
  • Inline VSCode diagnostics
  • Chrome DevTools integration (--inspect)

No build step. No bundler. No config.

6.2 Safe, Structured Deployment

Deployment should be:

  • sync-based (like PHP)
  • secure (key-based auth)
  • environment-aware (staging vs prod)
  • programmable (hook pipeline)
  • deliberate (double confirmations, branch checks)

6.3 Stateless Request Execution

Each request runs in a fresh VM context:

  • No global state leakage
  • Deterministic behavior
  • Easy debugging

6.4 Batteries-Included Web Standard Library

A cohesive TS-native stdlib:

  • Request/response helpers
  • Cookie/session utilities
  • File uploads
  • Routing
  • DB connectors
  • HTML templating

6.5 Canonical Setup Across All OSes

Everything should "just work":

  • npm package + CLI
  • Optional Docker image
  • VSCode extension
  • Minimal configuration

7. Architecture Overview

┌──────────────────────────────────────────────────────────────┐
│                        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                    │
└──────────────────────────────────────────────────────────────┘

Component Interaction (Request Flow)

                   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)       │
└───────────────────┬───────────────────┘
                    │
                    ▼
                 Response

8. Core Components

8.1 TypeScript Loader

A fast, production-grade TS loader using swc:

typescript
// 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:

  • Content-addressed caching (hash-based)
  • Incremental compilation (only changed files)
  • Inline source maps for debugging
  • No separate build step
  • Works inside VM contexts

8.2 VM Context Pool

Node's VM module provides per-request isolation:

typescript
// 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:

  • Fresh globals per request (PHP's statelessness)
  • Configurable timeout (prevents runaway requests)
  • Memory limits (via V8 flags, configured at process start)
  • Isolated console output per request
  • Deterministic teardown

8.3 Request Handler Protocol

Every route handler exports a single function:

typescript
// 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:

typescript
type Handler = (req: Request) => Response | Promise<Response>;

Why a single default export?

  • Maximum simplicity (like PHP: one file = one endpoint)
  • No configuration needed
  • Clear mental model
  • Easy to test in isolation

9. Project Structure & Conventions

9.1 Minimal Structure

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

9.2 Route File Naming Conventions

File PathHTTP MethodURL Pattern
routes/index.tsGET/
routes/about.tsGET/about
routes/api/users.tsGET/api/users
routes/api/users/[id].tsGET/api/users/:id
routes/POST.login.tsPOST/login
routes/PUT.api.users.[id].tsPUT/api/users/:id
routes/api/[...path].tsGET/api/* (catch-all)

Method Prefixes:

  • No prefix = GET
  • POST. = POST
  • PUT. = PUT
  • DELETE. = DELETE
  • PATCH. = PATCH

Dynamic Segments:

  • [param] = named parameter
  • [...param] = catch-all (rest) parameter

9.3 The lib/ Directory

Files in lib/ are automatically available to all handlers without explicit imports:

typescript
// 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];
}
typescript
// 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/?

  • Reduces boilerplate
  • Encourages code organization
  • Mirrors PHP's include path behavior
  • Still explicit (you write the import), just shorter paths

10. Configuration

10.1 tsweb.config.ts

Configuration is optional. Sensible defaults cover most cases.

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

10.2 Environment Variables

bash
# .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/myapp

Environment files are loaded automatically based on the current environment.

10.3 Zero-Config Defaults

If no tsweb.config.ts exists:

typescript
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,
  },
};

10.4 Passthrough Mode Configuration

For using your own framework (Express, Fastify, Hono, etc.):

typescript
// 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: { /* ... */ },
  },
});

11. Web Standard Library (tsweb/std)

A cohesive, batteries-included API that feels natural to TypeScript developers.

11.1 Request & Response

Based on the WHATWG Fetch API with ergonomic extensions:

typescript
// 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>;
}
typescript
// 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>;

11.2 Usage Examples

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

11.3 Cookies

typescript
// 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";
}
typescript
// 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;
}

11.4 Sessions

typescript
// 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>;
}
typescript
// 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 { /* ... */ }
typescript
// 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 } });
}

11.5 HTML Templating

A simple, secure templating system:

typescript
// 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;
typescript
// 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:

  • All interpolated values are automatically escaped
  • raw() must be explicitly used for unescaped content
  • No eval() or dynamic code execution

11.6 Streaming Primitives (Conduit-Inspired)

The 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:

  • Pull-based — consumers drive the flow (backpressure is automatic)
  • Linear — pipelines, not graphs
  • Resource-safe — cleanup happens automatically
  • Native — builds on async iterables, feels like TypeScript

11.6.1 Core Concepts

Design Philosophy:

The streaming API is designed to feel like native TypeScript, not a DSL. Key principles:

  1. No new syntax — just functions and async generators
  2. No subscriptions — await the result, get the value
  3. No schedulers — use the event loop you already understand
  4. No "observable thinking" — it's just iteration with transforms
  5. Discoverable — IDE autocomplete guides you

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 R

Three types:

  • Source<T> — produces values (async iterable)
  • Pipe<A,B> — transforms values (async iterable → async iterable)
  • Sink<T,R> — consumes values, produces final result

11.6.2 The Pipe Operator

typescript
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 backpressure

11.6.3 Sources

typescript
import { 
  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 5

11.6.4 Pipes (Transforms)

typescript
import {
  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 end

11.6.5 Sinks (Consumers)

typescript
import {
  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" }));

11.6.6 Real-World Examples

CSV Processing Pipeline:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
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:

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

11.6.7 Creating Custom Pipes

Pipes are just functions from async iterable to async iterable:

typescript
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 }]

11.6.8 Resource Management

Resources are automatically cleaned up when pipelines complete or error:

typescript
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

11.6.9 Type Signatures

typescript
// 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

11.6.10 Why Not RxJS?

RxJStsweb/stream
Push-based (producer controls flow)Pull-based (consumer controls flow)
Complex scheduler systemNo schedulers (just async/await)
Hot/cold observable distinctionJust async iterables
100+ operators to learn~30 focused operators
Subscription managementAutomatic cleanup
Graph-based (merge, combine, etc.)Linear pipelines
Requires mental model shiftFeels 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.

11.6.11 Comparison with Conduit

Haskell Conduittsweb/stream
Source m aSource<T> (AsyncIterable)
Conduit a m bPipe<A, B>
Sink a m rSink<T, R>
`.` operator
runConduitawait pipe(...)
bracketPbracket()
yieldyield (generators)
awaitfor 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.


11.7 Database Helpers

typescript
// 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>;
typescript
// 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];
  });
}

11.8 How Streams Unify the Stdlib

The Conduit-inspired streaming model isn't just another feature — it's the unifying abstraction that makes the entire stdlib concise and consistent.

Everything is a Source

typescript
// 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>

Everything is a Sink

typescript
// 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>

Consistent Error Handling

typescript
// Same pattern everywhere
await pipe(
  anySource,
  anyTransforms,
  catchError(err => {
    logger.error(err);
    return fallbackSource;
  }),
  finalize(() => cleanup()),
  anySink,
);

Backpressure Everywhere

Because it's pull-based, backpressure is automatic:

typescript
// 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 "One Weird Trick"

The entire request/response cycle can be expressed as a pipeline:

typescript
// 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.


12. Routing

12.1 File-Based Routing

The router maps URLs to files in routes/:

typescript
// 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;
  }
}

12.2 Middleware

Middleware is defined as wrapper functions:

typescript
// 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);
  };
}
typescript
// 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);
});

12.3 Global Middleware

For middleware that applies to all routes:

typescript
// 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: "*" }),
  ],
});

12.4 Error Handling

typescript
// 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();
    }
  };
}

13. Developer Workflow

13.1 Getting Started

bash
# Create new project
npx tsweb init my-app
cd my-app

# Start development server
npx tsweb dev

That's it. No configuration required.

13.2 Local Development Loop

bash
$ 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 8ms

What tsweb dev does:

  1. Starts HTTP server
  2. Watches files for changes
  3. Recompiles changed TS files instantly
  4. Reloads VM contexts automatically
  5. Sends browser refresh signal (via WebSocket)
  6. Streams logs to terminal
  7. Reports TypeScript errors inline

13.3 Interactive Debugging

Use Chrome DevTools for a full interactive JS console:

bash
$ 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://inspect

In Chrome DevTools console:

javascript
> 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:

  • Full JS console with your app's context
  • Breakpoints (click in Sources tab)
  • Step-through debugging
  • Memory and CPU profiling
  • Network tab for request inspection

13.4 TypeScript Diagnostics

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):

  • Red squiggly under naem
  • Hover shows error message
  • Quick fix: "Change 'naem' to 'name'"

14. Deployment System

14.1 Overview

Deployments are:

  • Sync-based — only changed files are transferred
  • Signed — payloads are cryptographically signed
  • Atomic — all-or-nothing updates
  • Reversible — instant rollback capability

14.2 Deployment Flow

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Compute   │ ──▶ │    Run      │ ──▶ │    Sign     │
│    Diff     │     │  Friction   │     │   Payload   │
└─────────────┘     └─────────────┘     └─────────────┘
                                              │
                                              ▼
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│    Swap     │ ◀── │   Health    │ ◀── │   Upload    │
│   Traffic   │     │   Check     │     │  & Verify   │
└─────────────┘     └─────────────┘     └─────────────┘

14.3 Deployment Keys

bash
# 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...

14.4 Deployment Protocol

typescript
// 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
}

14.5 Server-Side Deployment Handler

typescript
// 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 });
});

14.6 Atomic Deployments

deployments/
├── current -> v42/           # Symlink to active version
├── v41/                      # Previous version (for rollback)
├── v42/                      # Current version
│   ├── routes/
│   ├── lib/
│   └── tsweb.config.ts
└── v43-pending/              # In-progress deployment

Deployment steps:

  1. Write new files to v43-pending/
  2. Run health checks
  3. Atomically swap symlink: current -> v43/
  4. Reload VM contexts
  5. Clean up old versions (keep last 5)

14.7 Rollback

bash
# 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.2s

15. Programmable Friction Pipeline

15.1 Concept

Friction is a first-class concept. The idea: make safe deployments feel safe, and risky deployments feel risky.

15.2 Configuration

typescript
// 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",
      ],
    },
  },
});

15.3 Built-in Friction Hooks

HookDescription
confirm_environment"You're deploying to prod. Continue? [y/N]"
show_diffDisplay changed files and line counts
require_clean_working_treeFail if there are uncommitted changes
require_on_mainFail if not on main/master branch
require_on_branchFail if not on specified branch
double_confirm"Are you sure? This is production. [y/N]"
type_environment_name"Type 'prod' to confirm:"
require_recent_pullFail if local is behind remote
business_hours_onlyFail outside 9am-5pm local time
no_friday_deploysFail on Fridays

15.4 Custom Friction Hooks

typescript
// 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 },
          ],
        }],
      }),
    });
  },
};
typescript
// 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)"
      );
    }
  },
};
typescript
// 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",
      ],
    },
  },
});

15.5 Friction Hook Interface

typescript
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>;
  };
}

16. Observability & Debugging

16.1 Philosophy: Use Standard Tools

tsweb doesn't reinvent observability. Instead, it integrates cleanly with tools you already know:

Concerntsweb's RoleYou Use
JS debuggingExpose Node's inspector protocolChrome DevTools
MetricsExport Prometheus formatGrafana, Datadog, Cloud Monitoring
LogsStructured JSON to stdoutCloud Logging, ELK, Loki, journald
TracingOpenTelemetry exportJaeger, Zipkin, Cloud Trace
Shell accessNothing (not our job)SSH

Why not build a custom dashboard?

  • You already have Grafana/Datadog/Cloud Console
  • Your ops team already knows those tools
  • They're battle-tested at scale
  • We'd just be building a worse version

16.2 Remote Debugging via Chrome DevTools

Node.js has built-in V8 inspector support. We just expose it securely.

bash
# 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://inspect

Or configure it in tsweb.config.ts:

typescript
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:

  • Full JS console (evaluate expressions in your app's context)
  • Breakpoints and stepping
  • Memory profiling
  • CPU profiling
  • Network inspection
  • Source maps work automatically

This is the "remote REPL" — it's just Chrome DevTools over an SSH tunnel. No custom protocol needed.

16.3 Logs: Structured JSON to stdout

tsweb follows the 12-factor app principle: logs are just stdout. Your infrastructure handles the rest.

typescript
// 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:

bash
# 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>&1

Log configuration:

typescript
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:

typescript
// 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);
}

16.4 Metrics: Prometheus Format

Expose a /metrics endpoint in Prometheus format. Scrape it with whatever you use.

typescript
export default defineConfig({
  metrics: {
    enabled: true,
    path: "/_tsweb/metrics",  // Internal, firewall from public
  },
});

Default metrics exposed:

prometheus
# 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"} 52428800

Custom metrics:

typescript
// 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);
}

16.5 Tracing: OpenTelemetry

For distributed tracing, export spans via OpenTelemetry:

typescript
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:

  • Incoming HTTP requests
  • Outgoing fetch calls
  • Database queries (if using tsweb/std/db)

16.6 Health Check Endpoint

A simple, standard health check:

typescript
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" };
      },
    },
  },
});
bash
$ 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"
}

16.7 Local Development: tsweb dev Output

For local dev, we do provide nice terminal output (not a custom dashboard, just good defaults):

bash
$ 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    1ms

That's it. For anything more, use real tools.

16.8 The "Console" is Just Node's Inspector

If you want an interactive JS console connected to your running app:

Local:

bash
$ tsweb dev --inspect
# Then: chrome://inspect in Chrome

Remote (staging):

bash
$ ssh -L 9229:localhost:9229 user@staging.example.com
# Then: chrome://inspect in Chrome

This gives you a full debugger, console, profiler — everything Chrome DevTools offers. We don't need to build anything.

16.9 Injected Debug Utilities

When you attach Chrome DevTools (via --inspect), tsweb injects a $tsweb object into the global scope with useful debugging helpers:

javascript
// 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 stats

These utilities are:

  • Only available when inspector is attached (not in production requests)
  • Read-only (no mutations)
  • Designed for quick answers, not deep analysis (use real tools for that)

Example debugging session:

javascript
// "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"]

16.10 LLM Debug Assistant (Optional)

For natural language debugging, tsweb can optionally integrate with an LLM:

typescript
// 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:

javascript
// 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:

  • LLM queries are opt-in and explicit
  • Source code is only sent if sourceCode: true in config
  • Request bodies are truncated and can be disabled
  • All queries are logged locally for audit
  • Works with local models (Ollama) for air-gapped environments
typescript
// Air-gapped / local model config
export default defineConfig({
  assistant: {
    enabled: true,
    provider: "ollama",
    model: "codellama:13b",
    endpoint: "http://localhost:11434",
  },
});

16.11 Framework Modes

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

typescript
// 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:

  • Full $tsweb debug utilities
  • Automatic request tracing
  • Integrated logging with request context
  • LLM assistant with deep context
  • All the conveniences described in this doc

Mode 2: Bring Your Own Framework ("passthrough mode")

When you use Express, Fastify, Hono, or your own setup:

typescript
// 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;
typescript
// tsweb.config.ts
export default defineConfig({
  mode: "passthrough",
  entry: "./server.ts",  // Your framework's entry point
});

tsweb provides:

  • TypeScript loader (no build step)
  • Hot reload on file changes
  • Deployment system (sync, friction, signing)
  • Basic metrics (request count, latency)
  • --inspect flag for Chrome DevTools

tsweb does NOT interfere with:

  • Your framework's routing
  • Your framework's middleware
  • Your framework's error handling
  • Your framework's logging
  • Any debug tools your framework provides

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:

typescript
// 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.

16.12 Summary: What tsweb Does and Doesn't Do

TaskFull ModePassthrough ModeExternal Tool
TS → JS (no build)
Hot reload
Deployment (sync + friction)
--inspect for DevToolsChrome DevTools
$tsweb debug utilitiesminimalChrome DevTools
Structured loggingoptionalCloud Logging, etc.
Prometheus metricsbasicGrafana, Datadog
OpenTelemetry tracingoptionalJaeger, Cloud Trace
LLM debug assistantlimited
Framework routing✓ (file-based)yours
Framework middlewareyours
DashboardsGrafana, Datadog
Shell accessSSH

The principle: tsweb emits standard formats. You plug in standard tools. In passthrough mode, your framework's tools work unchanged.

17. VSCode Integration

17.1 Extension Features

The tsweb VSCode extension provides:

  1. Inline TypeScript errors — errors appear as you type
  2. Go to definition — works with lib/ auto-imports
  3. Deploy commandCmd+Shift+D opens deploy picker
  4. Diff viewer — see deployment diff before confirming
  5. Log streaming — stream logs from staging/prod in terminal

17.2 Extension Configuration

json
// .vscode/settings.json
{
  "tsweb.autoStart": true,
  "tsweb.showDeployDiff": true,
  "tsweb.defaultEnvironment": "staging",
  "tsweb.logLevel": "info"
}

17.3 Command Palette

CommandDescription
tsweb: Start Dev ServerStart local development
tsweb: Stop Dev ServerStop local development
tsweb: Deploy to StagingDeploy to staging
tsweb: Deploy to ProductionDeploy to production
tsweb: View LogsStream logs from environment
tsweb: RollbackRollback to previous version

17.4 Deploy UX

When you run "Deploy to Production":

  1. Diff panel opens showing changed files
  2. Friction hooks run with progress indicator
  3. Interactive prompts appear in VSCode modal
  4. Deployment progress shown in status bar
  5. Success/failure notification with details

18. Security Model

18.1 Principles

  1. Isolation by default — each request in its own VM context
  2. No global state — nothing persists between requests
  3. Signed deployments — all code changes are cryptographically signed
  4. Audit everything — all deployments logged
  5. Principle of least privilege — VM contexts are sandboxed

18.2 VM Isolation

typescript
// 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 parsing

18.3 Allowed Modules

Modules are explicitly whitelisted:

typescript
// 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",
    ],
  },
});

18.4 Environment Variable Access

typescript
// 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",
    ],
  },
});

18.5 Request Signing (Optional)

For extra security, clients can sign requests:

typescript
// 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");
  }
  // ...
}

19. Performance Considerations

19.1 Cold Start Optimization

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

19.2 TS Compilation Caching

.tsweb/cache/
├── a1b2c3d4.js      # Compiled routes/index.ts
├── e5f6g7h8.js      # Compiled routes/api/users.ts
└── manifest.json    # Source hash → compiled file mapping
  • Cache is content-addressed (hash of source → compiled)
  • Survives restarts
  • Automatically invalidated on TS version change
  • Can be committed to git for faster CI deploys

19.3 VM Context Pooling Strategies

typescript
// 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",
  },
});

19.4 Benchmarks (Approximate)

ScenarioRequests/secLatency (p50)
Hello World (fresh context)8,0002ms
Hello World (pooled)15,0001ms
JSON API + DB query3,0008ms
HTML template + DB2,00012ms

Benchmarked on M1 MacBook Pro, local PostgreSQL

19.5 Memory Management

typescript
// 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",
    ],
  },
});

20. CLI Reference

20.1 Commands

bash
# 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

20.2 Global Options

bash
--config <path>    # Path to config file
--verbose          # Verbose output
--quiet            # Minimal output
--color / --no-color

20.3 Examples

bash
# 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...

Appendix: Example Project

A.1 Project Structure

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

A.2 Key Files

typescript
// 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",
      ],
    },
  },
});
typescript
// lib/db.ts
import { createPool } from "tsweb/std/db";

export const db = createPool({
  connectionString: process.env.DATABASE_URL,
});
typescript
// 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];
}
typescript
// 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>
  `);
}
typescript
// 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>
  `);
}
typescript
// 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 });
}
typescript
// 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 });
}

A.3 Development Workflow

bash
# 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.3s

Summary

tsweb brings the best of PHP's developer experience to TypeScript:

PHP's Magictsweb's Solution
Edit → Refresh → SeeHot reload with instant TS compilation
Stateless requestsVM context isolation
Copy files to deploySync-based deployment with signing
No build stepTS loader with caching
Forgiving errorsErrors isolated to single request

Plus modern enhancements:

  • Type safety
  • Standard observability (Prometheus, OpenTelemetry, structured logs)
  • Debug utilities in Chrome DevTools ($tsweb.*)
  • Optional LLM assistant for natural language debugging
  • Programmable friction for deploys
  • VSCode integration
  • Batteries-included stdlib (or bring your own framework)
  • Conduit-style streaming

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)

Content is user-generated and unverified.
    tsweb Design Document: PHP-Inspired TypeScript Web Runtime | Claude