Initial commit: full-stack Bun + Elysia + React template
Elysia.js API with session-based auth (email/password + Google OAuth), role system (USER/ADMIN/SUPER_ADMIN), Prisma + PostgreSQL, React 19 with Mantine UI, TanStack Router, dark theme, and comprehensive test suite (unit, integration, E2E with Lightpanda). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
177
src/index.tsx
Normal file
177
src/index.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { env } from './lib/env'
|
||||
|
||||
const isProduction = env.NODE_ENV === 'production'
|
||||
|
||||
// ─── Route Classification ──────────────────────────────
|
||||
const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health']
|
||||
|
||||
function isApiRoute(pathname: string): boolean {
|
||||
return API_PREFIXES.some((p) => pathname.startsWith(p)) || pathname === '/health'
|
||||
}
|
||||
|
||||
// ─── Vite Dev Server (dev only) ────────────────────────
|
||||
let vite: Awaited<ReturnType<typeof import('./vite').createVite>> | null = null
|
||||
if (!isProduction) {
|
||||
const { createVite } = await import('./vite')
|
||||
vite = await createVite()
|
||||
}
|
||||
|
||||
// ─── Frontend Serving ──────────────────────────────────
|
||||
async function serveFrontend(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
const pathname = url.pathname
|
||||
|
||||
if (!isProduction && vite) {
|
||||
// === DEVELOPMENT: Vite Middleware Mode ===
|
||||
|
||||
// SPA route → serve index.html via Vite transform
|
||||
if (
|
||||
pathname === '/' ||
|
||||
(!pathname.includes('.') &&
|
||||
!pathname.startsWith('/@') &&
|
||||
!pathname.startsWith('/__open-stack-frame-in-editor'))
|
||||
) {
|
||||
const htmlPath = path.resolve('index.html')
|
||||
let htmlContent = fs.readFileSync(htmlPath, 'utf-8')
|
||||
htmlContent = await vite.transformIndexHtml(pathname, htmlContent)
|
||||
|
||||
// Dedupe: Vite 8 middlewareMode injects react-refresh preamble twice
|
||||
const preamble =
|
||||
'<script type="module">import { injectIntoGlobalHook } from "/@react-refresh";\ninjectIntoGlobalHook(window);\nwindow.$RefreshReg$ = () => {};\nwindow.$RefreshSig$ = () => (type) => type;</script>'
|
||||
const firstIdx = htmlContent.indexOf(preamble)
|
||||
if (firstIdx !== -1) {
|
||||
const secondIdx = htmlContent.indexOf(preamble, firstIdx + preamble.length)
|
||||
if (secondIdx !== -1) {
|
||||
htmlContent = htmlContent.slice(0, secondIdx) + htmlContent.slice(secondIdx + preamble.length)
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(htmlContent, {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
}
|
||||
|
||||
// Asset/module requests → proxy ke Vite middleware
|
||||
// Bridge: Bun Request → Node.js IncomingMessage/ServerResponse
|
||||
return new Promise<Response>((resolve) => {
|
||||
const req = new Proxy(request, {
|
||||
get(target, prop) {
|
||||
if (prop === 'url') return pathname + url.search
|
||||
if (prop === 'method') return request.method
|
||||
if (prop === 'headers') return Object.fromEntries(request.headers as any)
|
||||
return (target as any)[prop]
|
||||
},
|
||||
}) as any
|
||||
|
||||
const chunks: (Buffer | Uint8Array)[] = []
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {} as Record<string, string>,
|
||||
setHeader(name: string, value: string) { this.headers[name.toLowerCase()] = value; return this },
|
||||
getHeader(name: string) { return this.headers[name.toLowerCase()] },
|
||||
removeHeader(name: string) { delete this.headers[name.toLowerCase()] },
|
||||
writeHead(code: number, reasonOrHeaders?: string | Record<string, string>, maybeHeaders?: Record<string, string>) {
|
||||
this.statusCode = code
|
||||
const hdrs = typeof reasonOrHeaders === 'object' ? reasonOrHeaders : maybeHeaders
|
||||
if (hdrs) for (const [k, v] of Object.entries(hdrs)) this.headers[k.toLowerCase()] = String(v)
|
||||
return this
|
||||
},
|
||||
write(chunk: any) {
|
||||
if (chunk) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
||||
return true
|
||||
},
|
||||
end(data?: any) {
|
||||
if (data) {
|
||||
if (typeof data === 'string') chunks.push(Buffer.from(data))
|
||||
else if (data instanceof Uint8Array || Buffer.isBuffer(data)) chunks.push(data)
|
||||
}
|
||||
resolve(new Response(
|
||||
chunks.length > 0 ? Buffer.concat(chunks) : null,
|
||||
{ status: this.statusCode, headers: this.headers },
|
||||
))
|
||||
},
|
||||
once() { return this },
|
||||
on() { return this },
|
||||
emit() { return this },
|
||||
removeListener() { return this },
|
||||
} as any
|
||||
|
||||
vite.middlewares(req, res, (err: any) => {
|
||||
if (err) {
|
||||
resolve(new Response(err.stack || err.toString(), { status: 500 }))
|
||||
return
|
||||
}
|
||||
resolve(new Response('Not Found', { status: 404 }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// === PRODUCTION: Static Files + SPA Fallback ===
|
||||
const filePath = path.join('dist', pathname === '/' ? 'index.html' : pathname)
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath)
|
||||
const contentType: Record<string, string> = {
|
||||
'.js': 'application/javascript', '.css': 'text/css',
|
||||
'.html': 'text/html; charset=utf-8', '.json': 'application/json',
|
||||
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon',
|
||||
}
|
||||
const isHashed = pathname.startsWith('/assets/')
|
||||
return new Response(Bun.file(filePath), {
|
||||
headers: {
|
||||
'Content-Type': contentType[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': isHashed ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SPA fallback — semua route yang tidak match file → index.html
|
||||
const indexHtml = path.join('dist', 'index.html')
|
||||
if (fs.existsSync(indexHtml)) {
|
||||
return new Response(Bun.file(indexHtml), {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
// ─── Elysia App ────────────────────────────────────────
|
||||
import { createApp } from './app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
// Frontend intercept — onRequest jalan SEBELUM route matching
|
||||
.onRequest(async ({ request }) => {
|
||||
const pathname = new URL(request.url).pathname
|
||||
|
||||
// Dev inspector: open file di editor
|
||||
if (!isProduction && pathname === '/__open-in-editor' && request.method === 'POST') {
|
||||
const { relativePath, lineNumber, columnNumber } = (await request.json()) as {
|
||||
relativePath: string; lineNumber: string; columnNumber: string
|
||||
}
|
||||
const file = `${process.cwd()}/${relativePath}`
|
||||
const editor = env.REACT_EDITOR
|
||||
const loc = `${file}:${lineNumber}:${columnNumber}`
|
||||
// zed & subl: editor file:line:col — code & cursor: editor --goto file:line:col
|
||||
const noGotoEditors = ['subl', 'zed']
|
||||
const args = noGotoEditors.includes(editor) ? [loc] : ['--goto', loc]
|
||||
const editorPath = Bun.which(editor)
|
||||
if (editorPath) Bun.spawn([editor, ...args], { stdio: ['ignore', 'ignore', 'ignore'] })
|
||||
return new Response('ok')
|
||||
}
|
||||
|
||||
// Non-API route → serve frontend
|
||||
if (!isApiRoute(pathname)) {
|
||||
return serveFrontend(request)
|
||||
}
|
||||
// undefined → lanjut ke Elysia route matching
|
||||
})
|
||||
|
||||
.listen(env.PORT)
|
||||
|
||||
console.log(`Server running at http://localhost:${app.server!.port}`)
|
||||
Reference in New Issue
Block a user