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:
bipproduction
2026-04-01 10:12:19 +08:00
commit 08a1054e3c
57 changed files with 3732 additions and 0 deletions

158
src/app.ts Normal file
View File

@@ -0,0 +1,158 @@
import { cors } from '@elysiajs/cors'
import { html } from '@elysiajs/html'
import { Elysia } from 'elysia'
import { prisma } from './lib/db'
import { env } from './lib/env'
export function createApp() {
return new Elysia()
.use(cors())
.use(html())
// ─── Global Error Handler ────────────────────────
.onError(({ code, error }) => {
if (code === 'NOT_FOUND') {
return new Response(JSON.stringify({ error: 'Not Found', status: 404 }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
}
console.error('[Server Error]', error)
return new Response(JSON.stringify({ error: 'Internal Server Error', status: 500 }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
})
// API routes
.get('/health', () => ({ status: 'ok' }))
// ─── Auth API ──────────────────────────────────────
.post('/api/auth/login', async ({ request, set }) => {
const { email, password } = (await request.json()) as { email: string; password: string }
let user = await prisma.user.findUnique({ where: { email } })
if (!user || !(await Bun.password.verify(password, user.password))) {
set.status = 401
return { error: 'Email atau password salah' }
}
// Auto-promote super admin from env
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') {
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } })
}
const token = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
})
.post('/api/auth/logout', async ({ request, set }) => {
const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1]
if (token) await prisma.session.deleteMany({ where: { token } })
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
return { ok: true }
})
.get('/api/auth/session', async ({ request, set }) => {
const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1]
if (!token) { set.status = 401; return { user: null } }
const session = await prisma.session.findUnique({
where: { token },
include: { user: { select: { id: true, name: true, email: true, role: true } } },
})
if (!session || session.expiresAt < new Date()) {
if (session) await prisma.session.delete({ where: { id: session.id } })
set.status = 401
return { user: null }
}
return { user: session.user }
})
// ─── Google OAuth ──────────────────────────────────
.get('/api/auth/google', ({ request, set }) => {
const origin = new URL(request.url).origin
const params = new URLSearchParams({
client_id: env.GOOGLE_CLIENT_ID,
redirect_uri: `${origin}/api/auth/callback/google`,
response_type: 'code',
scope: 'openid email profile',
access_type: 'offline',
prompt: 'consent',
})
set.status = 302; set.headers['location'] =`https://accounts.google.com/o/oauth2/v2/auth?${params}`
})
.get('/api/auth/callback/google', async ({ request, set }) => {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const origin = url.origin
if (!code) {
set.status = 302; set.headers['location'] ='/login?error=google_failed'
return
}
// Exchange code for tokens
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: `${origin}/api/auth/callback/google`,
grant_type: 'authorization_code',
}),
})
if (!tokenRes.ok) {
set.status = 302; set.headers['location'] ='/login?error=google_failed'
return
}
const tokens = (await tokenRes.json()) as { access_token: string }
// Get user info
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
})
if (!userInfoRes.ok) {
set.status = 302; set.headers['location'] ='/login?error=google_failed'
return
}
const googleUser = (await userInfoRes.json()) as { email: string; name: string }
// Upsert user (no password for Google users)
const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email)
const user = await prisma.user.upsert({
where: { email: googleUser.email },
update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) },
create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' },
})
// Create session
const token = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
})
// ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({
message: 'Hello, world!',
method: 'GET',
}))
.put('/api/hello', () => ({
message: 'Hello, world!',
method: 'PUT',
}))
.get('/api/hello/:name', ({ params }) => ({
message: `Hello, ${params.name}!`,
}))
}

34
src/frontend.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './frontend/App'
// DevInspector hanya di-import saat dev (tree-shaken di production)
const InspectorWrapper = import.meta.env?.DEV
? (await import('./frontend/DevInspector')).DevInspector
: ({ children }: { children: ReactNode }) => <>{children}</>
// Remove splash screen after React mounts
function removeSplash() {
const splash = document.getElementById('splash')
if (splash) {
splash.classList.add('fade-out')
setTimeout(() => splash.remove(), 300)
}
}
const elem = document.getElementById('root')!
const app = (
<InspectorWrapper>
<App />
</InspectorWrapper>
)
// HMR-safe: reuse root agar React state preserved saat hot reload
if (import.meta.hot) {
import.meta.hot.data.root ??= createRoot(elem)
import.meta.hot.data.root.render(app)
} else {
createRoot(elem).render(app)
}
removeSplash()

40
src/frontend/App.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const theme = createTheme({
primaryColor: 'blue',
fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif',
})
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1 },
},
})
const router = createRouter({
routeTree,
context: { queryClient },
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
export function App() {
return (
<>
<ColorSchemeScript defaultColorScheme="dark" />
<MantineProvider theme={theme} defaultColorScheme="dark" forceColorScheme="dark">
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</MantineProvider>
</>
)
}

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from 'react'
interface CodeInfo {
relativePath: string
line: string
column: string
}
/** Walk up DOM tree, cari elemen dengan data-inspector-* attributes */
function findCodeInfo(target: HTMLElement): { element: HTMLElement; info: CodeInfo } | null {
let el: HTMLElement | null = target
while (el) {
const relativePath = el.getAttribute('data-inspector-relative-path')
const line = el.getAttribute('data-inspector-line')
const column = el.getAttribute('data-inspector-column')
if (relativePath && line) {
return {
element: el,
info: { relativePath, line, column: column ?? '1' },
}
}
el = el.parentElement
}
return null
}
function openInEditor(info: CodeInfo) {
fetch('/__open-in-editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
relativePath: info.relativePath,
lineNumber: info.line,
columnNumber: info.column,
}),
})
}
export function DevInspector({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState(false)
const overlayRef = useRef<HTMLDivElement | null>(null)
const tooltipRef = useRef<HTMLDivElement | null>(null)
const lastInfoRef = useRef<CodeInfo | null>(null)
const updateOverlay = useCallback((target: HTMLElement | null) => {
const ov = overlayRef.current
const tt = tooltipRef.current
if (!ov || !tt) return
if (!target) {
ov.style.display = 'none'
tt.style.display = 'none'
lastInfoRef.current = null
return
}
const result = findCodeInfo(target)
if (!result) {
ov.style.display = 'none'
tt.style.display = 'none'
lastInfoRef.current = null
return
}
lastInfoRef.current = result.info
const rect = result.element.getBoundingClientRect()
ov.style.display = 'block'
ov.style.top = `${rect.top + window.scrollY}px`
ov.style.left = `${rect.left + window.scrollX}px`
ov.style.width = `${rect.width}px`
ov.style.height = `${rect.height}px`
tt.style.display = 'block'
tt.textContent = `${result.info.relativePath}:${result.info.line}`
const ttTop = rect.top + window.scrollY - 24
tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px`
tt.style.left = `${rect.left + window.scrollX}px`
}, [])
useEffect(() => {
if (!active) return
const onMouseOver = (e: MouseEvent) => updateOverlay(e.target as HTMLElement)
const onClick = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const result = findCodeInfo(e.target as HTMLElement)
const info = result?.info ?? lastInfoRef.current
if (info) {
const loc = `${info.relativePath}:${info.line}:${info.column}`
navigator.clipboard.writeText(loc).catch(() => {})
openInEditor(info)
}
setActive(false)
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setActive(false)
}
document.addEventListener('mouseover', onMouseOver, true)
document.addEventListener('click', onClick, true)
document.addEventListener('keydown', onKeyDown)
document.body.style.cursor = 'crosshair'
return () => {
document.removeEventListener('mouseover', onMouseOver, true)
document.removeEventListener('click', onClick, true)
document.removeEventListener('keydown', onKeyDown)
document.body.style.cursor = ''
if (overlayRef.current) overlayRef.current.style.display = 'none'
if (tooltipRef.current) tooltipRef.current.style.display = 'none'
}
}, [active, updateOverlay])
// Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C (Windows/Linux)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'c' && e.ctrlKey && e.shiftKey && (e.metaKey || e.altKey)) {
e.preventDefault()
setActive((prev) => !prev)
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
return (
<>
{children}
<div
ref={overlayRef}
style={{
display: 'none',
position: 'absolute',
pointerEvents: 'none',
border: '2px solid #3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
zIndex: 99999,
transition: 'all 0.05s ease',
}}
/>
<div
ref={tooltipRef}
style={{
display: 'none',
position: 'absolute',
pointerEvents: 'none',
backgroundColor: '#1e293b',
color: '#e2e8f0',
fontSize: '12px',
fontFamily: 'monospace',
padding: '2px 6px',
borderRadius: '3px',
zIndex: 100000,
whiteSpace: 'nowrap',
}}
/>
</>
)
}

View File

@@ -0,0 +1,26 @@
import { Button, Center, Code, Stack, Text, Title } from '@mantine/core'
import { TbAlertTriangle, TbRefresh } from 'react-icons/tb'
export function ErrorPage({ error }: { error: unknown }) {
const message = error instanceof Error ? error.message : 'Terjadi kesalahan yang tidak terduga'
return (
<Center mih="100vh">
<Stack align="center" gap="md" maw={500}>
<TbAlertTriangle size={80} color="var(--mantine-color-red-6)" />
<Title order={1}>500</Title>
<Text c="dimmed" size="lg" ta="center">Terjadi kesalahan pada server</Text>
<Code block c="red" style={{ maxWidth: '100%', wordBreak: 'break-word' }}>
{message}
</Code>
<Button
onClick={() => window.location.reload()}
leftSection={<TbRefresh size={18} />}
variant="light"
>
Muat Ulang
</Button>
</Stack>
</Center>
)
}

View File

@@ -0,0 +1,18 @@
import { Button, Center, Stack, Text, Title } from '@mantine/core'
import { Link } from '@tanstack/react-router'
import { TbArrowLeft, TbError404 } from 'react-icons/tb'
export function NotFound() {
return (
<Center mih="100vh">
<Stack align="center" gap="md">
<TbError404 size={80} color="var(--mantine-color-dimmed)" />
<Title order={1}>404</Title>
<Text c="dimmed" size="lg">Halaman tidak ditemukan</Text>
<Button component={Link} to="/" leftSection={<TbArrowLeft size={18} />} variant="light">
Kembali ke Beranda
</Button>
</Stack>
</Center>
)
}

View File

@@ -0,0 +1,66 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
export type Role = 'USER' | 'ADMIN' | 'SUPER_ADMIN'
export interface User {
id: string
name: string
email: string
role: Role
}
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { credentials: 'include', ...init })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Request failed' }))
throw new Error(err.error || `HTTP ${res.status}`)
}
return res.json()
}
export function useSession() {
return useQuery({
queryKey: ['auth', 'session'],
queryFn: () => apiFetch<{ user: User | null }>('/api/auth/session'),
retry: false,
staleTime: 30_000,
})
}
export function useLogin() {
const queryClient = useQueryClient()
const navigate = useNavigate()
return useMutation({
mutationFn: (data: { email: string; password: string }) =>
apiFetch<{ user: User }>('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
onSuccess: (data) => {
queryClient.setQueryData(['auth', 'session'], data)
// Super admin → dashboard, others → profile
if (data.user.role === 'SUPER_ADMIN') {
navigate({ to: '/dashboard' })
} else {
navigate({ to: '/profile' })
}
},
})
}
export function useLogout() {
const queryClient = useQueryClient()
const navigate = useNavigate()
return useMutation({
mutationFn: () =>
apiFetch<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
onSuccess: () => {
queryClient.setQueryData(['auth', 'session'], { user: null })
navigate({ to: '/login' })
},
})
}

View File

@@ -0,0 +1,14 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { NotFound } from '@/frontend/components/NotFound'
import { ErrorPage } from '@/frontend/components/ErrorPage'
interface RouterContext {
queryClient: QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
notFoundComponent: NotFound,
errorComponent: ({ error }) => <ErrorPage error={error} />,
})

View File

@@ -0,0 +1,94 @@
import {
Avatar,
Badge,
Button,
Card,
Container,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { TbChartBar, TbLogout, TbSettings, TbUsers } from 'react-icons/tb'
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => {
try {
const data = await context.queryClient.ensureQueryData({
queryKey: ['auth', 'session'],
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
})
if (!data?.user) throw redirect({ to: '/login' })
if (data.user.role !== 'SUPER_ADMIN') throw redirect({ to: '/profile' })
} catch (e) {
if (e instanceof Error) throw redirect({ to: '/login' })
throw e
}
},
component: DashboardPage,
})
const stats = [
{ title: 'Users', value: '1,234', icon: TbUsers, color: 'blue' },
{ title: 'Revenue', value: '$12.4k', icon: TbChartBar, color: 'green' },
{ title: 'Settings', value: '3 active', icon: TbSettings, color: 'violet' },
]
function DashboardPage() {
const { data } = useSession()
const logout = useLogout()
const user = data?.user
return (
<Container size="md" py="xl">
<Stack gap="xl">
<Group justify="space-between">
<Title order={2}>Dashboard</Title>
<Button
variant="light"
color="red"
leftSection={<TbLogout size={16} />}
onClick={() => logout.mutate()}
loading={logout.isPending}
>
Logout
</Button>
</Group>
<Paper withBorder p="lg" radius="md">
<Group>
<Avatar color="blue" radius="xl" size="lg">
{user?.name?.charAt(0).toUpperCase()}
</Avatar>
<div>
<Group gap="xs">
<Text fw={500}>{user?.name}</Text>
<Badge color="red" variant="light" size="sm">SUPER ADMIN</Badge>
</Group>
<Text c="dimmed" size="sm">{user?.email}</Text>
</div>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{stats.map((stat) => (
<Card key={stat.title} withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="sm" c="dimmed" fw={500}>{stat.title}</Text>
<ThemeIcon variant="light" color={stat.color} size="sm">
<stat.icon size={14} />
</ThemeIcon>
</Group>
<Text fw={700} size="xl">{stat.value}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Container>
)
}

View File

@@ -0,0 +1,36 @@
import { Button, Container, Group, Stack, Text, Title } from '@mantine/core'
import { Link, createFileRoute } from '@tanstack/react-router'
import { SiBun } from 'react-icons/si'
import { TbBrandReact, TbLogin, TbRocket } from 'react-icons/tb'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
return (
<Container size="sm" py="xl">
<Stack align="center" gap="lg">
<Group gap="lg">
<SiBun size={64} color="#fbf0df" />
<TbBrandReact size={64} color="#61dafb" />
</Group>
<Title order={1}>Bun + Elysia + Vite + React</Title>
<Text c="dimmed" ta="center" maw={480}>
Full-stack starter template with Mantine UI, TanStack Router, and session-based auth.
</Text>
<Group>
<Button component={Link} to="/login" leftSection={<TbLogin size={18} />} variant="filled">
Login
</Button>
<Button component={Link} to="/dashboard" leftSection={<TbRocket size={18} />} variant="light">
Dashboard
</Button>
</Group>
</Stack>
</Container>
)
}

View File

@@ -0,0 +1,115 @@
import {
Alert,
Button,
Center,
Divider,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react'
import { FcGoogle } from 'react-icons/fc'
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
import { useLogin } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/login')({
validateSearch: (search: Record<string, unknown>) => ({
error: (search.error as string) || undefined,
}),
beforeLoad: async ({ context }) => {
try {
const data = await context.queryClient.ensureQueryData({
queryKey: ['auth', 'session'],
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
})
if (data?.user) {
throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' })
}
} catch (e) {
if (e instanceof Error) return
throw e
}
},
component: LoginPage,
})
function LoginPage() {
const login = useLogin()
const { error: searchError } = Route.useSearch()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
login.mutate({ email, password })
}
return (
<Center mih="100vh">
<Paper shadow="md" p="xl" radius="md" w={400} withBorder>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Title order={2} ta="center">
Login
</Title>
<Text c="dimmed" size="sm" ta="center">
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
<br />
atau: <strong>user@example.com</strong> / <strong>user123</strong>
</Text>
{(login.isError || searchError) && (
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
{login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'}
</Alert>
)}
<TextInput
label="Email"
placeholder="email@example.com"
leftSection={<TbMail size={16} />}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
/>
<PasswordInput
label="Password"
placeholder="Password"
leftSection={<TbLock size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
<Button
type="submit"
fullWidth
leftSection={<TbLogin size={18} />}
loading={login.isPending}
>
Sign in
</Button>
<Divider label="atau" labelPosition="center" />
<Button
component="a"
href="/api/auth/google"
fullWidth
variant="default"
leftSection={<FcGoogle size={18} />}
>
Login dengan Google
</Button>
</Stack>
</form>
</Paper>
</Center>
)
}

View File

@@ -0,0 +1,97 @@
import {
Avatar,
Badge,
Button,
Container,
Group,
Paper,
Stack,
Text,
Title,
} from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { TbLogout, TbUser } from 'react-icons/tb'
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/profile')({
beforeLoad: async ({ context }) => {
try {
const data = await context.queryClient.ensureQueryData({
queryKey: ['auth', 'session'],
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
})
if (!data?.user) throw redirect({ to: '/login' })
} catch (e) {
if (e instanceof Error) throw redirect({ to: '/login' })
throw e
}
},
component: ProfilePage,
})
const roleBadgeColor: Record<string, string> = {
USER: 'blue',
ADMIN: 'violet',
SUPER_ADMIN: 'red',
}
function ProfilePage() {
const { data } = useSession()
const logout = useLogout()
const user = data?.user
return (
<Container size="sm" py="xl">
<Stack gap="xl">
<Group justify="space-between">
<Title order={2}>Profile</Title>
<Button
variant="light"
color="red"
leftSection={<TbLogout size={16} />}
onClick={() => logout.mutate()}
loading={logout.isPending}
>
Logout
</Button>
</Group>
<Paper withBorder p="xl" radius="md">
<Stack align="center" gap="md">
<Avatar color="blue" radius="xl" size={80}>
{user?.name?.charAt(0).toUpperCase()}
</Avatar>
<div style={{ textAlign: 'center' }}>
<Text fw={600} size="lg">{user?.name}</Text>
<Text c="dimmed" size="sm">{user?.email}</Text>
</div>
<Badge color={roleBadgeColor[user?.role ?? 'USER']} variant="light" size="lg">
{user?.role}
</Badge>
</Stack>
</Paper>
<Paper withBorder p="lg" radius="md">
<Stack gap="sm">
<Group gap="xs">
<TbUser size={16} />
<Text fw={500} size="sm">Account Info</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Name</Text>
<Text size="sm">{user?.name}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Email</Text>
<Text size="sm">{user?.email}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Role</Text>
<Text size="sm">{user?.role}</Text>
</Group>
</Stack>
</Paper>
</Stack>
</Container>
)
}

187
src/index.css Normal file
View File

@@ -0,0 +1,187 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
}
body {
margin: 0;
display: grid;
place-items: center;
min-width: 320px;
min-height: 100vh;
position: relative;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.05;
background: url("./logo.svg");
background-size: 256px;
transform: rotate(-12deg) scale(1.35);
animation: slide 30s linear infinite;
pointer-events: none;
}
@keyframes slide {
from {
background-position: 0 0;
}
to {
background-position: 256px 224px;
}
}
.app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
position: relative;
z-index: 1;
}
.logo-container {
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 0.3s;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.bun-logo {
transform: scale(1.2);
}
.bun-logo:hover {
filter: drop-shadow(0 0 2em #fbf0dfaa);
}
.react-logo {
animation: spin 20s linear infinite;
}
.react-logo:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes spin {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
code {
background-color: #1a1a1a;
padding: 0.2em 0.4em;
border-radius: 0.3em;
font-family: monospace;
}
.api-tester {
margin: 2rem auto 0;
width: 100%;
max-width: 600px;
text-align: left;
display: flex;
flex-direction: column;
gap: 1rem;
}
.endpoint-row {
display: flex;
align-items: center;
gap: 0.5rem;
background: #1a1a1a;
padding: 0.75rem;
border-radius: 12px;
font: monospace;
border: 2px solid #fbf0df;
transition: 0.3s;
width: 100%;
box-sizing: border-box;
}
.endpoint-row:focus-within {
border-color: #f3d5a3;
}
.method {
background: #fbf0df;
color: #1a1a1a;
padding: 0.3rem 0.7rem;
border-radius: 8px;
font-weight: 700;
font-size: 0.9em;
appearance: none;
margin: 0;
width: min-content;
display: block;
flex-shrink: 0;
border: none;
}
.method option {
text-align: left;
}
.url-input {
width: 100%;
flex: 1;
background: 0;
border: 0;
color: #fbf0df;
font: 1em monospace;
padding: 0.2rem;
outline: 0;
}
.url-input:focus {
color: #fff;
}
.url-input::placeholder {
color: rgba(251, 240, 223, 0.4);
}
.send-button {
background: #fbf0df;
color: #1a1a1a;
border: 0;
padding: 0.4rem 1.2rem;
border-radius: 8px;
font-weight: 700;
transition: 0.1s;
cursor: var(--bun-cursor);
}
.send-button:hover {
background: #f3d5a3;
transform: translateY(-1px);
cursor: pointer;
}
.response-area {
width: 100%;
min-height: 120px;
background: #1a1a1a;
border: 2px solid #fbf0df;
border-radius: 12px;
padding: 0.75rem;
color: #fbf0df;
font: monospace;
resize: vertical;
box-sizing: border-box;
}
.response-area:focus {
border-color: #f3d5a3;
}
.response-area::placeholder {
color: rgba(251, 240, 223, 0.4);
}
@media (prefers-reduced-motion) {
*,
::before,
::after {
animation: none !important;
}
}

177
src/index.tsx Normal file
View 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}`)

13
src/lib/db.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '../../generated/prisma'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}

19
src/lib/env.ts Normal file
View File

@@ -0,0 +1,19 @@
function optional(key: string, fallback: string): string {
return process.env[key] ?? fallback
}
function required(key: string): string {
const value = process.env[key]
if (!value) throw new Error(`Missing required environment variable: ${key}`)
return value
}
export const env = {
PORT: parseInt(optional('PORT', '3000'), 10),
NODE_ENV: optional('NODE_ENV', 'development'),
REACT_EDITOR: optional('REACT_EDITOR', 'code'),
DATABASE_URL: required('DATABASE_URL'),
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean),
} as const

1
src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

8
src/react.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
<g stroke="#61dafb" stroke-width="1" fill="none">
<ellipse rx="11" ry="4.2"/>
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

3
src/serve.ts Normal file
View File

@@ -0,0 +1,3 @@
// Workaround: Bun 1.3.6 EADDRINUSE errno:0
// Dynamic import memberi waktu OS release port sebelum binding
import('./index.tsx')

180
src/vite.ts Normal file
View File

@@ -0,0 +1,180 @@
import fs from 'node:fs'
import path from 'node:path'
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
import react from '@vitejs/plugin-react'
import type { Plugin } from 'vite'
import { createServer as createViteServer } from 'vite'
/**
* Custom Vite plugin: inject data-inspector-* attributes ke JSX via regex.
* enforce: "pre" → jalan SEBELUM OXC transform JSX.
*
* Karena plugin lain (OXC, TanStack) bisa mengubah source sebelum kita
* (collapse lines, resolve imports), kita baca file ASLI dari disk untuk
* line number yang akurat, lalu cross-reference dengan code yang diterima.
*/
function inspectorPlugin(): Plugin {
const rootDir = process.cwd()
return {
name: 'inspector-inject',
enforce: 'pre',
transform(code, id) {
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
if (!code.includes('<')) return null
const cleanId = id.replace(/\?.*$/, '')
const relativePath = path.relative(rootDir, cleanId)
// Baca file asli dari disk untuk line number akurat
let originalLines: string[] | null = null
try {
originalLines = fs.readFileSync(cleanId, 'utf-8').split('\n')
} catch {}
let modified = false
let lastOrigIdx = 0
const lines = code.split('\n')
const result: string[] = []
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
const jsxPattern = /(<(?:[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*|[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*))\b/g
let match: RegExpExecArray | null = null
while ((match = jsxPattern.exec(line)) !== null) {
const charBefore = match.index > 0 ? line[match.index - 1] : ''
if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue
// Cari line number asli di file original
let actualLine = i + 1
if (originalLines) {
const afterTag = line.slice(match.index)
// Snippet: tag + atribut sampai '>' pertama, tanpa injected attrs
const snippet = afterTag.split('>')[0]
.replace(/\s*data-inspector-[^"]*"[^"]*"/g, '')
.trim()
// Tag name saja sebagai fallback (e.g. "<Button")
const tagName = match[1]
let found = false
// 1) Forward search dengan full snippet
for (let j = lastOrigIdx; j < originalLines.length; j++) {
if (originalLines[j].includes(snippet)) {
actualLine = j + 1
lastOrigIdx = j + 1
found = true
break
}
}
// 2) Fallback: forward search hanya tag name (handle multi-line collapsed)
// Penting untuk <Button\n attr="..."\n> yang di-collapse jadi 1 baris
if (!found) {
for (let j = lastOrigIdx; j < originalLines.length; j++) {
if (originalLines[j].includes(tagName)) {
actualLine = j + 1
lastOrigIdx = j + 1
found = true
break
}
}
}
// 3) Last resort: search dari awal dengan full snippet
if (!found) {
for (let j = 0; j < originalLines.length; j++) {
if (originalLines[j].includes(snippet)) {
actualLine = j + 1
lastOrigIdx = j + 1
found = true
break
}
}
}
// 4) Last resort: search dari awal dengan tag name
if (!found) {
for (let j = 0; j < originalLines.length; j++) {
if (originalLines[j].includes(tagName) && !originalLines[j].trim().startsWith('</')) {
actualLine = j + 1
lastOrigIdx = j + 1
break
}
}
}
}
const col = match.index + 1
const attr = ` data-inspector-line="${actualLine}" data-inspector-column="${col}" data-inspector-relative-path="${relativePath}"`
const insertPos = match.index + match[0].length
line = line.slice(0, insertPos) + attr + line.slice(insertPos)
modified = true
jsxPattern.lastIndex += attr.length
}
result.push(line)
}
if (!modified) return null
return result.join('\n')
},
}
}
/**
* Workaround: @vitejs/plugin-react v6 + Vite 8 middlewareMode
* inject React Refresh HMR footer 2x → "Identifier RefreshRuntime already declared".
* Plugin ini hapus duplikat setelah semua transform selesai.
*/
function dedupeRefreshPlugin(): Plugin {
return {
name: 'dedupe-react-refresh',
enforce: 'post',
transform(code, id) {
if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null
const marker = 'import * as RefreshRuntime from "/@react-refresh"'
const firstIdx = code.indexOf(marker)
if (firstIdx === -1) return null
const secondIdx = code.indexOf(marker, firstIdx + marker.length)
if (secondIdx === -1) return null
const sourcemapIdx = code.indexOf('\n//# sourceMappingURL=', secondIdx)
const endIdx = sourcemapIdx !== -1 ? sourcemapIdx : code.length
return { code: code.slice(0, secondIdx) + code.slice(endIdx), map: null }
},
}
}
export async function createVite() {
return createViteServer({
root: process.cwd(),
resolve: {
alias: {
'@': path.resolve(process.cwd(), './src'),
},
},
plugins: [
TanStackRouterVite({
routesDirectory: './src/frontend/routes',
generatedRouteTree: './src/frontend/routeTree.gen.ts',
routeFileIgnorePrefix: '-',
quoteStyle: 'single',
}),
inspectorPlugin(),
react(),
dedupeRefreshPlugin(),
],
server: {
middlewareMode: true,
hmr: { port: 24678 },
allowedHosts: true,
},
appType: 'custom',
optimizeDeps: {
include: ['react', 'react-dom'],
},
})
}