From 1f8c6bde50f75670caeeb6140a3a2c13292d7b2e Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 29 Nov 2025 11:20:41 -0800 Subject: [PATCH] hype --- .gitignore | 34 +++++++++++ CLAUDE.md | 111 +++++++++++++++++++++++++++++++++++ README.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++ bun.lock | 37 ++++++++++++ package.json | 16 ++++++ src/index.tsx | 90 +++++++++++++++++++++++++++++ src/utils.ts | 130 +++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 36 ++++++++++++ 8 files changed, 610 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/index.tsx create mode 100644 src/utils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/CLAUDE.md @@ -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 ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +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

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..90b510a --- /dev/null +++ b/README.md @@ -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) => + + + {title ?? 'hype'} + + + + + + + +
+ {children} +
+ + + +(Files starting with `_` don't get served directly.) + +### css + +CSS can be accessed via `/css/main.css`: + + + +### js + +JS can be accessed (and transpiled) via `/js/main.ts` or `/shared/utils.ts`: + + + +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 diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..efb035c --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f966da1 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..86ecb90 --- /dev/null +++ b/src/index.tsx @@ -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 + const withLayout = Layout ? {innerHTML} : 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) +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..19948f7 --- /dev/null +++ b/src/utils.ts @@ -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(list: T[]): T | undefined { + if (list.length === 0) return + return list[randRange(0, list.length - 1)] +} + +// randomIndex([5, 7, 9]) #=> 1 +export function randIndex(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(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 = {} + +// Transpile the frontend *.ts file at `path` to JavaScript. +export async function transpile(path: string): Promise { + 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) +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..408e62d --- /dev/null +++ b/tsconfig.json @@ -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/*" + ] + }, + } +} \ No newline at end of file