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 = ( send: (data: unknown, event?: string) => Promise, c: Context ) => void | (() => void) | Promise void)> export class Hype< E extends Env = Env, S extends Schema = {}, BasePath extends string = '/' > extends Hono { props: HypeProps middlewareRegistered = false routesRegistered = false constructor(props?: InternalProps & ConstructorParameters>[0]) { super(props) this.props = props ?? {} if (!props?._isRouter) { this.registerMiddleware() } } static router(): Hype { return new Hype({ _isRouter: true }) } sse(path: string, handler: SSEHandler) { 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((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( `

Error: ${err.message}

${isDev ? `
${err.stack}
` : '

An error occurred

'} `, 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('', ``) 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 const withLayout = this.props.layout !== false ? {innerHTML} : 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) }