hype/docs/GUIDE.md
2026-02-19 10:07:42 -08:00

26 KiB

Guide to Writing Hype Apps

Hype is a thin, opinionated wrapper around Hono for fast prototyping with Bun. It gives you file-based routing, automatic TypeScript transpilation, SSE, inline CSS/JS helpers, and a default HTML5 layout — all without a build step.

Since Hype extends Hono, every Hono API (get, post, use, on, etc.) works out of the box.

Table of Contents


Getting Started

# add hype to your project
bun add @because/hype

# create your server entry point
mkdir -p src/server src/pages src/css src/client pub

Add scripts to package.json:

{
  "scripts": {
    "start": "bun run src/server/index.ts",
    "dev": "bun run --hot src/server/index.ts"
  }
}

Add the required tsconfig.json:

{
  "compilerOptions": {
    "lib": ["ESNext", "DOM"],
    "target": "ESNext",
    "module": "Preserve",
    "moduleDetection": "force",
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx",
    "allowJs": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "baseUrl": ".",
    "paths": {
      "$*": ["src/server/*"],
      "#*": ["src/client/*"],
      "@*": ["src/shared/*"]
    }
  }
}

Create your server:

// src/server/index.ts
import { Hype } from '@because/hype'

const app = new Hype()

export default app.defaults

Create your first page:

// src/pages/index.tsx
export default () => (
  <section>
    <h1>Hello, world!</h1>
  </section>
)

Run it:

bun dev

That's it. You have a server-rendered page at http://localhost:3000.


Project Structure

.
├── package.json
├── tsconfig.json
├── pub/                    # Static files (served as-is at /)
│   └── img/
│       └── logo.png        # => /img/logo.png
├── src/
│   ├── server/
│   │   └── index.ts        # Server entry point
│   ├── pages/
│   │   ├── index.tsx        # => GET /
│   │   ├── about.tsx        # => GET /about
│   │   └── _layout.tsx      # Custom layout (optional)
│   ├── client/
│   │   └── main.ts          # Client JS (auto-included by default layout)
│   ├── shared/
│   │   └── types.ts         # Shared between server and client
│   └── css/
│       └── main.css         # App CSS (auto-included by default layout)
  • src/pages/ — SSR pages, one file per route
  • src/client/ — Client-side TypeScript, transpiled and bundled on demand
  • src/shared/ — Isomorphic code, available to both server and client
  • src/css/ — Stylesheets
  • src/server/ — Server-only code
  • pub/ — Static assets served directly

SSR Apps

SSR is the default mode. Pages in src/pages/ are server-rendered on every request using Hono's JSX engine and wrapped in a layout.

Minimal SSR app

// src/server/index.ts
import { Hype } from '@because/hype'

const app = new Hype()

export default app.defaults
// src/pages/index.tsx
export default () => (
  <section>
    <h1>Welcome</h1>
    <p>This is server-rendered HTML.</p>
    <a href="/about">About</a>
  </section>
)
// src/pages/about.tsx
export default () => (
  <section>
    <h1>About</h1>
    <p>This website was made using futuristic internet technologies.</p>
    <a href="/">&lt;= Back</a>
  </section>
)

The default layout automatically includes src/css/main.css and src/client/main.ts, wraps your page content in <html>, <head>, and <body><main>...</main></body>.

Accessing the request

Page components receive c (the Hono context) and req (the Hono request) as props:

// src/pages/greet.tsx
export default ({ req }) => (
  <section>
    <h1>Hello, {req.query('name') ?? 'stranger'}!</h1>
  </section>
)

Visit /greet?name=Chris to see Hello, Chris!.

For the full Hono context:

// src/pages/debug.tsx
export default ({ c, req }) => {
  const ua = req.header('user-agent')
  return (
    <section>
      <h1>Request Info</h1>
      <p>URL: {req.url}</p>
      <p>User-Agent: {ua}</p>
    </section>
  )
}

SPA Apps

For a single-page app with client-side rendering, disable the default layout and provide your own HTML shell:

// src/server/index.ts
import { Hype } from '@because/hype'

const app = new Hype({ layout: false })

export default app.defaults
// src/pages/index.tsx
export default () => (
  <html lang="en">
    <head>
      <title>My SPA</title>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <meta name="color-scheme" content="light dark" />
      <link href="/css/main.css" rel="stylesheet" />
    </head>
    <body>
      <div id="root" />
      <script src="/client/app.js" type="module" />
    </body>
  </html>
)
// src/client/app.tsx
import { render } from 'hono/jsx/dom'
import { useState } from 'hono/jsx/dom'

function App() {
  const [count, setCount] = useState(0)

  return (
    <>
      <h1>My SPA</h1>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c && c - 1)}>-</button>
    </>
  )
}

render(<App />, document.getElementById('root')!)

The client-side app uses hono/jsx/dom for React-like rendering with hooks (useState, useEffect, etc.). Hype automatically transpiles and bundles src/client/app.tsx when the browser requests /client/app.js.

Cache-busting in SPAs

Use the git hash to bust asset caches:

// src/pages/index.tsx
import { $ } from 'bun'

const GIT_HASH = await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'dev')

export default () => (
  <html lang="en">
    <head>
      <link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
    </head>
    <body>
      <div id="root" />
      <script src={`/client/app.js?${GIT_HASH}`} type="module" />
    </body>
  </html>
)

Passing server data to the client

Inject globals via an inline script:

<script dangerouslySetInnerHTML={{
  __html: `
    window.GIT_HASH = '${GIT_HASH}';
    window.API_URL = '${process.env.API_URL}';
  `
}} />

Routing

Hype supports two routing layers that work together.

File-based routing

Files in src/pages/ are automatically mapped to GET routes:

File URL
src/pages/index.tsx /
src/pages/about.tsx /about
src/pages/contact.tsx /contact

Files prefixed with _ are private and won't be served (returns 404). This is used for layouts and other internal files.

Custom routes

Since Hype extends Hono, you can define any route directly:

// src/server/index.ts
import { Hype } from '@because/hype'

const app = new Hype()

// JSON API
app.get('/api/users/:id', (c) => {
  return c.json({ id: c.req.param('id'), name: 'Chris' })
})

// Form handling
app.post('/api/contact', async (c) => {
  const body = await c.req.parseBody()
  console.log('Message:', body.message)
  return c.redirect('/thanks')
})

// Custom HTML
app.get('/custom', (c) => {
  return c.html('<h1>Custom page</h1>')
})

export default app.defaults

Custom routes are defined before the file-based routes, so they take priority.


Layouts

Default layout

By default, Hype wraps every page in a simple HTML5 layout that includes:

  • <meta charset="utf-8">
  • <meta name="viewport"> for responsive design
  • <meta name="color-scheme" content="light dark"> for automatic dark mode
  • <link> to /css/main.css (your src/css/main.css)
  • <script> loading /client/main.ts (your src/client/main.ts)
  • Content wrapped in <body><main>...</main></body>

Optionally includes the CSS reset and/or Pico CSS based on your config.

Custom layout

Create src/pages/_layout.tsx to replace the default layout:

// src/pages/_layout.tsx
import type { FC } from 'hono/jsx'

const Layout: FC = ({ children, title, props }) => (
  <html lang="en">
    <head>
      <title>{title ?? 'My App'}</title>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />

      <link href="/css/main.css" rel="stylesheet" />
      <script src="/client/main.ts" type="module"></script>
    </head>
    <body>
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
      </nav>
      <main>{children}</main>
      <footer>Built with Hype</footer>
    </body>
  </html>
)

export default Layout

The layout receives:

  • children — the rendered page content
  • title — page title (defaults to 'hype')
  • props — the HypeProps passed to the constructor (useful for conditional CSS)

No layout

Disable the layout entirely for full control (used in SPA mode):

const app = new Hype({ layout: false })

Pages

Pages are .tsx files in src/pages/ that export a default function (or raw JSX).

// src/pages/index.tsx
export default ({ c, req }) => (
  <section>
    <h1>Home</h1>
  </section>
)

Static JSX export

// src/pages/about.tsx
export default (
  <section>
    <h1>About</h1>
  </section>
)

Async data in pages

Since pages render on the server, you can use top-level await:

// src/pages/users.tsx
const users = await fetch('https://api.example.com/users').then(r => r.json())

export default () => (
  <section>
    <h1>Users</h1>
    <ul>
      {users.map(u => <li>{u.name}</li>)}
    </ul>
  </section>
)

Note: top-level data is fetched once at import time and cached. For per-request data, use the c context:

// src/pages/profile.tsx
export default async ({ c, req }) => {
  const userId = req.query('id')
  const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json())

  return (
    <section>
      <h1>{user.name}</h1>
    </section>
  )
}

Private pages

Prefix a file with _ to prevent it from being served:

src/pages/_layout.tsx    # not a route — used as the layout
src/pages/_helpers.tsx   # not a route — internal helpers
src/pages/index.tsx      # GET /

Client-Side JavaScript

Automatic transpilation

TypeScript files in src/client/ and src/shared/ are automatically transpiled and bundled by Bun when requested by the browser. The URL maps directly to the file path:

File URL
src/client/main.ts /client/main.ts
src/client/app.tsx /client/app.js
src/shared/utils.ts /shared/utils.ts

You can request .ts or .js extensions — Hype resolves .ts and .tsx files automatically.

Module imports

Client-side files can import from each other using relative paths:

// src/client/main.ts
import { initBurger } from './burger'

initBurger()
// src/client/burger.ts
export function initBurger() {
  document.addEventListener('click', (ev) => {
    const el = (ev?.target as HTMLElement).closest('.burger') as HTMLImageElement
    if (!el) return
    el.src = '/img/bite.png'
  })
}

Imports are bundled — the full dependency graph is included in the output, so the browser only needs one request.

The default layout auto-includes main.ts

When using the default layout, src/client/main.ts is automatically loaded as a module. Just create the file and it works.


Styling

External CSS

Put your styles in src/css/main.css. The default layout auto-includes it:

/* src/css/main.css */
section {
  max-width: 500px;
  margin: 0 auto;
}

You can also serve additional CSS files from src/css/:

<link href="/css/components.css" rel="stylesheet" />

Pico CSS

Enable the bundled Pico CSS for classless styling:

const app = new Hype({ pico: true })

Or include it in a custom layout:

<link href="/css/pico.css" rel="stylesheet" />

CSS Reset

Enable the bundled CSS reset (Josh W. Comeau's reset):

const app = new Hype({ reset: true })

Or include it in a custom layout:

<link href="/css/reset.css" rel="stylesheet" />

Combining options

const app = new Hype({ pico: true, reset: true })

Inline CSS

Use the css template tag for scoped inline styles in any page:

import { css } from '@because/hype'

export default () => (
  <section>
    {css`
      .hero {
        background: linear-gradient(135deg, #667eea, #764ba2);
        color: white;
        padding: 4rem 2rem;
        text-align: center;
      }
    `}
    <div class="hero">
      <h1>Welcome</h1>
    </div>
  </section>
)

This renders a <style> tag inline. Install the vscode-styled-components extension for syntax highlighting.


Server-Sent Events (SSE)

Hype provides app.sse() for streaming data to the browser.

Server

// src/server/index.ts
import { Hype } from '@because/hype'

const app = new Hype()

// Stream the current time every second
app.sse('/api/time', (send) => {
  send({ time: Date.now() })
  const interval = setInterval(() => send({ time: Date.now() }), 1000)
  return () => clearInterval(interval) // cleanup on disconnect
})

export default app.defaults

The send function:

  • Automatically JSON.stringifys objects; strings are sent as-is
  • Accepts an optional second argument for named events: send(data, 'eventName')

The handler receives (send, c) where c is the Hono context:

app.sse('/api/user-events', (send, c) => {
  const userId = c.req.query('userId')
  // subscribe to user-specific events...
})

Return a cleanup function to handle client disconnection.

Client

// src/pages/sse.tsx
import { js } from '@because/hype'

export default () => (
  <section>
    <h1>SSE Demo</h1>
    <div id="time" style="font-size: 2em; font-family: monospace;"></div>

    {js`
      const timeEl = document.getElementById('time')
      const events = new EventSource('/api/time')

      events.onmessage = (e) => {
        const data = JSON.parse(e.data)
        timeEl.textContent = new Date(data.time).toLocaleTimeString()
      }

      events.onerror = () => {
        timeEl.textContent = 'Disconnected'
      }
    `}
  </section>
)

Named events

// Server
app.sse('/api/feed', (send) => {
  send({ type: 'user_joined', name: 'Chris' }, 'activity')
  send({ message: 'Hello everyone!' }, 'chat')
})
// Client
const events = new EventSource('/api/feed')

events.addEventListener('activity', (e) => {
  console.log('Activity:', JSON.parse(e.data))
})

events.addEventListener('chat', (e) => {
  console.log('Chat:', JSON.parse(e.data))
})

Test with curl

curl -N http://localhost:3000/api/time

The fe() Helper

fe() (short for "frontend") lets you define JavaScript functions on the server and use them as inline event handlers in SSR pages. The function bodies are automatically extracted and injected as a <script> before </body>.

Basic usage

import { fe } from '@because/hype'

const showAlert = fe(() => {
  alert('ding dong')
})

export default () => (
  <section>
    <button onclick={showAlert}>Click me</button>
  </section>
)

fe() returns a string like "frontendFn0()" which works as an onclick attribute value. The actual function is injected once in a <script> tag at the end of the page.

Passing arguments

Pass server data to frontend functions:

import { fe } from '@because/hype'

const greet = (name: string) => fe((args) => {
  alert(`Hello, ${args.name}!`)
}, { name })

export default () => (
  <section>
    <button onclick={greet('Chris')}>Greet Chris</button>
    <button onclick={greet('Alex')}>Greet Alex</button>
  </section>
)

Arguments are JSON-serialized and passed at call time. The function body is deduplicated — if the same function is used with different arguments, only one copy of the function is injected.

Deduplication

fe() deduplicates by function body. If you call fe() with the same function multiple times, it only injects one copy:

import { fe } from '@because/hype'

const handleClick = fe(() => {
  console.log('clicked!')
})

export default () => (
  <section>
    {/* Both buttons share the same injected function */}
    <button onclick={handleClick}>Button 1</button>
    <button onclick={handleClick}>Button 2</button>
  </section>
)

Inline css and js Tags

css template tag

Returns a <style> element. Use it anywhere in your JSX:

import { css } from '@because/hype'

export default () => (
  <section>
    {css`
      .card {
        border: 1px solid #ccc;
        border-radius: 8px;
        padding: 1rem;
      }
    `}
    <div class="card">
      <h2>Card Title</h2>
      <p>Card content</p>
    </div>
  </section>
)

js template tag

Returns a <script> element. The content is transpiled from TypeScript on the server using Bun's transpiler, so you can write TypeScript directly:

import { js } from '@because/hype'

export default () => (
  <section>
    <div id="output"></div>

    {js`
      interface TimeData {
        formatted: string
        timestamp: number
      }

      function formatTime(ts: number): TimeData {
        return {
          formatted: new Date(ts).toLocaleTimeString(),
          timestamp: ts
        }
      }

      const el = document.getElementById('output')!
      setInterval(() => {
        el.textContent = formatTime(Date.now()).formatted
      }, 1000)
    `}
  </section>
)

Interpolation

Both tags support template literal interpolation:

const primaryColor = '#3498db'

export default () => (
  <section>
    {css`
      h1 { color: ${primaryColor}; }
    `}
    <h1>Styled heading</h1>
  </section>
)

Custom API Routes

Since Hype extends Hono, use any Hono routing method:

import { Hype } from '@because/hype'

const app = new Hype()

// GET with params
app.get('/api/users/:id', (c) => {
  return c.json({ id: c.req.param('id') })
})

// POST with body parsing
app.post('/api/users', async (c) => {
  const body = await c.req.json()
  return c.json({ created: true, name: body.name }, 201)
})

// Form submission
app.post('/contact', async (c) => {
  const form = await c.req.parseBody()
  console.log(form.email, form.message)
  return c.redirect('/')
})

// Delete
app.delete('/api/users/:id', (c) => {
  return c.json({ deleted: true })
})

export default app.defaults

Redirect back

Use redirectBack() to redirect to the referrer:

import { Hype, redirectBack } from '@because/hype'

const app = new Hype()

app.post('/api/like', async (c) => {
  // ... handle the like
  return redirectBack(c, '/') // falls back to '/' if no referrer
})

Sub-Routers

Use Hype.router() to create sub-routers without duplicate middleware:

// src/server/index.ts
import { Hype } from '@because/hype'
import api from './api'

const app = new Hype()
app.route('/api', api)

export default app.defaults
// src/server/api.ts
import { Hype } from '@because/hype'

const api = Hype.router()

api.get('/users', (c) => c.json([{ id: 1, name: 'Chris' }]))
api.get('/users/:id', (c) => c.json({ id: c.req.param('id') }))
api.post('/users', async (c) => {
  const body = await c.req.json()
  return c.json(body, 201)
})

export default api

Hype.router() creates a Hype instance that skips middleware registration (no duplicate logging, static file serving, etc.).


Static Files

Files in pub/ are served at the root URL:

pub/img/logo.png     => /img/logo.png
pub/favicon.ico      => /favicon.ico
pub/robots.txt       => /robots.txt

Use them in pages:

export default () => (
  <section>
    <img src="/img/logo.png" width="200" />
  </section>
)

Configuration

Constructor options

const app = new Hype({
  pico: true,       // Include Pico CSS (default: false)
  reset: true,      // Include CSS reset (default: false)
  prettyHTML: true,  // Pretty-print HTML in dev (default: true in dev, false in prod)
  layout: true,     // Wrap pages in layout (default: true)
  logging: true,    // HTTP request logging (default: true)
  ok: true,         // Add GET /ok healthcheck (default: false)
})

app.defaults

Always export app.defaults as the default export. It returns a Bun.serve-compatible config object:

export default app.defaults
// => { port, fetch, idleTimeout: 255 }

This triggers lazy route registration and auto-port selection. Bun picks it up as the server config when the file is the entry point.


Environment Variables

Variable Effect
PORT Server port (default: 3000)
NODE_ENV Set to production to disable auto-port, HTML prettification, and enable transpile caching
NO_AUTOPORT Disable auto-port selection even in dev

In dev mode, if port 3000 is busy, Hype automatically tries 3001, 3002, etc. (up to 100 attempts).


Path Aliases

The recommended tsconfig.json defines three path aliases:

Alias Maps to Use for
$* src/server/* Server-only imports
#* src/client/* Client-only imports
@* src/shared/* Isomorphic imports
// In server code:
import { db } from '$db'         // => src/server/db.ts

// In client code:
import { format } from '@utils'  // => src/shared/utils.ts

Utility Functions

Hype exports a collection of helpers from @because/hype (or @because/hype/utils):

Randomness

import { rand, randRange, randItem, randIndex, shuffle, weightedRand, randomId } from '@because/hype'

rand()            // 1 or 2 (coin flip)
rand(6)           // 1-6 (roll a die)
rand(20)          // 1-20 (d20)
randRange(5, 10)  // 5-10 inclusive
randItem(['a', 'b', 'c'])   // random element
randIndex(['a', 'b', 'c'])  // random index (0, 1, or 2)
shuffle([1, 2, 3, 4, 5])    // shuffled copy
weightedRand()    // 1-10, lower numbers more likely
randomId()        // e.g. "k7x2m1"

Arrays

import { times, unique } from '@because/hype'

times(5)                    // [1, 2, 3, 4, 5]
unique([1, 1, 2, 2, 3, 3]) // [1, 2, 3]

Colors

import { lightenColor, darkenColor } from '@because/hype'

lightenColor('#3498db', 0.5)  // blend halfway to white
darkenColor('#3498db', 0.5)   // blend halfway to black

Strings

import { capitalize } from '@because/hype'

capitalize('hello')  // "Hello"

Dark mode detection (client-side)

import { isDarkMode } from '@because/hype'

if (isDarkMode()) {
  // user prefers dark mode
}

Transpilation

import { transpile } from '@because/hype'

const js = await transpile('./src/client/app.tsx')
// => bundled ESM JavaScript string

Recipes

Form with POST handler

// src/server/index.ts
import { Hype, redirectBack } from '@because/hype'

const app = new Hype()

const messages: string[] = []

app.post('/api/message', async (c) => {
  const { message } = await c.req.parseBody()
  if (typeof message === 'string') messages.push(message)
  return redirectBack(c)
})

app.get('/api/messages', (c) => c.json(messages))

export default app.defaults
// src/pages/index.tsx
export default () => (
  <section>
    <h1>Guestbook</h1>
    <form method="POST" action="/api/message">
      <input name="message" placeholder="Leave a message" required />
      <button type="submit">Send</button>
    </form>
  </section>
)

Healthcheck endpoint

const app = new Hype({ ok: true })
// GET /ok => "ok" (200)

SSR with Pico CSS

const app = new Hype({ pico: true, reset: true })

All your pages instantly get clean, classless styling. Just write semantic HTML.

Multiple SSE channels

const app = new Hype()

// Chat messages
app.sse('/api/chat', (send, c) => {
  const room = c.req.query('room') ?? 'general'
  // subscribe to chat room...
  return () => { /* unsubscribe */ }
})

// Live notifications
app.sse('/api/notifications', (send, c) => {
  send({ type: 'connected' })
  const check = setInterval(async () => {
    // check for new notifications...
    send({ count: 5 }, 'notification')
  }, 5000)
  return () => clearInterval(check)
})

Mixing SSR pages with a JSON API

import { Hype } from '@because/hype'

const app = new Hype({ pico: true })

// API routes (JSON)
app.get('/api/data', (c) => c.json({ items: [1, 2, 3] }))

// SSR pages still work via src/pages/
// Custom SSR route:
app.get('/dashboard', (c) => {
  return c.html(
    <html>
      <body>
        <h1>Dashboard</h1>
        <div id="data"></div>
        <script dangerouslySetInnerHTML={{ __html: `
          fetch('/api/data')
            .then(r => r.json())
            .then(d => document.getElementById('data').textContent = JSON.stringify(d))
        `}} />
      </body>
    </html>
  )
})

export default app.defaults

Using Hono middleware

Any Hono middleware works with Hype:

import { Hype } from '@because/hype'
import { cors } from 'hono/cors'
import { basicAuth } from 'hono/basic-auth'

const app = new Hype()

// CORS for API routes
app.use('/api/*', cors())

// Basic auth for admin
app.use('/admin/*', basicAuth({
  username: 'admin',
  password: process.env.ADMIN_PASSWORD!,
}))

export default app.defaults