spa shell mode

This commit is contained in:
Chris Wanstrath 2026-01-27 23:15:27 -08:00
parent 1a8d422834
commit f96acbd17e
7 changed files with 183 additions and 1 deletions

View File

@ -60,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,

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

@ -0,0 +1,29 @@
{
"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/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}

View File

@ -19,6 +19,22 @@ 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
@ -68,7 +84,7 @@ export class Hype<
}) })
} }
registerRoutes() { async registerRoutes() {
if (this.routesRegistered) return if (this.routesRegistered) return
this.routesRegistered = true this.routesRegistered = true
@ -167,6 +183,15 @@ export class Hype<
return render404(c) return render404(c)
}) })
// SPA auto-detection: serve shell if src/client/index.tsx exists
const clientIndexPath = join(process.env.PWD ?? '.', 'src/client/index.tsx')
const clientIndexTsPath = join(process.env.PWD ?? '.', 'src/client/index.ts')
const hasSpaClient = await Bun.file(clientIndexPath).exists() || await Bun.file(clientIndexTsPath).exists()
if (hasSpaClient) {
this.get('/', (c) => c.html(generateShellHtml()))
}
// file based routing // file based routing
this.on('GET', ['/', '/:page'], async c => { this.on('GET', ['/', '/:page'], async c => {
const pageName = (c.req.param('page') ?? 'index').replace('.', '') const pageName = (c.req.param('page') ?? 'index').replace('.', '')