ตั้งค่า LynxJS Mobile App สำหรับ IoT

ตั้งค่า LynxJS Mobile App สำหรับ IoT

ShowkhunWorkshop

ตั้งค่า LynxJS Mobile App สำหรับ IoT

Branch: step-10-lynx-setup Phase: Mobile (1/5) — Project Setup Repo: kangana1024/showkhun-workshop


Backend เราเสร็จแล้วครับ — ทั้ง ingestion, time-series, alerting engine ครบ แต่จะให้คนเดินไปนั่งหน้า terminal ยิง curl ดูค่า sensor มันก็ไม่ไหวใช่ไหม? วันนี้เราจะเริ่มสร้าง mobile app ที่ดูข้อมูล IoT ได้จากมือถือจริงๆ — แล้วเราจะใช้ LynxJS ครับ (ʘ‿ʘ)

ทำไม LynxJS ไม่ใช่ React Native? LynxJS (จากทีม ByteDance ที่ทำ TikTok) ให้เราเขียน UI ด้วย React mental model เดิม — useState, useEffect, component, context ครบ — แล้ว render ออกเป็น native ผ่าน LynxExplorer ของจริง ที่สำคัญคือบทความนี้ อ้างอิงโค้ดจริงใน branch step-10-lynx-setup ทั้งหมด ไม่มี API ที่กุขึ้นมาเอง


ก่อนเริ่ม: น้องๆ จะได้อะไรจากบทนี้?

  • เข้าใจ stack ของ Lynx: ReactLynx + Rspeedy และเวอร์ชันจริงของ @lynx-js/* ที่ใช้
  • scaffold project ด้วย create-rspeedy แล้วรู้ว่าแต่ละ config ทำอะไร
  • สร้าง bottom tab bar 4 แท็บ (Dashboard / Devices / Alerts / Settings) แบบ state-driven
  • เขียน typed REST client ที่ field ตรงกับ Go struct เป๊ะ — deserialize ได้โดยไม่ต้อง remap
  • inject base URL ตอน build จาก environment เพื่อไม่ฝัง host/secret ลง bundle
  • วาง theme provider (light/dark) + ชุด UI primitive ที่จะ reuse ทั้ง 5 บท

ทำไมต้องวางรากฐานก่อน? (WHY ก่อน HOW เสมอ)

ลองนึกถึงการสร้างบ้านครับ ถ้ารีบขึ้นผนังโดยไม่เทพื้นก่อน พอลมพัดทีก็ล้มทั้งหลัง บทนี้คือ การเทพื้น ของ app — navigation, API layer, theme, primitive ทั้งหมด ถ้าเราวางให้ดีตั้งแต่แรก อีก 4 บทที่เหลือ (dashboard, control, charts, alerts) จะแค่ “เอา component มาเสียบ” เท่านั้นเอง

อุปมา: typed API client เหมือน ล่ามแปลภาษา ที่ยืนอยู่ระหว่าง app (TypeScript) กับ backend (Go) ถ้าล่ามแม่น สองฝั่งคุยกันรู้เรื่องโดยไม่ต้องเดา


ภาพรวม: app นี้คุยกับ backend ยังไง?

graph LR
    A[📱 LynxJS App] -->|REST GET/POST| B[🖥️ Go Backend /api/v1]
    A <-->|WebSocket /api/v1/ws| B
    B -->|อ่าน/เขียน| C[(💾 Mongo + InfluxDB)]
    D[⚙️ lynx.config.ts] -.->|inject base URL ตอน build| A

จุดสำคัญคือ REST และ WebSocket ใช้ host เดียวกัน (ต่างแค่ scheme) และทั้งคู่ derive มาจาก env ตัวเดียวคือ SHOWKHUN_API_BASE_URL — ไม่มี host หรือ token ฝังในโค้ดเลย


Step 1: รู้จัก Stack — ReactLynx + Rspeedy

Lynx ไม่ได้มาเป็นก้อนเดียว มันเป็นชุด package เล็กๆ ที่ทำงานร่วมกัน นี่คือ package.json จริงของโปรเจกต์ (frontend-mobile/package.json):

{
  "name": "showkhun-mobile",
  "type": "module",
  "private": true,
  "scripts": {
    "build": "rspeedy build",
    "dev": "rspeedy dev",
    "preview": "rspeedy preview",
    "lint": "eslint .",
    "typecheck": "tsc --build --noEmit"
  },
  "dependencies": {
    "@lynx-js/react": "0.121.2"
  },
  "devDependencies": {
    "@lynx-js/preact-devtools": "5.0.1-20260525112551-dfe2d03",
    "@lynx-js/qrcode-rsbuild-plugin": "0.5.0",
    "@lynx-js/react-rsbuild-plugin": "0.17.0",
    "@lynx-js/rspeedy": "0.15.0",
    "@lynx-js/types": "3.7.0",
    "@rsbuild/plugin-type-check": "1.3.4",
    "typescript": "5.9.3"
  },
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  }
}

แยกบทบาทให้เห็นภาพ:

Package หน้าที่
@lynx-js/react (0.121.2) React runtime ของ Lynx — useState, root.render, context ฯลฯ
@lynx-js/rspeedy (0.15.0) bundler/CLI (build/dev/preview) — base เป็น Rsbuild
@lynx-js/react-rsbuild-plugin (0.17.0) plugin ที่ทำให้ Rspeedy เข้าใจ ReactLynx
@lynx-js/qrcode-rsbuild-plugin (0.5.0) สร้าง QR code ให้ scan ด้วย LynxExplorer ตอน dev
@lynx-js/types (3.7.0) type ของ element/attribute ฝั่ง Lynx (<view>, bindtap, …)

เกร็ด: "type": "module" กับการ import แบบลงท้าย .js (เช่น ./App.js) เป็น ESM ล้วน — TypeScript จะ resolve ไป .tsx ให้เองตอน build


Step 2: Scaffold ด้วย create-rspeedy

วิธีเริ่มเร็วสุดคือใช้ตัว scaffold ของ Lynx เอง:

# สร้างโปรเจกต์ ReactLynx ใหม่ (เลือก template ReactLynx + TypeScript)
npm create rspeedy@latest

# เข้าโฟลเดอร์แล้วลง dependency ตามรายการใน package.json
cd frontend-mobile
npm install

ในโปรเจกต์เรามี script ครบสำหรับ workflow:

npm run typecheck  # tsc --build --noEmit (เช็ค type อย่างเดียว ไม่ออกไฟล์)
npm run lint       # eslint
npm run build      # rspeedy production bundle → emits dist/
npm run dev        # rspeedy dev server (โชว์ QR code ให้ scan)
npm run preview    # preview bundle ที่ build แล้ว

WHY มี typecheck/lint/build เป็น gate? Lynx render บน native จริงผ่าน LynxExplorer — เราเทสต์ทุก commit ไม่ได้ตลอด เลยใช้สามตัวนี้เป็น “ด่านตรวจ” ว่าโค้ด compile ได้และ type ถูกก่อน


Step 3: Entry Point — ประตูหน้าบ้านของ app

ทุก app ต้องมีจุดเริ่ม src/index.tsx คือ “ประตูหน้าบ้าน” — มันบอก Lynx ว่าให้ render component ไหนเป็นราก:

import '@lynx-js/preact-devtools'
import '@lynx-js/react/debug'
import { root } from '@lynx-js/react'

import { App } from './App.js'

// root.render คือจุดที่ Lynx เอา component tree ไปวาดบนหน้าจอ native
root.render(<App />)

// Hot reload: แก้โค้ดแล้วเห็นผลทันทีโดยไม่ต้อง restart
if (import.meta.webpackHot) {
  import.meta.webpackHot.accept()
}

สังเกตว่ามันคล้าย ReactDOM.createRoot(...).render(<App/>) ของ React บนเว็บมาก — นี่แหละข้อดีของ Lynx เราใช้ mental model เดิมได้เลย


Step 4: App Shell + Tab Navigation

App.tsx คือ shell ที่ครอบทุกอย่าง มันถือ state ว่าตอนนี้อยู่แท็บไหน แล้วสลับ screen ตามนั้น — navigation แบบ state-driven ตรงไปตรงมา:

import { useState } from '@lynx-js/react'

import './App.css'
import { TabBar } from './navigation/TabBar.js'
import type { TabKey } from './navigation/tabs.js'
import { AlertsScreen } from './screens/AlertsScreen.js'
import { DashboardScreen } from './screens/DashboardScreen.js'
import { DevicesScreen } from './screens/DevicesScreen.js'
import { SettingsScreen } from './screens/SettingsScreen.js'
import { ThemeProvider } from './theme/ThemeProvider.js'

function ActiveScreen(props: { tab: TabKey }) {
  switch (props.tab) {
    case 'dashboard': return <DashboardScreen />
    case 'devices':   return <DevicesScreen />
    case 'alerts':    return <AlertsScreen />
    case 'settings':  return <SettingsScreen />
    default:          return <DashboardScreen />
  }
}

export function App() {
  const [tab, setTab] = useState<TabKey>('dashboard')

  return (
    <ThemeProvider initial='light'>
      <view className='AppShell'>
        <view className='AppShell__content'>
          <ActiveScreen tab={tab} />
        </view>
        <TabBar active={tab} onChange={setTab} />
      </view>
    </ThemeProvider>
  )
}

สิ่งที่ต่างจาก React เว็บ: ไม่มี <div> แต่ใช้ <view> เป็นกล่อง layout และ <text> สำหรับข้อความ — element พวกนี้ map ไป native โดยตรง

นิยามแท็บแยกไว้ที่เดียว

navigation/tabs.ts เก็บ source of truth ของแท็บทั้งหมด เปลี่ยนที่นี่ที่เดียวพอ:

export type TabKey = 'dashboard' | 'devices' | 'alerts' | 'settings'

export interface TabDef {
  key: TabKey
  label: string
  icon: string
}

export const TABS: TabDef[] = [
  { key: 'dashboard', label: 'Dashboard', icon: '📊' },
  { key: 'devices', label: 'Devices', icon: '🔌' },
  { key: 'alerts', label: 'Alerts', icon: '🔔' },
  { key: 'settings', label: 'Settings', icon: '⚙️' },
]

TabBar เอง

navigation/TabBar.tsx แค่ map TABS ออกมาเป็นปุ่ม สังเกต bindtap — มันคือ event ของ Lynx (เทียบเท่า onClick บนเว็บ):

import './tabbar.css'
import { TABS, type TabKey } from './tabs.js'

interface TabBarProps {
  active: TabKey
  onChange: (key: TabKey) => void
}

export function TabBar(props: TabBarProps) {
  return (
    <view className='TabBar'>
      {TABS.map((tab) => {
        const isActive = tab.key === props.active
        return (
          <view
            key={tab.key}
            className={isActive ? 'TabBar__item TabBar__item--active' : 'TabBar__item'}
            bindtap={() => props.onChange(tab.key)}
          >
            <text className='TabBar__icon'>{tab.icon}</text>
            <text className='TabBar__label'>{tab.label}</text>
          </view>
        )
      })}
    </view>
  )
}

WHY state-driven แทน router library? app นี้มีแค่ 4 หน้าหลัก การใช้ useState + switch ง่ายกว่า ดีบักง่ายกว่า และไม่ต้องลาก dependency เพิ่ม ส่วนการ “เข้าไปดูรายละเอียด” (เช่น device detail) เราใช้ local state ในแต่ละ screen เอง — เดี๋ยวบทหลังจะเห็น


Step 5: Config — Inject Base URL ตอน Build (ไม่ฝัง host/secret)

นี่คือส่วนที่พี่ชอบมากครับ เรา ไม่ฝัง URL ของ backend ลงในโค้ดเลย แต่ inject เข้ามาตอน build จาก environment ผ่าน lynx.config.ts:

import { defineConfig } from '@lynx-js/rspeedy'
import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'
import { pluginTypeCheck } from '@rsbuild/plugin-type-check'

export default defineConfig({
  source: {
    define: {
      // Base URL ถูก inject ตอน build จาก env — ไม่มี host/credential ฝังใน source
      // ถ้าไม่ตั้ง env จะ fallback ไป backend ของ docker-compose ตอน dev
      'process.env.SHOWKHUN_API_BASE_URL': JSON.stringify(
        process.env.SHOWKHUN_API_BASE_URL ?? 'http://localhost:3000',
      ),
    },
  },
  plugins: [
    pluginQRCode({ schema(url) { return `${url}?fullscreen=true` } }),
    pluginReactLynx(),
    pluginTypeCheck(),
  ],
})

ตั้งค่าผ่านไฟล์ .env (มี .env.example ให้ copy):

cp .env.example .env
# SHOWKHUN_API_BASE_URL=http://localhost:3000

แล้ว api/config.ts ก็อ่านค่านี้มาแปลงต่อ — รวมถึง derive WebSocket URL จาก REST base โดยสลับ scheme (httpswss, httpws):

function readBaseUrl(): string {
  const raw = process.env.SHOWKHUN_API_BASE_URL
  if (typeof raw !== 'string' || raw.trim() === '') {
    return 'http://localhost:3000'
  }
  // ตัด trailing slash ทิ้ง เพื่อให้ต่อ path ได้คาดเดาได้
  return raw.trim().replace(/\/+$/, '')
}

export const API_BASE_URL: string = readBaseUrl()
export const API_PREFIX = '/api/v1'
export const REQUEST_TIMEOUT_MS = 10_000

/** WebSocket endpoint จาก REST base โดยสลับ scheme */
export function websocketUrl(): string {
  const httpUrl = `${API_BASE_URL}${API_PREFIX}/ws`
  if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice(8)}`
  if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice(7)}`
  return httpUrl
}

WHY แยก REST กับ WS แต่ derive จากตัวเดียว? เพราะ backend serve ทั้งคู่บน host เดียวกัน ตั้ง env ทีเดียวได้ทั้งสอง — ลดโอกาสตั้งค่าหลุดกัน


Step 6: Typed REST Client — ล่ามแปลภาษาที่แม่นยำ

หัวใจของ API layer คือ api/types.ts ที่ field ตรงกับ Go struct เป๊ะ (ตาม json:"..." tag) เพื่อให้ response deserialize ได้โดยไม่ต้อง remap เลย:

export type DeviceStatus = 'online' | 'offline' | 'error' | 'maintenance'

export interface Device {
  id: string
  device_id: string        // human-readable id (ใช้กับ command/room)
  name: string
  type: DeviceType
  status: DeviceStatus
  enabled: boolean
  last_seen_at?: string
  created_at: string
  updated_at: string
  // ... location, tags, group_id ฯลฯ
}

// envelope ที่ backend ห่อ response มา (backend/internal/httpx/response.go)
export interface DataEnvelope<T> { data: T }
export interface ListEnvelope<T> { data: T[]; pagination: Pagination }

จากนั้น api/client.ts ห่อ fetch ให้มี timeout, แปลง error envelope เป็น ApiError และคืน type ที่ถูกต้อง:

/** ApiError ถือ HTTP status + ข้อความจาก error envelope ของ backend */
export class ApiError extends Error {
  readonly status: number
  readonly fields?: Record<string, string>

  constructor(status: number, message: string, fields?: Record<string, string>) {
    super(message)
    this.name = 'ApiError'
    this.status = status
    this.fields = fields
  }

  get notFound(): boolean {
    return this.status === 404
  }
}

ทุก request ถูกหุ้มด้วย AbortController + timeout 10 วินาที ถ้า request ไหนช้าเกินหรือ caller ยกเลิก ก็ abort ทิ้ง:

async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
  // ...ถ้า caller มี signal เองก็ผูกให้ abort พร้อมกัน...

  try {
    const response = await fetch(buildUrl(path, opts.query), {
      method: opts.method ?? 'GET',
      headers,
      body: serializedBody,
      signal: controller.signal,
    })

    if (response.status === 204) return undefined as T

    const text = await response.text()
    const parsed: unknown = text ? JSON.parse(text) : undefined

    if (!response.ok) {
      if (isErrorBody(parsed)) {
        throw new ApiError(response.status, parsed.error.message, parsed.error.fields)
      }
      throw new ApiError(response.status, `request failed (${response.status})`)
    }
    return parsed as T
  } catch (err) {
    if (err instanceof ApiError) throw err
    if (err instanceof DOMException && err.name === 'AbortError') {
      throw new ApiError(0, 'request timed out')
    }
    throw new ApiError(0, 'network error')
  } finally {
    clearTimeout(timer)
  }
}

แล้วก็ export เป็น api object ที่มี method ตรงกับ endpoint จริงของ backend:

export const api = {
  listDevices,    // GET    /api/v1/devices
  getDevice,      // GET    /api/v1/devices/:id
  sendCommand,    // POST   /api/v1/devices/:id/commands
  getReadings,    // GET    /api/v1/devices/:id/readings
  listAlertRules, // GET    /api/v1/alert-rules
  getAlertRule,   // GET    /api/v1/alert-rules/:id
}

WHY ห่อ fetch เอง? เพราะเราอยากได้ behavior สม่ำเสมอทุก call: timeout, error เป็น type เดียว (ApiError), unwrap envelope ให้แล้ว — screen ปลายทางจะได้ branch ง่ายๆ ด้วย err instanceof ApiError และ err.notFound


Step 7: Theme Provider + UI Primitives

ก่อนจบ เราวาง theme (light/dark) ด้วย context ปกติของ React theme/ThemeProvider.tsx ถือ state แล้วสะท้อนลง <view data-theme=...> เพื่อให้ CSS variable ใน theme.css ใช้ได้ทั้ง subtree:

export function ThemeProvider(props: ThemeProviderProps) {
  const [theme, setTheme] = useState<ThemeName>(props.initial ?? 'light')

  const toggle = useCallback(() => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
  }, [])

  const value = useMemo(() => ({ theme, toggle, setTheme }), [theme, toggle])

  return (
    <ThemeContext.Provider value={value}>
      <view data-theme={theme} className='ThemeRoot'>
        {props.children}
      </view>
    </ThemeContext.Provider>
  )
}

ส่วน ui/ มี primitive ที่จะ reuse ทุกบท: Card, Button, Badge, ScreenHeader ดูตัวอย่าง Badge ที่ใช้ทำ pill บอกสถานะ:

export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'

export function Badge(props: { label: string; tone?: BadgeTone }) {
  const tone = props.tone ?? 'neutral'
  return (
    <view className={`Badge Badge--${tone}`}>
      <text className='Badge__label'>{props.label}</text>
    </view>
  )
}

แล้วมี helper ui/status.ts map สถานะ → tone สี เพื่อให้ทั้ง app ใช้สีเดียวกัน:

export function deviceStatusTone(status: DeviceStatus): BadgeTone {
  switch (status) {
    case 'online':      return 'success'
    case 'offline':     return 'neutral'
    case 'error':       return 'danger'
    case 'maintenance': return 'warning'
    default:            return 'neutral'
  }
}

ในบทนี้ 4 screen หลักยังเป็น placeholder (navigable shell) — มีแต่ ScreenHeader + Card ว่างๆ ยกเว้น SettingsScreen ที่ใช้งานได้แล้ว: สลับ theme และโชว์ค่า API_BASE_URL ที่ inject เข้ามา ยืนยันว่า config layer ทำงานจริง

export function SettingsScreen() {
  const { theme, toggle } = useTheme()
  return (
    <view className='Screen'>
      <ScreenHeader title='Settings' subtitle='Appearance and connection' />
      <view className='Screen__body'>
        <Card>
          <Badge label={theme === 'dark' ? 'Dark' : 'Light'} tone='info' />
          <Button
            label={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
            variant='secondary'
            onTap={toggle}
          />
        </Card>
        <Card>
          <text className='Settings__rowLabel'>Backend</text>
          <text className='Settings__value'>{API_BASE_URL}</text>
        </Card>
      </view>
    </view>
  )
}

Step 8: รันบนเครื่องจริงด้วย LynxExplorer

Lynx ไม่ได้ render ใน browser แต่ render ผ่านแอป LynxExplorer (iOS/Android) วิธีรัน:

npm run dev
# Rspeedy จะ print QR code ออกมาที่ terminal
# เปิดแอป LynxExplorer แล้ว scan QR → bundle จะโหลดขึ้นเครื่อง/simulator
เมื่อ scan QR แล้วเห็น 4 แท็บโผล่บนมือถือ:

   ╔══════════════════════════════════╗
   ║                                  ║
   ║        \(^o^)/ It runs!          ║
   ║                                  ║
   ║   "เขียน React → ได้ native app" ║
   ║                                  ║
   ╚══════════════════════════════════╝

สรุป: บทนี้เราวางอะไรไปบ้าง

ส่วนประกอบ รายละเอียด
Stack ReactLynx (@lynx-js/react 0.121.2) + Rspeedy (0.15.0)
Entry index.tsxroot.render(<App />) + hot reload
Navigation bottom tab bar 4 แท็บ แบบ state-driven (ไม่ใช้ router lib)
Config inject base URL ตอน build ผ่าน lynx.config.ts (ไม่ฝัง host/secret)
API client typed REST client, field ตรง Go struct, timeout + ApiError
Theme provider light/dark ผ่าน context + CSS variable
UI primitive Card/Button/Badge/ScreenHeader + helper สี

บทนี้น้องอาจยังไม่เห็น “ของเล่น” หวือหวา เพราะมันคือการ เทพื้นคอนกรีต — ไม่สวย แต่ถ้าไม่มี ทุกอย่างข้างบนพังหมด ดีตรงที่ทุกอย่างวางบน type ที่ตรงกับ backend แล้ว บทต่อๆ ไปจะแค่เอา component มาเสียบ


Next Step

บทหน้าเราจะปลุก DashboardScreen ให้มีชีวิต — เชื่อม WebSocket hub เพื่อดูค่า sensor แบบ real-time พร้อม auto-reconnect และ pull-to-refresh มาลุยกันต่อ! (^_^)v


Navigation: