258 lines
7.7 KiB
TypeScript
258 lines
7.7 KiB
TypeScript
import { join } from 'path'
|
|
import { render as formatHTML } from './lib/html-formatter'
|
|
import { type Context, Hono, type Schema, type Env } from 'hono'
|
|
import { serveStatic } from 'hono/bun'
|
|
import { streamSSE } from 'hono/streaming'
|
|
import color from 'kleur'
|
|
|
|
import { transpile } from './utils'
|
|
import defaultLayout from './layout'
|
|
import { feFunctions, fnStorage } from './frontend'
|
|
|
|
const SHOW_HTTP_LOG = true
|
|
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()
|
|
|
|
export * from './utils'
|
|
export { frontend, frontend as fe } from './frontend'
|
|
export type { Context } from 'hono'
|
|
|
|
const pageCache = new Map()
|
|
|
|
export type HypeProps = {
|
|
pico?: boolean
|
|
reset?: boolean
|
|
prettyHTML?: boolean
|
|
layout?: boolean
|
|
logging?: boolean
|
|
}
|
|
|
|
type InternalProps = HypeProps & { _isRouter?: boolean }
|
|
|
|
export type SSEHandler<E extends Env> = (
|
|
send: (data: unknown, event?: string) => Promise<void>,
|
|
c: Context<E>
|
|
) => void | (() => void) | Promise<void | (() => void)>
|
|
|
|
export class Hype<
|
|
E extends Env = Env,
|
|
S extends Schema = {},
|
|
BasePath extends string = '/'
|
|
> extends Hono<E, S, BasePath> {
|
|
props: HypeProps
|
|
middlewareRegistered = false
|
|
routesRegistered = false
|
|
|
|
constructor(props?: InternalProps & ConstructorParameters<typeof Hono<E, S, BasePath>>[0]) {
|
|
super(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>) {
|
|
return this.get(path, (c) => {
|
|
return streamSSE(c, async (stream) => {
|
|
const send = async (data: unknown, event?: string) => {
|
|
await stream.writeSSE({
|
|
data: typeof data === 'string' ? data : JSON.stringify(data),
|
|
event,
|
|
})
|
|
}
|
|
|
|
const cleanup = await handler(send, c)
|
|
|
|
// Keep stream open until client disconnects
|
|
await new Promise<void>((resolve) => {
|
|
stream.onAbort(() => {
|
|
cleanup?.()
|
|
resolve()
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
registerMiddleware() {
|
|
if (this.middlewareRegistered) return
|
|
this.middlewareRegistered = 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
|
|
if (this.props.logging !== false) {
|
|
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)`)
|
|
})
|
|
}
|
|
|
|
// exception handler
|
|
this.onError((err, c) => {
|
|
const isDev = process.env.NODE_ENV !== 'production'
|
|
|
|
return c.html(
|
|
`<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<h1>Error: ${err.message}</h1>
|
|
${isDev ? `<pre>${err.stack}</pre>` : '<p>An error occurred</p>'}
|
|
</body>
|
|
</html>`,
|
|
500
|
|
)
|
|
})
|
|
|
|
// prettify HTML output
|
|
if (this.props.prettyHTML ?? process.env.NODE_ENV !== 'production') {
|
|
this.use('*', async (c, next) => {
|
|
await next()
|
|
|
|
const contentType = c.res.headers.get('content-type')
|
|
if (!contentType?.includes('text/html')) return
|
|
|
|
const res = c.res.clone()
|
|
const html = await res.text()
|
|
const formatted = formatHTML(html)
|
|
const headers = new Headers(c.res.headers)
|
|
headers.delete('content-length')
|
|
c.res = new Response(formatted, { status: c.res.status, headers })
|
|
})
|
|
}
|
|
}
|
|
|
|
registerRoutes() {
|
|
if (this.routesRegistered) return
|
|
this.routesRegistered = true
|
|
|
|
// serve frontend js
|
|
this.use('*', async (c, next) => {
|
|
await fnStorage.run({ fns: new Map(), counter: 0 }, async () => {
|
|
await next()
|
|
|
|
const contentType = c.res.headers.get('content-type')
|
|
if (!contentType?.includes('text/html')) return
|
|
|
|
const fns = feFunctions()
|
|
if (!fns.length) return
|
|
|
|
const res = c.res.clone()
|
|
const html = await res.text()
|
|
const newHtml = html.replace('</body>', `<script>${fns.join('\n')}</script></body>`)
|
|
const headers = new Headers(c.res.headers)
|
|
headers.delete('content-length')
|
|
c.res = new Response(newHtml, { status: c.res.status, headers })
|
|
})
|
|
})
|
|
|
|
// css reset
|
|
this.get('/css/reset.css', async c => new Response(CSS_RESET, { headers: { 'Content-Type': 'text/css' } }))
|
|
|
|
// pico
|
|
this.get('/css/pico.css', async c => new Response(PICO_CSS, { headers: { 'Content-Type': 'text/css' } }))
|
|
|
|
// serve transpiled js
|
|
this.on('GET', ['/client/: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(ts + 'x').exists())
|
|
return new Response(await transpile(ts + 'x'), { 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)
|
|
|
|
let Layout = defaultLayout
|
|
const layoutPath = join(process.env.PWD ?? '.', `./src/pages/_layout.tsx`)
|
|
if (await Bun.file(layoutPath).exists()) {
|
|
let Layout = pageCache.get(layoutPath)
|
|
if (!Layout) {
|
|
Layout = (await import(layoutPath + `?t=${Date.now()}`)).default
|
|
pageCache.set(layoutPath, Layout)
|
|
}
|
|
}
|
|
|
|
let page = pageCache.get(path)
|
|
if (!page) {
|
|
page = await import(path + `?t=${Date.now()}`)
|
|
pageCache.set(path, page)
|
|
}
|
|
|
|
const innerHTML = typeof page.default === 'function' ? <page.default c={c} req={c.req} /> : page.default
|
|
const withLayout = this.props.layout !== false ? <Layout props={this.props}>{innerHTML}</Layout> : innerHTML
|
|
return c.html(withLayout)
|
|
})
|
|
}
|
|
|
|
get defaults() {
|
|
this.registerRoutes()
|
|
|
|
const isDev = process.env.NODE_ENV !== 'production'
|
|
let port = process.env.PORT ? Number(process.env.PORT) : 3000
|
|
|
|
if (isDev) port = findAvailablePort(port)
|
|
|
|
return {
|
|
port,
|
|
fetch: this.fetch,
|
|
idleTimeout: 255
|
|
}
|
|
}
|
|
}
|
|
|
|
// find an available port starting from the given port
|
|
function findAvailablePort(startPort: number, maxAttempts = 100): number {
|
|
if (process.env.NO_AUTOPORT) return startPort
|
|
|
|
for (let port = startPort; port < startPort + maxAttempts; port++) {
|
|
try {
|
|
const server = Bun.serve({ port, fetch: () => new Response() })
|
|
server.stop(true)
|
|
return port
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
throw new Error(`Could not find an available port after ${maxAttempts} attempts starting from ${startPort}`)
|
|
}
|
|
|
|
function render404(c: Context) {
|
|
return c.text('File not found', 404)
|
|
}
|