Compare commits

..

1 Commits

Author SHA1 Message Date
f96acbd17e spa shell mode 2026-01-27 23:15:27 -08:00
34 changed files with 439 additions and 3773 deletions

1
.npmrc
View File

@ -1 +0,0 @@
registry=https://npm.nose.space

View File

@ -109,13 +109,3 @@ bun --hot ./index.ts
``` ```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
## Type Declarations
This package exports raw `.tsx` source as the runtime entry (Bun handles it fine), but uses hand-written `.d.ts` files in `dist/` for the `types` export condition. This prevents consumers' `tsc` from type-checking our Hono JSX internals, which would fail if they use a different JSX runtime.
- When adding or changing exported functions/types, update the corresponding `.d.ts` file in `dist/` by hand.
- Do NOT add a build step to generate declarations. The `.d.ts` files are small and manually maintained.
- `dist/index.d.ts` — main exports (`Hype` class, `HypeProps`, `SSEHandler`, re-exports from utils/layout)
- `dist/utils.d.ts` — utility function signatures
- `dist/layout.d.ts``Layout` and `ReloadScript` FC declarations

114
README.md
View File

@ -13,12 +13,11 @@
`hype` wraps `hono` with useful features for fast prototyping: `hype` wraps `hono` with useful features for fast prototyping:
- HTTP logging to the console (disable with `NO_HTTP_LOG` env variable) - HTTP logging to the console (disable with `NO_HTTP_LOG` env variable)
- Auto-port selection in dev mode: if the port is busy, tries the next one (disable with `NO_AUTOPORT` env variable)
- Static files served from `pub/` - Static files served from `pub/`
- Page-based routing to `.tsx` files that export a `JSX` function in `./src/pages` - Page-based routing to `.tsx` files that export a `JSX` function in `./src/pages`
- Transpile `.ts` files in `src/client/blah.ts` via `website.com/client/blah.ts` - Transpile `.ts` files in `src/client/blah.ts` via `website.com/client/blah.ts`
- Helpers like `css` and `js` template tags.
- Default, simple HTML5 layout with working frontend transpilation/bundling, or supply your own. - Default, simple HTML5 layout with working frontend transpilation/bundling, or supply your own.
- Live reload in development mode — pages automatically refresh when the server restarts.
- Optional CSS reset. - Optional CSS reset.
- Optional pico.css. - Optional pico.css.
@ -61,6 +60,51 @@ The `req` JSX prop will be given the `Hono` request:
To put a file in `./src/pages` but prevent it from being server, preface it with `_`. To put a file in `./src/pages` but prevent it from being server, preface it with `_`.
### SPA Shell Mode
If `src/client/index.tsx` (or `src/client/index.ts`) exists, hype automatically serves a minimal HTML shell at `/` - no `src/pages/index.tsx` needed.
```
src/
client/
index.tsx <- This triggers SPA shell mode!
server/
index.ts
```
The shell HTML served at `/`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<title>App</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/client/index.js"></script>
</body>
</html>
```
Your client code mounts to `#app`:
```tsx
// src/client/index.tsx
import { render } from 'hono/jsx/dom'
function App() {
return <h1>Hello from the SPA!</h1>
}
render(<App />, document.getElementById('app')!)
```
See `examples/spa-shell` for a complete example.
### Layout ### Layout
`hype` will wrap everything in a simple default layout unless `./src/pages/_layout.tsx` exists, `hype` will wrap everything in a simple default layout unless `./src/pages/_layout.tsx` exists,
@ -115,6 +159,25 @@ CSS can be accessed via `/css/main.css`:
<link href="/css/main.css" rel="stylesheet" /> <link href="/css/main.css" rel="stylesheet" />
``` ```
Or written inline using the `css` template tag:
```tsx
import { css } from "hype"
export default () => (
<div>
{css`
* {
color: red;
}
`}
<h1>Hello</h1>
</div>
)
```
Install the `vscode-styled-components` VSCode extension to highlight inline `css` tags!
### css reset ### css reset
To use reset.css with the default layout, create `Hype` with `{ reset: true }`: To use reset.css with the default layout, create `Hype` with `{ reset: true }`:
@ -146,6 +209,27 @@ import { initAbout } from "./about"
import utils from "./shared/utils" import utils from "./shared/utils"
``` ```
Or written inline as transpiled typescript using the `js` template tag:
```tsx
import { js } from "hype"
export default () => (
<div>
{js`
window.onload = () => alert(welcomeMsg(Date.now()))
function welcomeMsg(time: number): string {
return "Welcome to my website!"
}
`}
<h1>Hello!</h1>
</div>
)
```
Install the `vscode-styled-components` VSCode extension to highlight inline `js` tags!
### pub ### pub
Anything in `pub/` is served as-is. Simple stuff. Anything in `pub/` is served as-is. Simple stuff.
@ -204,37 +288,15 @@ Test with curl:
curl -N http://localhost:3000/api/time curl -N http://localhost:3000/api/time
``` ```
### Live Reload
In development mode (`NODE_ENV !== 'production'`), `hype` automatically injects a live reload script into pages using the default layout. When the server restarts (e.g. via `bun --hot`), the browser will automatically reload the page.
If you're using a custom layout, you can add live reload by importing and rendering the `ReloadScript` component:
```tsx
import { ReloadScript } from "hype"
export default ({ children, title }: any) => (
<html lang="en">
<head>
<title>{title ?? "hype"}</title>
</head>
<body>
<main>{children}</main>
<ReloadScript />
</body>
</html>
)
```
`ReloadScript` only renders in development mode — it's a no-op in production.
### utils ### utils
`hype` includes helpful utils for your webapp: `hype` includes helpful utils for your webapp:
- `capitalize(str: string): string` - Capitalizes a word - `capitalize(str: string): string` - Capitalizes a word
- `css` Template Tag - Lets you inline CSS in your TSX. Returns a `<style>` tag
- `darkenColor(hex: string, opacity: number): string` - Darken a hex color by blending with black. opacity 1 = original, 0 = black - `darkenColor(hex: string, opacity: number): string` - Darken a hex color by blending with black. opacity 1 = original, 0 = black
- `isDarkMode(): boolean` - Check if the user prefers dark mode - `isDarkMode(): boolean` - Check if the user prefers dark mode
- `js` Template Tag - Lets you inline JavaScript in your TSX. Transpiles and returns a `<script>` tag
- `lightenColor(hex: string, opacity: number): string` - Lighten a hex color by blending with white. opacity 1 = original, 0 = white - `lightenColor(hex: string, opacity: number): string` - Lighten a hex color by blending with white. opacity 1 = original, 0 = white
- `rand(end = 2, startAtZero = false): number` - Generate random integer. `rand(2)` flips a coin, `rand(6)` rolls a die, `rand(20)` rolls d20 - `rand(end = 2, startAtZero = false): number` - Generate random integer. `rand(2)` flips a coin, `rand(6)` rolls a die, `rand(20)` rolls d20
- `randIndex<T>(list: T[]): number | undefined` - Get a random index from an array. `randIndex([5, 7, 9])` returns `0`, `1`, or `2` - `randIndex<T>(list: T[]): number | undefined` - Get a random index from an array. `randIndex([5, 7, 9])` returns `0`, `1`, or `2`

View File

@ -3,7 +3,7 @@
"configVersion": 1, "configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "@because/hype", "name": "hype",
"dependencies": { "dependencies": {
"hono": "^4.10.4", "hono": "^4.10.4",
"kleur": "^4.1.5", "kleur": "^4.1.5",
@ -11,19 +11,24 @@
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
}, },
"peerDependencies": {
"typescript": "^5",
},
}, },
}, },
"packages": { "packages": {
"@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/node": ["@types/node@25.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="],
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], "hono": ["hono@4.11.1", "", {}, "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "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=="],
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
{
"name": "hype-live-reload-example",
"module": "src/index.tsx",
"type": "module",
"devDependencies": {
"@types/bun": "latest"
},
"dependencies": {
"@because/hype": "*"
},
"scripts": {
"start": "bun run src/server/index.ts",
"dev": "bun run --hot src/server/index.ts"
}
}

View File

@ -1 +0,0 @@
console.log('Live reload is active in dev mode — no setup required.')

View File

@ -1,29 +0,0 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
padding-top: 4rem;
}
section {
max-width: 500px;
text-align: center;
}
code {
background: #f0f0f0;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.9em;
}
@media (prefers-color-scheme: dark) {
code {
background: #333;
}
}
.hint {
color: #888;
font-style: italic;
}

View File

@ -1,8 +0,0 @@
export default () => (
<section>
<h1>Live Reload Demo</h1>
<p>Run this with <code>bun run dev</code>, then edit this file.</p>
<p>The browser will automatically refresh no manual reload needed.</p>
<p class="hint">OMG! Try changing this text and saving!</p>
</section>
)

View File

@ -1,5 +0,0 @@
import { Hype } from '@because/hype'
const app = new Hype({})
export default app.defaults

View File

@ -0,0 +1,44 @@
# SPA Shell Mode Example
This example demonstrates the SPA shell mode feature. When `src/client/index.tsx` (or `.ts`) exists, hype automatically serves a minimal HTML shell at `/` - no `src/pages/index.tsx` needed.
## Structure
```
src/
client/
index.tsx <- This triggers SPA shell mode!
server/
index.ts
```
## Run
```sh
bun install
bun dev
```
Visit http://localhost:3000 to see the SPA in action.
## How it works
When hype detects `src/client/index.tsx` or `src/client/index.ts`, it serves this HTML at `/`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<title>App</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/client/index.js"></script>
</body>
</html>
```
Your client code mounts to `#app` and takes over from there.

View File

@ -0,0 +1,10 @@
{
"name": "spa-shell-example",
"scripts": {
"start": "bun run src/server/index.ts",
"dev": "bun run --hot src/server/index.ts"
},
"dependencies": {
"hype": "link:../.."
}
}

View File

@ -0,0 +1,21 @@
import { render } from 'hono/jsx/dom'
import { useState } from 'hono/jsx/dom'
function App() {
const [count, setCount] = useState(0)
return (
<>
<h1>SPA Shell Mode</h1>
<p>This page is served from a minimal HTML shell - no <code>src/pages/index.tsx</code> needed!</p>
<h2>Count: {count}</h2>
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
{' '}
<button onClick={() => setCount(c => c && c - 1)}>-</button>
</div>
</>
)
}
render(<App />, document.getElementById('app')!)

View File

@ -0,0 +1,8 @@
import { Hype } from 'hype'
const app = new Hype()
// Add your API routes here
app.get('/api/hello', (c) => c.json({ message: 'Hello from the API!' }))
export default app.defaults

View File

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

View File

@ -1 +0,0 @@
registry=https://npm.nose.space

35
examples/spa/bun.lock Normal file
View File

@ -0,0 +1,35 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "hype-spa-example",
"dependencies": {
"hype": "git+https://git.nose.space/defunkt/hype",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
"hype": ["hype@git+https://git.nose.space/defunkt/hype#33d228c9ac6a01fad570e0ac2ba836a100dde623", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "33d228c9ac6a01fad570e0ac2ba836a100dde623"],
"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=="],
}
}

View File

@ -5,8 +5,11 @@
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": {
"typescript": "^5"
},
"dependencies": { "dependencies": {
"@because/hype": "*" "hype": "git+https://git.nose.space/defunkt/hype"
}, },
"scripts": { "scripts": {
"start": "bun run src/server/index.ts", "start": "bun run src/server/index.ts",

View File

@ -1,4 +1,4 @@
import { Hype } from '@because/hype' import { Hype } from 'hype'
const app = new Hype({ layout: false }) const app = new Hype({ layout: false })

View File

@ -1 +0,0 @@
registry=https://npm.nose.space

35
examples/ssr/bun.lock Normal file
View File

@ -0,0 +1,35 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "hype-ssr-example",
"dependencies": {
"hype": "git+https://git.nose.space/defunkt/hype",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
"hype": ["hype@git+https://git.nose.space/defunkt/hype#33d228c9ac6a01fad570e0ac2ba836a100dde623", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "33d228c9ac6a01fad570e0ac2ba836a100dde623"],
"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=="],
}
}

View File

@ -5,8 +5,11 @@
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": {
"typescript": "^5"
},
"dependencies": { "dependencies": {
"@because/hype": "*" "hype": "git+https://git.nose.space/defunkt/hype"
}, },
"scripts": { "scripts": {
"start": "bun run src/server/index.ts", "start": "bun run src/server/index.ts",

View File

@ -1,5 +1,11 @@
import { fe } from 'hype'
const test = fe(() => {
alert('ding dong')
})
export default () => ( export default () => (
<section> <section>
<a href="#" onclick="alert('ding dong')">test</a> <a href="#" onclick={test}>test</a>
</section> </section>
) )

View File

@ -1,3 +1,5 @@
import { js } from 'hype'
export default () => ( export default () => (
<section> <section>
<h1>SSE Demo</h1> <h1>SSE Demo</h1>
@ -10,8 +12,7 @@ export default () => (
<a href="/">Back to Home</a> <a href="/">Back to Home</a>
</p> </p>
<script dangerouslySetInnerHTML={{ {js`
__html: `
const statusEl = document.getElementById('status') const statusEl = document.getElementById('status')
const timeEl = document.getElementById('time') const timeEl = document.getElementById('time')
const countEl = document.getElementById('count') const countEl = document.getElementById('count')
@ -35,6 +36,6 @@ export default () => (
statusEl.textContent = 'Disconnected' statusEl.textContent = 'Disconnected'
statusEl.style.color = 'red' statusEl.style.color = 'red'
} }
`}} /> `}
</section> </section>
) )

View File

@ -1,4 +1,4 @@
import { Hype } from '@because/hype' import { Hype } from 'hype'
const app = new Hype({}) const app = new Hype({})

View File

@ -1,31 +1,17 @@
{ {
"name": "@because/hype", "name": "hype",
"version": "0.0.14",
"module": "src/index.tsx", "module": "src/index.tsx",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": "./src/index.tsx",
"types": "./dist/index.d.ts", "./utils": "./src/utils.tsx"
"default": "./src/index.tsx"
},
"./utils": {
"types": "./dist/utils.d.ts",
"default": "./src/utils.tsx"
}
}, },
"files": [
"dist",
"src/css",
"src/frontend.ts",
"src/index.tsx",
"src/layout.tsx",
"src/lib",
"src/utils.tsx",
"README.md"
],
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": {
"typescript": "^5"
},
"dependencies": { "dependencies": {
"hono": "^4.10.4", "hono": "^4.10.4",
"kleur": "^4.1.5" "kleur": "^4.1.5"

48
src/frontend.ts Normal file
View File

@ -0,0 +1,48 @@
import { AsyncLocalStorage } from 'async_hooks'
export const fnStorage = new AsyncLocalStorage<{
fns: Map<string, string>
counter: number
}>()
export function fe<T extends Record<string, unknown>>(
fn: (args?: T) => void,
args?: T
): string {
const store = fnStorage.getStore()
if (!store) {
// Fallback to IIFE if outside request context
return args
? `(${fn.toString()})(${JSON.stringify(args)})`
: `(${fn.toString()})()`
}
const fnStr = fn.toString()
// Dedupe by function body
for (const [name, body] of store.fns)
if (body === fnStr)
return args ? `${name}(${JSON.stringify(args)})` : `${name}()`
const name = `frontendFn${store.counter++}`
store.fns.set(name, fnStr)
return args ? `${name}(${JSON.stringify(args)})` : `${name}()`
}
export function feFunctions(): string[] {
const store = fnStorage.getStore()
if (!store?.fns.size) return []
return [...store.fns.entries()].map(([name, body]) => {
// Handle arrow functions vs regular functions
if (body.startsWith('(') || body.startsWith('async ('))
return `const ${name} = ${body};`
// Named function - rename it
return body.replace(/^(async\s+)?function\s*\w*/, `$1function ${name}`)
})
}
// Keep for backwards compat
export const frontend = fe

View File

@ -1,4 +1,4 @@
import { join, resolve } from 'path' import { join } from 'path'
import { render as formatHTML } from './lib/html-formatter' import { render as formatHTML } from './lib/html-formatter'
import { type Context, Hono, type Schema, type Env } from 'hono' import { type Context, Hono, type Schema, type Env } from 'hono'
import { serveStatic } from 'hono/bun' import { serveStatic } from 'hono/bun'
@ -7,28 +7,41 @@ import color from 'kleur'
import { transpile } from './utils' import { transpile } from './utils'
import defaultLayout from './layout' import defaultLayout from './layout'
import { feFunctions, fnStorage } from './frontend'
const SHOW_HTTP_LOG = true const SHOW_HTTP_LOG = true
const CSS_RESET = await Bun.file(join(import.meta.dir, '/css/reset.css')).text() const CSS_RESET = await Bun.file(join(import.meta.dir, '/css/reset.css')).text()
const PICO_CSS = await Bun.file(join(import.meta.dir, '/css/pico.css')).text() const PICO_CSS = await Bun.file(join(import.meta.dir, '/css/pico.css')).text()
export * from './utils' export * from './utils'
export { ReloadScript } from './layout' export { frontend, frontend as fe } from './frontend'
export type { Context } from 'hono' export type { Context } from 'hono'
const pageCache = new Map() const pageCache = new Map()
function generateShellHtml(title = 'App', mountId = 'app', clientEntry = '/client/index.js'): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<title>${title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
</head>
<body>
<div id="${mountId}"></div>
<script type="module" src="${clientEntry}"></script>
</body>
</html>`
}
export type HypeProps = { export type HypeProps = {
pico?: boolean pico?: boolean
reset?: boolean reset?: boolean
prettyHTML?: boolean prettyHTML?: boolean
layout?: boolean layout?: boolean
logging?: boolean
ok?: boolean
} }
type InternalProps = HypeProps & { _isRouter?: boolean }
export type SSEHandler<E extends Env> = ( export type SSEHandler<E extends Env> = (
send: (data: unknown, event?: string) => Promise<void>, send: (data: unknown, event?: string) => Promise<void>,
c: Context<E> c: Context<E>
@ -40,20 +53,12 @@ export class Hype<
BasePath extends string = '/' BasePath extends string = '/'
> extends Hono<E, S, BasePath> { > extends Hono<E, S, BasePath> {
props: HypeProps props: HypeProps
middlewareRegistered = false
routesRegistered = false routesRegistered = false
constructor(props?: InternalProps & ConstructorParameters<typeof Hono<E, S, BasePath>>[0]) { constructor(props?: HypeProps & ConstructorParameters<typeof Hono<E, S, BasePath>>[0]) {
super(props) super(props)
this.props = props ?? {} this.props = props ?? {}
if (!props?._isRouter) {
this.registerMiddleware()
}
}
static router<E extends Env = Env>(): Hype<E> {
return new Hype<E>({ _isRouter: true })
} }
sse(path: string, handler: SSEHandler<E>) { sse(path: string, handler: SSEHandler<E>) {
@ -79,9 +84,9 @@ export class Hype<
}) })
} }
registerMiddleware() { async registerRoutes() {
if (this.middlewareRegistered) return if (this.routesRegistered) return
this.middlewareRegistered = true this.routesRegistered = true
// static assets in pub/ // static assets in pub/
this.use('/*', serveStatic({ root: './pub' })) this.use('/*', serveStatic({ root: './pub' }))
@ -90,25 +95,21 @@ export class Hype<
this.use('/css/*', serveStatic({ root: './src' })) this.use('/css/*', serveStatic({ root: './src' }))
// console logging // console logging
if (this.props.logging !== false) { this.use('*', async (c, next) => {
this.use('*', async (c, next) => { if (!SHOW_HTTP_LOG) return await next()
if (!SHOW_HTTP_LOG) return await next()
const start = Date.now() const start = Date.now()
await next() await next()
const end = Date.now() const end = Date.now()
const fn = c.res.status < 400 ? color.green : c.res.status < 500 ? color.yellow : color.red 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) 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)`) console.log(fn(`${c.res.status}`), `${color.bold(method)} ${c.req.url} (${end - start}ms)`)
}) })
}
// exception handler // exception handler
this.onError((err, c) => { this.onError((err, c) => {
const isDev = process.env.NODE_ENV !== 'production' const isDev = process.env.NODE_ENV !== 'production'
if (isDev) console.error(err)
return c.html( return c.html(
`<!DOCTYPE html> `<!DOCTYPE html>
<html> <html>
@ -132,26 +133,28 @@ export class Hype<
const res = c.res.clone() const res = c.res.clone()
const html = await res.text() const html = await res.text()
const formatted = formatHTML(html) const formatted = formatHTML(html)
const headers = new Headers(c.res.headers) c.res = new Response(formatted, c.res)
headers.delete('content-length')
c.res = new Response(formatted, { status: c.res.status, headers })
}) })
} }
}
registerRoutes() { // serve frontend js
if (this.routesRegistered) return this.use('*', async (c, next) => {
this.routesRegistered = true await fnStorage.run({ fns: new Map(), counter: 0 }, async () => {
await next()
// live reload endpoint (dev mode only) const contentType = c.res.headers.get('content-type')
if (process.env.NODE_ENV !== 'production') { if (!contentType?.includes('text/html')) return
this.sse('/__hype/reload', () => {})
this.get('/__hype/ping', c => c.text('ok'))
}
// healthcheck const fns = feFunctions()
if (this.props.ok) if (!fns.length) return
this.get('/ok', c => c.text('ok'))
const res = c.res.clone()
const html = await res.text()
const newHtml = html.replace('</body>', `<script>${fns.join('\n')}</script></body>`)
c.res = new Response(newHtml, c.res)
})
})
// css reset // css reset
this.get('/css/reset.css', async c => new Response(CSS_RESET, { headers: { 'Content-Type': 'text/css' } })) this.get('/css/reset.css', async c => new Response(CSS_RESET, { headers: { 'Content-Type': 'text/css' } }))
@ -161,45 +164,48 @@ export class Hype<
// serve transpiled js // serve transpiled js
this.on('GET', ['/client/:path{.+}', '/shared/:path{.+}'], async c => { this.on('GET', ['/client/:path{.+}', '/shared/:path{.+}'], async c => {
const reqPath = resolve('./src/', c.req.path.slice(1)) let path = './src/' + c.req.path.replace('..', '.')
if (!reqPath.startsWith(resolve('./src/'))) return render404(c)
// strip known extension to get base path // path must end in .js or .ts
const base = reqPath.replace(/\.(js|jsx|ts|tsx)$/, '') if (!path.endsWith('.js') && !path.endsWith('.ts')) path += '.ts'
// try TS extensions first (needs transpilation) const ts = path.replace('.js', '.ts')
for (const ext of ['.ts', '.tsx', '.jsx']) { if (await Bun.file(ts).exists())
const file = base + ext return new Response(await transpile(ts), { headers: { 'Content-Type': 'text/javascript' } })
if (await Bun.file(file).exists())
return new Response(await transpile(file), { headers: { 'Content-Type': 'text/javascript' } })
}
// try plain .js (serve raw) else if (await Bun.file(ts + 'x').exists())
const jsFile = base + '.js' return new Response(await transpile(ts + 'x'), { headers: { 'Content-Type': 'text/javascript' } })
if (await Bun.file(jsFile).exists())
return new Response(Bun.file(jsFile), { headers: { 'Content-Type': 'text/javascript' } })
return render404(c) 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 // SPA auto-detection: serve shell if src/client/index.tsx exists
this.on('GET', ['/', '/:page{.+}'], async c => { const clientIndexPath = join(process.env.PWD ?? '.', 'src/client/index.tsx')
const pageName = (c.req.param('page') ?? 'index').replace(/\.\./g, '') const clientIndexTsPath = join(process.env.PWD ?? '.', 'src/client/index.ts')
if (pageName.split('/').some(s => s.startsWith('_'))) return render404(c) const hasSpaClient = await Bun.file(clientIndexPath).exists() || await Bun.file(clientIndexTsPath).exists()
// try pageName.tsx, then pageName/index.tsx if (hasSpaClient) {
const base = join(process.env.PWD ?? process.cwd(), './src/pages') this.get('/', (c) => c.html(generateShellHtml()))
let path = join(base, `${pageName}.tsx`) }
if (!(await Bun.file(path).exists())) {
path = join(base, `${pageName}/index.tsx`) // file based routing
if (!(await Bun.file(path).exists())) this.on('GET', ['/', '/:page'], async c => {
return render404(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)
let Layout = defaultLayout let Layout = defaultLayout
const layoutPath = join(process.env.PWD ?? process.cwd(), `./src/pages/_layout.tsx`) const layoutPath = join(process.env.PWD ?? '.', `./src/pages/_layout.tsx`)
if (await Bun.file(layoutPath).exists()) { if (await Bun.file(layoutPath).exists()) {
Layout = pageCache.get(layoutPath) let Layout = pageCache.get(layoutPath)
if (!Layout) { if (!Layout) {
Layout = (await import(layoutPath + `?t=${Date.now()}`)).default Layout = (await import(layoutPath + `?t=${Date.now()}`)).default
pageCache.set(layoutPath, Layout) pageCache.set(layoutPath, Layout)
@ -236,8 +242,6 @@ export class Hype<
// find an available port starting from the given port // find an available port starting from the given port
function findAvailablePort(startPort: number, maxAttempts = 100): number { function findAvailablePort(startPort: number, maxAttempts = 100): number {
if (process.env.NO_AUTOPORT) return startPort
for (let port = startPort; port < startPort + maxAttempts; port++) { for (let port = startPort; port < startPort + maxAttempts; port++) {
try { try {
const server = Bun.serve({ port, fetch: () => new Response() }) const server = Bun.serve({ port, fetch: () => new Response() })
@ -253,4 +257,3 @@ function findAvailablePort(startPort: number, maxAttempts = 100): number {
function render404(c: Context) { function render404(c: Context) {
return c.text('File not found', 404) return c.text('File not found', 404)
} }

View File

@ -1,7 +1,5 @@
import { type FC } from 'hono/jsx' import { type FC } from 'hono/jsx'
const isDev = process.env.NODE_ENV !== 'production'
const Layout: FC = ({ children, title, props }) => const Layout: FC = ({ children, title, props }) =>
<html lang="en"> <html lang="en">
<head> <head>
@ -19,13 +17,7 @@ const Layout: FC = ({ children, title, props }) =>
<main> <main>
{children} {children}
</main> </main>
{isDev && <ReloadScript />}
</body> </body>
</html> </html>
const RELOAD_SCRIPT = '{let c=false;const e=new EventSource("/__hype/reload");e.onopen=()=>{if(c)location.reload();c=true};e.onerror=()=>{e.close();let d=50;setTimeout(function r(){fetch("/__hype/ping").then(()=>location.reload()).catch(()=>{d=Math.min(d*1.5,2000);setTimeout(r,d)})},d)}}'
export const ReloadScript: FC = () =>
isDev ? <script dangerouslySetInnerHTML={{ __html: RELOAD_SCRIPT }} /> : null
export default Layout export default Layout

View File

@ -46,6 +46,8 @@ const minify = function(el) {
.replace(/>\s+</g, '><') .replace(/>\s+</g, '><')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.replace(/\s>/g, '>') .replace(/\s>/g, '>')
.replace(/>\s/g, '>')
.replace(/\s</g, '<')
.replace(/class=["']\s/g, function(match) { .replace(/class=["']\s/g, function(match) {
return match.replace(/\s/g, ''); return match.replace(/\s/g, '');
}) })
@ -54,25 +56,7 @@ const minify = function(el) {
const convert = { const convert = {
comment: [], comment: [],
line: [], line: []
script: []
};
const preserveScripts = function (el) {
convert.script = [];
el = el.replace(/(<script[^>]*>)([\s\S]*?)(<\/script>)/gi, function (match, open, content, close) {
if (!content.trim()) return match;
convert.script.push(content);
return open + '[#s# : ' + (convert.script.length - 1) + ' : #s#]' + close;
});
return el;
};
const restoreScripts = function (el) {
convert.script.forEach(function (content, index) {
el = el.replace('[#s# : ' + index + ' : #s#]', content);
});
return el;
}; };
const comment = function (el) { const comment = function (el) {
@ -143,13 +127,11 @@ const tidy = function (el) {
const render = function (el, opt) { const render = function (el, opt) {
el = closing(el); el = closing(el);
el = preserveScripts(el);
el = comment(el); el = comment(el);
el = entity(el); el = entity(el);
el = minify(el); el = minify(el);
el = line(el); el = line(el);
el = tidy(el); el = tidy(el);
el = restoreScripts(el);
return el; return el;
}; };

View File

@ -1,137 +0,0 @@
import { test, expect, beforeAll, afterAll } from "bun:test"
import { Hype } from "../index.tsx"
import { join } from "path"
import { mkdirSync, writeFileSync, rmSync } from "fs"
// PWD is set to testRoot, so pages resolve at testRoot/src/pages/
const testRoot = join(process.cwd(), "src/tests/_test_root")
const pagesDir = join(testRoot, "src/pages")
beforeAll(() => {
mkdirSync(join(pagesDir, "editor"), { recursive: true })
mkdirSync(join(pagesDir, "deep/nested"), { recursive: true })
writeFileSync(join(pagesDir, "index.tsx"), `export default <h1>Home</h1>`)
writeFileSync(join(pagesDir, "about.tsx"), `export default <h1>About</h1>`)
writeFileSync(join(pagesDir, "editor/spell.tsx"), `export default <h1>Spell Editor</h1>`)
writeFileSync(join(pagesDir, "deep/nested/page.tsx"), `export default <h1>Deep Page</h1>`)
writeFileSync(join(pagesDir, "editor/index.tsx"), `export default <h1>Editor Index</h1>`)
writeFileSync(join(pagesDir, "_private.tsx"), `export default <h1>Private</h1>`)
writeFileSync(join(pagesDir, "editor/_secret.tsx"), `export default <h1>Secret</h1>`)
})
afterAll(() => {
rmSync(testRoot, { recursive: true, force: true })
})
test("single-segment page route", async () => {
const origPwd = process.env.PWD
process.env.PWD = testRoot
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
const res = await fetch(`http://localhost:${server.port}/about`)
const text = await res.text()
expect(res.status).toBe(200)
expect(text).toContain("About")
server.stop(true)
process.env.PWD = origPwd
})
test("nested page route (editor/spell)", async () => {
const origPwd = process.env.PWD
process.env.PWD = testRoot
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
const res = await fetch(`http://localhost:${server.port}/editor/spell`)
const text = await res.text()
expect(res.status).toBe(200)
expect(text).toContain("Spell Editor")
server.stop(true)
process.env.PWD = origPwd
})
test("deeply nested page route", async () => {
const origPwd = process.env.PWD
process.env.PWD = testRoot
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
const res = await fetch(`http://localhost:${server.port}/deep/nested/page`)
const text = await res.text()
expect(res.status).toBe(200)
expect(text).toContain("Deep Page")
server.stop(true)
process.env.PWD = origPwd
})
test("subdirectory index.tsx fallback", async () => {
const origPwd = process.env.PWD
process.env.PWD = testRoot
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
const res = await fetch(`http://localhost:${server.port}/editor`)
const text = await res.text()
expect(res.status).toBe(200)
expect(text).toContain("Editor Index")
server.stop(true)
process.env.PWD = origPwd
})
test("underscore-prefixed files return 404", async () => {
const origPwd = process.env.PWD
process.env.PWD = testRoot
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
const res = await fetch(`http://localhost:${server.port}/_private`)
expect(res.status).toBe(404)
server.stop(true)
process.env.PWD = origPwd
})
test("underscore-prefixed files in subdirectories return 404", async () => {
const origPwd = process.env.PWD
process.env.PWD = testRoot
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
const res = await fetch(`http://localhost:${server.port}/editor/_secret`)
expect(res.status).toBe(404)
server.stop(true)
process.env.PWD = origPwd
})
test("nonexistent page returns 404", async () => {
const origPwd = process.env.PWD
process.env.PWD = testRoot
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
const res = await fetch(`http://localhost:${server.port}/does/not/exist`)
expect(res.status).toBe(404)
server.stop(true)
process.env.PWD = origPwd
})
test("path traversal is blocked", async () => {
const origPwd = process.env.PWD
process.env.PWD = testRoot
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
const res = await fetch(`http://localhost:${server.port}/../../../etc/passwd`)
expect(res.status).toBe(404)
server.stop(true)
process.env.PWD = origPwd
})

View File

@ -1,5 +1,5 @@
import { test, expect } from "bun:test" import { test, expect } from "bun:test"
import { Hype } from "../index.tsx" import { Hype } from "./index.tsx"
test("defaults returns next available port in dev mode", () => { test("defaults returns next available port in dev mode", () => {
// Start a server on port 4000 to block it // Start a server on port 4000 to block it

View File

@ -1,6 +1,22 @@
import { type Context } from 'hono' import { type Context } from 'hono'
import { stat } from 'fs/promises' import { stat } from 'fs/promises'
// template literal tag for inline CSS. returns a <style> tag
export function css(strings: TemplateStringsArray, ...values: any[]) {
return <style dangerouslySetInnerHTML={{
__html: String.raw({ raw: strings }, ...values)
}} />
}
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
// template literal tag for inline JS. transpiles and returns a <script> tag
export function js(strings: TemplateStringsArray, ...values: any[]) {
return <script dangerouslySetInnerHTML={{
__html: transpiler.transformSync(String.raw({ raw: strings }, ...values))
}} />
}
// lighten a hex color by blending with white. opacity 1 = original, 0 = white // lighten a hex color by blending with white. opacity 1 = original, 0 = white
export function lightenColor(hex: string, opacity: number): string { export function lightenColor(hex: string, opacity: number): string {
// Remove # if present // Remove # if present