Project Team Onboarding

March 5, 2026 Β· View on GitHub

Complete guide for new team members

Note: This document was AI-generated and has not been fully reviewed. It may contain errors or outdated information. Please take it with a grain of salt and verify against the codebase when in doubt.


πŸš€ Quick Start (5 Minutes)

# Create new project
npx create-ts-liveview@latest my-app

# Setup
cd my-app
./scripts/init.sh

# Start development server
npm start

# Visit http://localhost:8100

Before You Start

The project uses WebSocket for real-time updates. The client keeps a connection open to the server. A few familiar patterns behave differently here:

  • alert() and confirm() block the JS runtime and can disconnect the WebSocket β€” use DOM updates instead
  • Client scripts run in global scope; elements with id become global variables
  • JSX has a specific AST structure β€” use mapArray() or [].map() wrap for arrays (see Critical Patterns)

πŸ“‹ What is this project?

This starter template helps you build fast, interactive web applications with:

  • Server-side rendering with minimal client JavaScript (2.3KB gzipped)
  • Real-time updates via WebSocket
  • JSX support without Virtual DOM
  • Type-safe routing with full TypeScript support
  • Progressive enhancement - works without JS, enhanced with JS

Why Use It?

  • 102x-45x smaller than socket.io alone
  • Server-first architecture reduces client complexity
  • Real-time interactivity without heavy frontend frameworks
  • Fast initial page load with HTML streaming
  • Built on Express.js for Node.js ecosystem

⚠️ Critical Patterns (Must Read!)

1. Array Rendering - Use mapArray() or Wrap

❌ WRONG:

{products.map(product => <div>{product.name}</div>)}

βœ… CORRECT Option 1:

import { mapArray } from '../components/fragment.js'

{mapArray(products, product => <div>{product.name}</div>)}

βœ… CORRECT Option 2:

{[products.map(product => <div>{product.name}</div>)]}

Note: The key attribute (e.g. key={item.id}) is optional. Use it when child components need to track identity (e.g. SingleFieldForm), but it is not required for simple list rendering.

2. Server Communication - Use emit()

Use the global emit(url, ...args) function (defined in client/index.ts) for server communication. The server routes messages by URL.

3. Scripts - Define at Top Level

❌ WRONG:

function Page() {
  return (
    <div>
      <button onclick="doSomething()">Click</button>
      <script>{`function doSomething() { ... }`}</script>
    </div>
  )
}

βœ… CORRECT:

import Script from '../components/script.js'

let script = Script(/* js */ `
function doSomething() {
  console.log('clicked')
}
`)

function Page() {
  return <>
    {script}
    <div>
      <button onclick="doSomething()">Click</button>
    </div>
  </>
}

4. Styles - Same Pattern

import Style from '../components/style.js'

let style = Style(/* css */ `
#my-page {
  padding: 1rem;
}
`)

function Page() {
  return <>
    {style}
    <div id="my-page">Content</div>
  </>
}

πŸ“ Project Structure

my-app/
β”œβ”€β”€ server/
β”‚   β”œβ”€β”€ app/
β”‚   β”‚   β”œβ”€β”€ pages/         # Page components
β”‚   β”‚   β”œβ”€β”€ components/    # Reusable components
β”‚   β”‚   β”œβ”€β”€ routes.tsx     # Route definitions (routeDict from page .routes)
β”‚   β”‚   β”œβ”€β”€ app.tsx        # App layout
β”‚   β”‚   └── express.ts     # Express app setup
β”‚   └── index.ts           # Server entry point
β”œβ”€β”€ db/                    # Database schema & migrations
β”‚   β”œβ”€β”€ erd.txt            # Schema (ERD format)
β”‚   β”œβ”€β”€ proxy.ts           # Database proxy (typed, auto-generated from erd.txt)
β”‚   └── migrations/
β”œβ”€β”€ docs/                  # Developer guide, AI agent guidelines, examples
β”œβ”€β”€ client/                # Client bundle source
β”œβ”€β”€ build/                 # Built client bundle
β”œβ”€β”€ public/                # Static assets (css, js, images)
β”œβ”€β”€ scripts/               # Setup & deployment scripts (init.sh, new-task.sh, deploy.sh, ...)
β”œβ”€β”€ tasks/                 # Optional: task backlog & planning (.md files; created by new-task.sh or manually)
└── package.json

🎯 Creating Your First Page

Step 1: Create Page Component

File: server/app/pages/hello.tsx

import { o } from '../jsx/jsx.js'
import Style from '../components/style.js'
import Script from '../components/script.js'

let style = Style(/* css */ `
#hello-page {
  padding: 2rem;
  text-align: center;
}
`)

let script = Script(/* js */ `
function sayHello() {
  greeting.textContent = 'Hello from the app!'
}
`)

export default function HelloPage() {
  return <>
    {style}
    <div id="hello-page">
      <h1>Hello!</h1>
      <span id="greeting"></span>
      <button onclick="sayHello()">Click Me</button>
    </div>
    {script}
  </>
}

Step 2: Add Route

File: server/app/routes.tsx

import HelloPage from './pages/hello.js'

let routes: Routes = {
  // ... existing routes

  '/hello': {
    title: 'Hello',
    description: 'My first page',
    resolve: () => ({
      title: 'Hello World',
      node: <HelloPage />
    })
  }
}

Step 3: Test

Visit http://localhost:8100/hello - Done! πŸŽ‰


πŸ”„ Real-Time Updates

How It Works

  1. Server sends initial HTML
  2. WebSocket connects after page load
  3. User interactions β†’ WebSocket β†’ Server
  4. Server processes β†’ generates updates β†’ sends to client
  5. Client applies updates to DOM

Example: Live Task List with Database

import { o } from '../jsx/jsx.js'
import Script from '../components/script.js'
import { Context } from '../context.js'
import { proxy } from '../../db/proxy.js'
import { mapArray } from '../components/fragment.js'
import { pick } from 'better-sqlite3-proxy'

let script = Script(/* js */ `
function addTask() {
  let input = document.getElementById('task-input')
  let text = input.value.trim()
  if (!text) return
  emit('/tasks/add', text)
  input.value = ''
}

function toggleTask(taskId) {
  emit('/tasks/toggle', taskId)
}
`)

export function TaskListPage() {
  // Query tasks from database
  let tasks = pick(proxy.task, ['id', 'text', 'completed'])

  return <>
    {script}
    <div id="task-list">
      <h1>My Tasks</h1>

      <div class="add-task">
        <input
          type="text"
          id="task-input"
          placeholder="What needs to be done?"
          onkeypress="if(event.key==='Enter') addTask()"
        />
        <button onclick="addTask()">Add Task</button>
      </div>

      <ul>
        {mapArray(tasks, task => (
          <li key={task.id} class={task.completed ? 'completed' : ''}>
            <input
              type="checkbox"
              checked={task.completed}
              onclick={`toggleTask(${task.id})`}
            />
            {task.text}
          </li>
        ))}
      </ul>
    </div>
  </>
}

// Server handler for adding task
export function handleAddTask(data: { text: string }, context: Context) {
  let userId = context.session.get('userId')

  // Save to database
  proxy.task.push({
    text: data.text,
    completed: false,
    user_id: userId,
    created_at: new Date().toISOString()
  })

  return <TaskListPage />
}

// Server handler for toggling task
export function handleToggleTask(data: { taskId: number }, context: Context) {
  let task = proxy.task.find(t => t.id === data.taskId)

  if (task) {
    task.completed = !task.completed
  }

  return <TaskListPage />
}

πŸ—ΊοΈ Routing Patterns

Static Route

Static routes can use either node directly or resolve when you need to return other fields (e.g. title):

// Option 1: node directly
'/about': {
  title: 'About',
  node: <AboutPage />
}

// Option 2: resolve (when you need dynamic title, etc.)
'/about': {
  title: 'About',
  resolve: () => ({ node: <AboutPage /> })
}

Dynamic Route

Use context.routerMatch?.params for route params (e.g. :id in /user/:id):

'/user/:id': {
  title: 'User Profile',
  resolve: (context) => ({
    node: <UserProfile userId={context.routerMatch?.params.id} />
  })
}

Protected Route

context.session is available in WebSocket/event handlers. For HTTP route resolution (initial page load), use context.req and your session middleware (e.g. context.req.session?.userId):

'/dashboard': {
  resolve: (context) => {
    let userId = context.type === 'ws'
      ? context.session.get('userId')
      : context.req?.session?.userId
    if (!userId) return { redirect: '/login' }

    return { node: <Dashboard userId={userId} /> }
  }
}

404 Catch-All

'*': {
  title: 'Page Not Found',
  resolve: () => ({ node: <NotFoundPage /> })
}

πŸ’Ύ Database (better-sqlite3-proxy)

The project includes SQLite with better-sqlite3-proxy for type-safe database access. This setup is ready out of the box β€” no ORM or other database needed. You can modify to use other databases if you prefer.

Use the proxy for type-safe CRUD and basic searching (find, filter, pick). For complex queries (joins, ORDER BY, LIMIT), use prepared statements on the underlying db.

Avoid: Do not use array methods on proxy tables (e.g. proxy.label.filter(...), proxy.user.find(...)). The proxy is backed by the DB table; .filter / .find would iterate all rows in memory. Use the helpers from better-sqlite3-proxy instead: filter(proxy.label, { project_id }), find(proxy.user, { id: userId }).

Setup

File: db/proxy.ts (auto-generated from erd.txt)

import { proxySchema } from 'better-sqlite3-proxy'
import { db } from './db'

export type User = {
  id?: null | number
  name: string
  email: string
  created_at?: string
}

export let proxy = proxySchema<{
  user: User
  // ... other tables
}>({
  db,
  tableFields: {
    user: [],
    // ... other tables
  },
})

Query Data

import { proxy } from '../../db/proxy.js'
import { filter, find } from 'better-sqlite3-proxy'

// Get all users
let users = proxy.user

// Find one user
let user = find(proxy.user, { id: userId })

// Filter users (DB-backed; does not loop all rows in JS)
let activeUsers = filter(proxy.user, { active: true })

// ❌ Avoid: proxy.user.filter(u => u.active) β€” iterates entire table in memory

// Custom query with pick
import { pick } from 'better-sqlite3-proxy'

let userNames = pick(proxy.user, ['id', 'name'])
// Returns: [{ id: 1, name: 'John' }, ...]

Insert/Update/Delete

import { proxy } from '../../db/proxy.js'

// Insert
let newUserId = proxy.user.push({
  name: 'John',
  email: 'john@example.com',
})

// Update
let user = find(proxy.user, { id: userId })
if (user) {
  user.name = 'Jane'
  user.email = 'jane@example.com'
}

// Delete
let user = find(proxy.user, { id: userId })
if (user) {
  proxy.user.splice(proxy.user.indexOf(user), 1)
}

Example: User List Page

import { o } from '../jsx/jsx.js'
import { proxy } from '../../db/proxy.js'
import { mapArray } from '../components/fragment.js'
import { pick } from 'better-sqlite3-proxy'

export function UsersPage() {
  // Get all users (select only needed fields)
  let users = pick(proxy.user, ['id', 'name', 'email'])

  return (
    <div id="users-page">
      <h1>Users</h1>
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Email</th>
          </tr>
        </thead>
        <tbody>
          {mapArray(users, user => (
            <tr key={user.id}>
              <td>{user.id}</td>
              <td>{user.name}</td>
              <td>{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

🎨 Complete Page Example

Same pattern as User List above: pick() for fields, mapArray() for rendering, emit(url, data) for actions. See Blog Posts Manager for a full CRUD example.


βœ… Best Practices

DO:

  • βœ… Use mapArray() or [array.map(...)] for arrays
  • βœ… Define scripts/styles at top level with Script() and Style()
  • βœ… Use emit(url, data) for server communication (defined in client/index.ts)
  • βœ… Use elements with id as global variables (e.g. greeting for id="greeting")
  • βœ… Use fragments <>...</> for multiple top-level elements
  • βœ… Keep state on server when possible
  • βœ… Validate all input on server
  • βœ… Use TypeScript types for safety
  • βœ… Use pick() to select only needed database fields
  • βœ… Use descriptive component names

DON'T:

  • ❌ Use .map() directly in JSX without wrapping (plain array doesn't match JSX AST; see Critical Patterns above)
  • ❌ Put <script> or <style> inline in component JSX (raw <script> works but Script component preferred for minification)
  • ❌ Use alert() β€” the project keeps a WebSocket open for real-time updates; alert() blocks the JS runtime and can disconnect it. Use DOM updates (e.g. span.textContent = 'Error') or a toast component instead
  • ❌ Trust client input (security: always validate on server)
  • ❌ Store large objects in session (memory, serialization)
  • ❌ Query all fields when you only need some (use pick() for performance)
  • ❌ Forget error handling (validate, catch, show user-friendly messages)

πŸ› Common Issues & Fixes

WebSocket Not Connecting

// Check in browser console
console.log('WS state:', ws.readyState)
// 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED

// Restart server
npm start

JSX Not Rendering

// Ensure correct imports
import { o } from '../jsx/jsx.js'

// Check for unclosed tags

Database Schema Changes

Reviewing migrations before applying helps catch issues.

# Update db/erd.txt, then from db/ folder:
cd db
npm run plan                    # Generate migration script
# Review the migration in migrations/ directory
npm run update                  # Apply migration + regenerate proxy

# If migration needs changes after running:
npx knex migrate:down           # Rollback last migration
# Edit the migration file, then run npm run update again

For simple changes, you can use npm run dev to run plan, migrate, and update in one step (review is still recommended but less likely to need edits). For non-trivial schema changes, always use the manual workflow above and review before applying.

Hot Reload Not Working

# Restart dev server
npm start

# Clear browser cache
Ctrl+Shift+R (Windows/Linux)
Cmd+Shift+R (Mac)

πŸŽ“ Learning Goals

Progress through these at your own pace:

  • Understand the project structure
  • Create 2-3 simple pages
  • Implement routing between pages
  • Build a form with validation
  • Query and display database data
  • Implement real-time features
  • Create reusable components
  • Add authentication
  • Deploy to production

πŸ“š Essential Imports

See Import Patterns in Quick Reference below.


πŸ”— Resources

Official

Examples


πŸš€ Next Steps

  1. Clone/Create Project

    npx create-ts-liveview@latest my-first-app
    cd my-first-app
    ./scripts/init.sh
    npm start
    
  2. Explore Code

    • server/app/pages/home.tsx
    • server/app/routes.tsx
    • db/proxy.ts (generated from erd.txt)
  3. Build Something

    • User list from database
    • Task tracker with real-time updates
    • Contact form saving to database
  4. Review Examples

    • Study demo pages in the repo
    • Check official examples at liveviews.cc

πŸ’‘ Key Takeaways

  • Server-first: Logic lives on the server
  • Minimal client: 2.3KB client runtime
  • Real-time: WebSocket for live updates
  • Type-safe: Full TypeScript support + proxy types
  • Fast: HTML streaming, efficient updates
  • Progressive: Works without JS, better with JS

Document Version: 3.0 Last Updated: February 2026 Based on: ts-liveview template + better-sqlite3-proxy patterns


πŸ“– Quick Reference

Commands Cheat Sheet

# Setup
npx create-ts-liveview@latest my-app
./scripts/init.sh

# Development
npm start              # Start dev server
npm run build          # Build for production
npm run format         # Format code
npm run fix            # Add .js extensions

# Database (from db/ folder)
cd db && npm run plan  # Generate migration from erd.txt
# Review migrations/, then:
npm run update         # Apply migration + gen-proxy

# Deploy
./scripts/deploy.sh    # Deploy to server

Import Patterns

// Core (paths from server/app/pages/)
import { o } from '../jsx/jsx.js'
import Style from '../components/style.js'
import Script from '../components/script.js'
import { mapArray } from '../components/fragment.js'

// Context
import { Context } from '../context.js'

// Database
import { proxy } from '../../db/proxy.js'
import { find, filter, pick } from 'better-sqlite3-proxy'

// Routing (Routes type from ../routes.js when in pages/)
import { Routes } from '../routes.js'

Common Patterns Quick Copy

Basic Page

(See Creating Your First Page for full example.)

import { o } from '../jsx/jsx.js'

export default function MyPage() {
  return (
    <div id="my-page">
      <h1>My Page</h1>
    </div>
  )
}

Page with Style & Script

(See Creating Your First Page.)

import { o } from '../jsx/jsx.js'
import Style from '../components/style.js'
import Script from '../components/script.js'

let style = Style(/* css */ `
#my-page { padding: 1rem; }
`)

let script = Script(/* js */ `
function handleClick() {
  output.textContent = 'Clicked!'
}
`)

export default function MyPage() {
  return <>
    {style}
    <div id="my-page">
      <span id="output"></span>
      <button onclick="handleClick()">Click</button>
    </div>
    {script}
  </>
}

List from Database

import { o } from '../jsx/jsx.js'
import { proxy } from '../../db/proxy.js'
import { mapArray } from '../components/fragment.js'
import { pick } from 'better-sqlite3-proxy'

export default function ListPage() {
  let items = pick(proxy.items, ['id', 'name'])

  return (
    <div>
      <ul>
        {mapArray(items, item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

Form Handler

// In routes.tsx or api handler
app.post('/api/contact', (req, res) => {
  let { name, email, message } = req.body

  // Validate
  if (!name || !email || !message) {
    return res.status(400).json({ error: 'Missing fields' })
  }

  // Save to database
  proxy.contact.push({ name, email, message })

  res.json({ success: true })
})

Database Quick Reference

// Get all
let users = proxy.user

// Find one
let user = find(proxy.user, { id: 123 })

// Filter
let active = filter(proxy.user, { active: true })

// Select specific fields
let names = pick(proxy.user, ['id', 'name'])

// Insert
let newId = proxy.user.push({ name: 'John', email: 'j@ex.com' })

// Update
let user = find(proxy.user, { id: 123 })
if (user) {
  user.name = 'Jane'
}

// Delete
let user = find(proxy.user, { id: 123 })
if (user) {
  proxy.user.splice(proxy.user.indexOf(user), 1)
}

Routes Quick Reference

let routes: Routes = {
  // Static
  '/': {
    title: 'Home',
    resolve: () => ({ node: <HomePage /> })
  },

  // Dynamic (use routerMatch.params for route params)
  '/post/:id': {
    resolve: (ctx) => ({ node: <Post id={ctx.routerMatch?.params.id} /> })
  },

  // Protected (session from ctx.session for ws, ctx.req?.session for express)
  '/admin': {
    resolve: (ctx) => {
      let admin = ctx.type === 'ws'
        ? ctx.session.get('admin')
        : ctx.req?.session?.admin
      if (!admin) return { redirect: '/login' }
      return { node: <AdminPage /> }
    }
  },

  // 404
  '*': {
    resolve: () => ({ node: <NotFound /> })
  }
}

WebSocket Communication

// Client to Server
let script = Script(/* js */ `
function sendData(data) {
  emit('/my-event', data)
}
`)

// Server Handler (in your event handler file)
export function handleMyEvent(data, context) {
  // Process data
  context.session.set('someValue', data.value)

  // Return updated component
  return <UpdatedComponent />
}

DO / DON'T Summary

βœ… DO❌ DON'T
{mapArray(arr, ...)}{arr.map(...)}
{[arr.map(...)]}Direct .map()
let script = Script(...)Inline <script>
let style = Style(...)Inline <style>
Top-level definitionsInline definitions
proxy.user.push({...})db.insert(...)
find(proxy.user, {...})await db.query...
Server validationClient-only validation
pick() for fieldsQuery all fields

🎯 Common Tasks

Add a New Page

  1. Create server/app/pages/my-page.tsx
  2. Add route in server/app/routes.tsx
  3. Add navigation link if needed

Add Database Table

  1. Edit db/erd.txt (see db/README.md for schema format)
  2. Run cd db && npm run plan, review the migration in migrations/, then npm run update
    • If migration needs changes after running: npx knex migrate:down, edit migration, then npm run update
  3. Import and use proxy.tableName

Add Real-Time Feature

  1. Use emit(url, data) in client script (defined in client/index.ts)
  2. Add route handler for the URL in routes
  3. Return updated component from handler

Wire emit() to Route Handlers

When the client calls emit('/posts/create', data), the server routes by URL. Add a route whose resolve invokes your handler:

// In your page file, export routes and add to routeDict in routes.tsx:
let routes = {
  '/posts': { title: 'Posts', node: <BlogPostsPage /> },
  '/posts/create': {
    resolve: (ctx) => {
      let data = ctx.args?.[0]
      return handleCreatePost(data, ctx)
    }
  },
}
export default { routes }

Then in server/app/routes.tsx: add ...BlogPosts.routes (or your module name) to routeDict. If you create the page with ./scripts/new-route.sh, it inserts this line and the import automatically.

Deploy

  1. Update scripts/config
  2. Run ./scripts/deploy.sh
  3. Server deploys with pm2

πŸ†˜ Getting Help

  1. Check Examples: Review demo pages at liveviews.cc
  2. Read Source: Look at existing pages in server/app/pages/
  3. GitHub Issues: github.com/beenotung/ts-liveview/issues
  4. Official Docs: github.com/beenotung/ts-liveview

πŸ’Ό Complete Real-World Example: Blog Posts Manager

See Blog Posts Manager example for a complete CRUD example with database, real-time updates, session management, and route wiring.


That's everything you need to get started! Welcome to the team! πŸš€