This commit is contained in:
Chris Wanstrath 2025-11-29 11:20:41 -08:00
commit 1f8c6bde50
8 changed files with 610 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

111
CLAUDE.md Normal file
View File

@ -0,0 +1,111 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
// import .css files directly and it works
import './index.css';
import { createRoot } from "react-dom/client";
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.

156
README.md Normal file
View File

@ -0,0 +1,156 @@
# hype
░▒▓███████████████████████████████████████████████▓▒░
░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
░▒▓████████▓▒░░▒▓██████▓▒░░▒▓███████▓▒░░▒▓██████▓▒░
░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░
░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░
░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░
░▒▓███████████████████████████████████████████████▓▒░
`hype` wraps `hono` with useful defaults:
- HTTP logging (disable with `NO_HTTP_LOG` env variable)
- Static files served from `pub/`
- Page-based routing to `.tsx` files that export a `JSX` function in `./src/pages`
- Transpile `.ts` files in `src/js/blah.ts` via `website.com/js/blah.ts`
## Overview
Your project structure should be:
```
.
├── README.md
├── package.json
├── pub
│   ├── asset.txt
│   └── img
│   └── logo.png
├── src
│   ├── css
│   │   └── main.css
│   ├── js
│   │   ├── about.ts
│   │   └── main.ts
│   ├── shared
│   │   └── utils.ts
│   ├── pages
│   │   ├── _layout.tsx
│   │   ├── about.tsx
│   │   └── index.tsx
│   └── server.tsx
└── tsconfig.json
```
### URLs
In the above, `/about` will render `./src/pages/about.tsx`.
The `req` JSX prop will be given the `Hono` request:
export default ({ req }) => `Query Param 'id': ${req.query('id')}`
If `_layout.tsx` exists, your pages will automatically be wrapped in it:
export default ({ children, title }: any) =>
<html lang="en">
<head>
<title>{title ?? 'hype'}</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="/js/main.ts" type="module"></script>
</head>
<body>
<main>
{children}
</main>
</body>
</html>
(Files starting with `_` don't get served directly.)
### css
CSS can be accessed via `/css/main.css`:
<link href="/css/reset.css" rel="stylesheet" />
### js
JS can be accessed (and transpiled) via `/js/main.ts` or `/shared/utils.ts`:
<script src="/js/main.ts" type="module"></script>
import your modules relatively, for example in `main.ts`:
import { initAbout } from './about'
import utils from './shared/utils'
### pub
Anything in `pub/` is served as-is. Simple stuff.
## Setup
1. Add `dev` and `start` to `package.json`:
```json
"scripts": {
"start": "bun run src/server.tsx",
"dev": "bun run --hot src/server.tsx"
},
```
2. Use our `tsconfig.json`:
```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,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"#*": ["src/*"]
}
}
}
```
3. Use `Hype` just like `Hono` in your `server.tsx`, exporting `app.defaults`:
```typescript
import { Hype } from "hype"
const app = new Hype()
app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
export default app.defaults
```
4. Enter dev mode
bun install
bun test

37
bun.lock Normal file
View File

@ -0,0 +1,37 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "hype",
"dependencies": {
"hono": "^4.10.4",
"kleur": "^4.1.5",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "hype",
"module": "src/index.tsx",
"exports": "./src/index.tsx",
"type": "module",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"hono": "^4.10.4",
"kleur": "^4.1.5"
}
}

90
src/index.tsx Normal file
View File

@ -0,0 +1,90 @@
import { join } from 'path'
import { type Context, Hono } from 'hono'
import { serveStatic } from 'hono/bun'
import color from 'kleur'
import { transpile } from './utils'
const SHOW_HTTP_LOG = true
export class Hype extends Hono {
routesRegistered = false
constructor() {
super()
}
registerRoutes() {
if (this.routesRegistered) return
this.routesRegistered = true
// static assets in pub/
this.use('/*', serveStatic({ root: './pub' }))
// css lives in src/, close to real code
this.use('/css/*', serveStatic({ root: './src' }))
// console logging
this.use('*', async (c, next) => {
if (!SHOW_HTTP_LOG) return await next()
const start = Date.now()
await next()
const end = Date.now()
const fn = c.res.status < 400 ? color.green : c.res.status < 500 ? color.yellow : color.red
const method = c.req.method === 'GET' ? color.cyan(c.req.method) : color.magenta(c.req.method)
console.log(fn(`${c.res.status}`), `${color.bold(method)} ${c.req.url} (${end - start}ms)`)
})
// serve transpiled js
this.on('GET', ['/js/:path{.+}', '/shared/:path{.+}'], async c => {
let path = './src/' + c.req.path.replace('..', '.')
// path must end in .js or .ts
if (!path.endsWith('.js') && !path.endsWith('.ts')) path += '.ts'
const ts = path.replace('.js', '.ts')
if (await Bun.file(ts).exists()) {
return new Response(await transpile(ts), { headers: { 'Content-Type': 'text/javascript' } })
} else if (await Bun.file(path).exists()) {
return new Response(Bun.file(path), { headers: { 'Content-Type': 'text/javascript' } })
} else {
return render404(c)
}
})
// file based routing
this.on('GET', ['/', '/:page'], async c => {
const pageName = (c.req.param('page') ?? 'index').replace('.', '')
if (pageName.startsWith('_')) return render404(c)
const path = join(process.env.PWD ?? '.', `./src/pages/${pageName}.tsx`)
if (!(await Bun.file(path).exists()))
return render404(c)
const layoutPath = join(process.env.PWD ?? '.', `./src/pages/_layout.tsx`)
let Layout
if (await Bun.file(layoutPath).exists())
Layout = (await import(layoutPath + `?t=${Date.now()}`)).default
const page = await import(path + `?t=${Date.now()}`)
const innerHTML = typeof page.default === 'function' ? <page.default req={c.req} /> : page.default
const withLayout = Layout ? <Layout>{innerHTML}</Layout> : innerHTML
return c.html(withLayout)
})
}
get defaults() {
this.registerRoutes()
return {
fetch: this.fetch,
idleTimeout: 255
}
}
}
function render404(c: Context) {
return c.text('File not found', 404)
}

130
src/utils.ts Normal file
View File

@ -0,0 +1,130 @@
import { type Context } from 'hono'
import { stat } from 'fs/promises'
// lighten a hex color by blending with white. opacity 1 = original, 0 = white
export function lightenColor(hex: string, opacity: number): string {
// Remove # if present
hex = hex.replace('#', '')
// Convert to RGB
let r = parseInt(hex.substring(0, 2), 16)
let g = parseInt(hex.substring(2, 4), 16)
let b = parseInt(hex.substring(4, 6), 16)
// Blend with white
r = Math.round(r * opacity + 255 * (1 - opacity))
g = Math.round(g * opacity + 255 * (1 - opacity))
b = Math.round(b * opacity + 255 * (1 - opacity))
// Convert back to hex
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')
}
// darken a hex color by blending with black. opacity 1 = original, 0 = black
export function darkenColor(hex: string, opacity: number): string {
hex = hex.replace('#', '')
let r = parseInt(hex.substring(0, 2), 16)
let g = parseInt(hex.substring(2, 4), 16)
let b = parseInt(hex.substring(4, 6), 16)
// Blend with black (0, 0, 0)
r = Math.round(r * opacity)
g = Math.round(g * opacity)
b = Math.round(b * opacity)
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')
}
// check if the user prefers dark mode
export function isDarkMode(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
// capitalize a word. that's it.
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// Generate a 6 character random ID
export function randomId(): string {
return Math.random().toString(36).slice(7)
}
// inclusive
// rand(2) == flip a coin
// rand(6) == roll a die
// rand(20) == dnd
export function rand(end = 2, startAtZero = false): number {
const start = startAtZero ? 0 : 1
return Math.floor(Math.random() * (end - start + 1)) + start
}
// randRange(1, 2) == flip a coin
// randRange(1, 6) == roll a die
// randRange(1, 20) == dnd
export function randRange(start = 0, end = 12): number {
return Math.floor(Math.random() * (end - start + 1)) + start
}
// randomItem([5, 7, 9]) #=> 7
export function randItem<T>(list: T[]): T | undefined {
if (list.length === 0) return
return list[randRange(0, list.length - 1)]
}
// randomIndex([5, 7, 9]) #=> 1
export function randIndex<T>(list: T[]): number | undefined {
if (!list.length) return
return randRange(0, list.length - 1)
}
// unique([1,1,2,2,3,3]) #=> [1,2,3]
export function unique<T>(array: T[]): T[] {
return [...new Set(array)]
}
// random number between 1 and 10, with decreasing probability
export function weightedRand(): number {
// Weights: 1 has weight 10, 2 has weight 9, ..., 10 has weight 1
const weights = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0)
let random = Math.random() * totalWeight
for (let i = 0; i < weights.length; i++) {
random -= weights[i]!
if (random <= 0) {
return numbers[i]!
}
}
return numbers[numbers.length - 1]!
}
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
const transpileCache: Record<string, string> = {}
// Transpile the frontend *.ts file at `path` to JavaScript.
export async function transpile(path: string): Promise<string> {
const { mtime } = await stat(path)
const key = `${path}?${mtime}`
let cached = transpileCache[key]
if (!cached) {
const code = await Bun.file(path).text()
cached = transpiler.transformSync(code)
cached = cached.replaceAll(/\bjsxDEV_?\w*\(/g, "jsx(")
cached = cached.replaceAll(/\bFragment_?\w*,/g, "Fragment,")
transpileCache[key] = cached
}
return cached
}
// redirect to the referrer, or fallback if no referrer
export function redirectBack(c: Context, fallback = "/") {
return c.redirect(c.req.header("Referer") || fallback)
}

36
tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"#*": [
"src/*"
]
},
}
}