# Skill: Dev Inspector — Click-to-Source untuk Bun + Elysia + Vite + React ## Ringkasan Fitur development: klik elemen UI di browser → langsung buka source code di editor (VS Code, Cursor, dll) pada baris dan kolom yang tepat. Zero overhead di production. **Hotkey**: `Ctrl+Shift+Cmd+C` (macOS) / `Ctrl+Shift+Alt+C` → aktifkan mode inspect → klik elemen → file terbuka. ## Kenapa Tidak Pakai Library `react-dev-inspector` crash di React 19 karena: - `fiber.return.child.sibling` bisa null di React 19 - `_debugSource` dihapus dari React 19 - Walking fiber tree tidak stabil antar versi React Solusi ini **regex-based + multi-fallback**, tidak bergantung pada React internals. ## Syarat Arsitektur Fitur ini bekerja karena 4 syarat struktural terpenuhi. Jika salah satu tidak ada, fitur tidak bisa diimplementasi atau perlu adaptasi signifikan. ### 1. Vite sebagai Bundler (Wajib) Seluruh mekanisme bergantung pada **Vite plugin transform pipeline**: - `inspectorPlugin()` inject attributes ke JSX saat build/HMR - `enforce: 'pre'` memastikan plugin jalan sebelum OXC/Babel transform JSX - `import.meta.env?.DEV` sebagai compile-time constant untuk tree-shaking **Tidak bisa diganti dengan**: esbuild standalone, webpack (perlu loader berbeda), SWC standalone. **Bisa diganti dengan**: framework yang pakai Vite di dalamnya (Remix Vite, TanStack Start, Astro). ### 2. Server dan Frontend dalam Satu Proses (Wajib) Endpoint `/__open-in-editor` harus **satu proses dengan dev server** yang melayani frontend: - Browser POST ke origin yang sama (no CORS) - Server punya akses ke filesystem lokal untuk `Bun.spawn(editor)` - Endpoint harus bisa ditangani **sebelum routing & middleware** (auth, tenant, dll) **Pola yang memenuhi syarat:** - Elysia + Vite middlewareMode (project ini) — `onRequest` intercept sebelum route matching - Express/Fastify + Vite middlewareMode — middleware biasa sebelum auth - Vite dev server standalone (`vite dev`) — pakai `configureServer` hook **Tidak memenuhi syarat:** - Frontend dan backend di proses/port terpisah (misal: CRA + separate API server) — perlu proxy atau CORS config tambahan - Serverless/edge deployment — tidak bisa `spawn` editor ### 3. React sebagai UI Framework (Wajib untuk Multi-Fallback) Strategi extraction source info bergantung pada React internals: 1. `__reactProps$*` — React menyimpan props di DOM element 2. `__reactFiber$*` — React fiber tree untuk walk-up 3. DOM attribute — fallback universal **Jika pakai framework lain** (Vue, Svelte, Solid): - Hanya strategi 3 (DOM attribute) yang berfungsi — tetap cukup - Hapus strategi 1 & 2 dari `getCodeInfoFromElement()` - Inject attributes tetap via Vite plugin (framework-agnostic) ### 4. Bun sebagai Runtime (Direkomendasikan, Bukan Wajib) Bun memberikan API yang lebih clean: - `Bun.spawn()` — fire-and-forget tanpa import - `Bun.which()` — cek executable ada di PATH (mencegah uncatchable error) **Jika pakai Node.js:** - `Bun.spawn()` → `child_process.spawn(editor, args, { detached: true, stdio: 'ignore' }).unref()` - `Bun.which()` → `const which = require('which'); which.sync(editor, { nothrow: true })` ### Ringkasan Syarat | Syarat | Wajib? | Alternatif | |-------------------------------|----------|------------------------------------------------------| | Vite sebagai bundler | Ya | Framework berbasis Vite (Remix, Astro, dll) | | Server + frontend satu proses | Ya | Bisa diakali dengan proxy, tapi tambah kompleksitas | | React | Sebagian | Framework lain bisa, hanya fallback ke DOM attribute | | Bun runtime | Tidak | Node.js dengan `child_process` + `which` package | ## Arsitektur ``` BUILD TIME (Vite Plugin): .tsx/.jsx file → [inspectorPlugin enforce:'pre'] inject data-inspector-* attributes ke JSX → [react() OXC] transform JSX ke createElement → Browser menerima elemen dengan attributes RUNTIME (Browser): Hotkey → aktifkan mode → hover elemen → baca attributes → klik → POST /__open-in-editor {relativePath, line, column} BACKEND (Elysia onRequest): /__open-in-editor → Bun.spawn([editor, '--goto', 'file:line:col']) → Editor terbuka di lokasi tepat ``` ## Komponen yang Dibutuhkan ### 1. Vite Plugin — `inspectorPlugin()` (enforce: 'pre') Inject `data-inspector-*` ke setiap JSX opening tag via regex. **HARUS `enforce: 'pre'`** — kalau tidak, OXC transform JSX duluan dan regex tidak bisa menemukan `) via charBefore check const jsxPattern = /(<(?:[A-Z][a-zA-Z0-9.]*|[a-z][a-zA-Z0-9-]*))\b/g let match: RegExpExecArray | null = null while ((match = jsxPattern.exec(line)) !== null) { // Skip jika karakter sebelum `<` adalah identifier char (TypeScript generic) const charBefore = match.index > 0 ? line[match.index - 1] : '' if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue const col = match.index + 1 const attr = ` data-inspector-line="${i + 1}" 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') }, } } ``` **Mengapa regex, bukan Babel?** - `@vitejs/plugin-react` v6+ pakai OXC (Rust), bukan Babel - Config `babel: { plugins: [...] }` di plugin-react **DIABAIKAN** - Regex jalan sebelum OXC via `enforce: 'pre'` **Gotcha: TypeScript generics** - `Record` → karakter sebelum `<` adalah `d` (identifier) → SKIP - `` → `React.createElement(Button, ...)` - Regex tidak menemukan `(null) const tooltipRef = useRef(null) const lastInfoRef = useRef(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 info = findCodeInfo(target) if (!info) { ov.style.display = 'none' tt.style.display = 'none' lastInfoRef.current = null return } lastInfoRef.current = info const rect = target.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 = `${info.relativePath}:${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` }, []) // Activate/deactivate event listeners useEffect(() => { if (!active) return const onMouseOver = (e: MouseEvent) => updateOverlay(e.target as HTMLElement) const onClick = (e: MouseEvent) => { e.preventDefault() e.stopPropagation() const info = lastInfoRef.current ?? findCodeInfo(e.target as HTMLElement) if (info) { const loc = `${info.relativePath}:${info.line}:${info.column}` console.log('[DevInspector] Open:', loc) navigator.clipboard.writeText(loc) 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 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}
) } ``` ### 4. Backend Endpoint — `/__open-in-editor` **HARUS ditangani di `onRequest` / sebelum middleware**, bukan sebagai route biasa. Kalau jadi route, akan kena auth middleware dan gagal. ```typescript // Di entry point server (src/index.tsx), dalam onRequest handler: 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 = process.env.REACT_EDITOR || 'code' const loc = `${file}:${lineNumber}:${columnNumber}` const args = editor === 'subl' ? [loc] : ['--goto', loc] const editorPath = Bun.which(editor) console.log(`[inspector] ${editor} → ${editorPath ?? 'NOT FOUND'} → ${loc}`) if (editorPath) { Bun.spawn([editor, ...args], { stdio: ['ignore', 'ignore', 'ignore'] }) } else { console.error(`[inspector] Editor "${editor}" not found in PATH. Set REACT_EDITOR in .env`) } return new Response('ok') } ``` **Penting — `Bun.which()` sebelum `Bun.spawn()`:** - `Bun.spawn()` throw native error yang TIDAK bisa di-catch jika executable tidak ada - `Bun.which()` return null dengan aman → cek dulu sebelum spawn **Editor yang didukung:** | REACT_EDITOR | Editor | Args | |------------------|--------------|--------------------------------| | `code` (default) | VS Code | `--goto file:line:col` | | `cursor` | Cursor | `--goto file:line:col` | | `windsurf` | Windsurf | `--goto file:line:col` | | `subl` | Sublime Text | `file:line:col` (tanpa --goto) | ### 5. Frontend Entry — Conditional Import (Zero Production Overhead) ```tsx // src/frontend.tsx (atau entry point React) import type { ReactNode } from 'react' const InspectorWrapper = import.meta.env?.DEV ? (await import('./frontend/DevInspector')).DevInspector : ({ children }: { children: ReactNode }) => <>{children} const app = ( ) ``` **Bagaimana zero overhead tercapai:** - `import.meta.env?.DEV` adalah compile-time constant - Production build: `false` → dynamic import TIDAK dieksekusi - Tree-shaking menghapus seluruh `DevInspector.tsx` dari bundle - Tidak ada runtime check, tidak ada dead code di bundle ### 6. (Opsional) Dedupe React Refresh — Workaround Vite middlewareMode Jika pakai Vite dalam `middlewareMode` (seperti di Elysia/Express), `@vitejs/plugin-react` v6 bisa inject React Refresh footer dua kali → error "already declared". ```typescript 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 const cleaned = code.slice(0, secondIdx) + code.slice(endIdx) return { code: cleaned, map: null } }, } } ``` ## Langkah Implementasi di Project Baru ### Prasyarat - Runtime: Bun - Server: Elysia (atau framework lain dengan onRequest/beforeHandle) - Frontend: React + Vite - `@vitejs/plugin-react` (OXC) ### Step-by-step 1. **Buat `DevInspector.tsx`** — copy komponen dari Bagian 3 ke folder frontend 2. **Tambah `inspectorPlugin()`** — copy fungsi dari Bagian 1 ke file vite config 3. **Atur plugin order** — `inspectorPlugin()` SEBELUM `react()` (Bagian 2) 4. **Tambah endpoint `/__open-in-editor`** — di `onRequest` handler (Bagian 4) 5. **Wrap root app** — conditional import di entry point (Bagian 5) 6. **Set env** — `REACT_EDITOR=code` (atau cursor/windsurf/subl) di `.env` 7. **(Opsional)** Tambah `dedupeRefreshPlugin()` jika pakai Vite `middlewareMode` ### Checklist Verifikasi - [ ] `inspectorPlugin` punya `enforce: 'pre'` - [ ] Plugin order: inspector → react (bukan sebaliknya) - [ ] Endpoint `/__open-in-editor` di LUAR middleware auth - [ ] `Bun.which(editor)` dipanggil SEBELUM `Bun.spawn()` - [ ] Conditional import pakai `import.meta.env?.DEV` - [ ] `REACT_EDITOR` di `.env` sesuai editor yang dipakai - [ ] Hotkey berfungsi: `Ctrl+Shift+Cmd+C` / `Ctrl+Shift+Alt+C` ## Gotcha & Pelajaran | Masalah | Penyebab | Solusi | |----------------------------------|---------------------------------------------|-----------------------------------------------| | Attributes tidak ter-inject | Plugin order salah | `enforce: 'pre'`, taruh sebelum `react()` | | `Record` ikut ter-inject | Regex match TypeScript generics | Cek `charBefore` — skip jika identifier char | | `Bun.spawn` crash | Editor tidak ada di PATH | Selalu `Bun.which()` dulu | | Hotkey tidak response | `e.key` return 'C' (uppercase) karena Shift | Pakai `e.key.toLowerCase()` | | React Refresh duplicate | Vite middlewareMode bug | `dedupeRefreshPlugin()` enforce: 'post' | | Endpoint kena auth middleware | Didaftarkan sebagai route biasa | Tangani di `onRequest` sebelum routing | | `_debugSource` undefined | React 19 menghapusnya | Multi-fallback: reactProps → fiber → DOM attr | ## Adaptasi untuk Framework Lain ### Express/Fastify (bukan Elysia) - Endpoint `/__open-in-editor`: gunakan middleware biasa SEBELUM auth - `Bun.spawn` → `child_process.spawn` jika pakai Node.js - `Bun.which` → `which` npm package jika pakai Node.js ### Next.js - Tidak perlu — Next.js punya built-in click-to-source - Tapi jika ingin custom: taruh endpoint di `middleware.ts`, plugin di `next.config.js` ### Remix/Tanstack Start (SSR) - Plugin tetap sama (Vite-based) - Endpoint perlu di server entry, bukan di route loader