mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 09:01:19 +00:00
feat: add Expo React Native client scaffold
- Expo SDK 54 / React Native 0.81 with expo-router - Tailscale devcontainer feature for direct device connectivity - Dev proxy for React Native DevTools access over Tailscale - EAS build configuration for development/preview/production - Ona automation for Expo dev server Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
127
apps/aris-client/scripts/dev-proxy.ts
Normal file
127
apps/aris-client/scripts/dev-proxy.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// Reverse proxy that sits in front of Metro so that all requests
|
||||
// (including those arriving via Tailscale or Ona port-forwarding) reach
|
||||
// Metro as loopback connections. This satisfies the isLocalSocket check
|
||||
// in Expo's debug middleware, making /debugger-frontend, /json, and
|
||||
// /open-debugger accessible from a remote browser.
|
||||
|
||||
import type { ServerWebSocket } from "bun"
|
||||
|
||||
const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
|
||||
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
|
||||
const METRO_BASE = `http://127.0.0.1:${METRO_PORT}`
|
||||
|
||||
function forwardHeaders(headers: Headers): Headers {
|
||||
const result = new Headers(headers)
|
||||
result.delete("origin")
|
||||
result.delete("referer")
|
||||
result.set("host", `127.0.0.1:${METRO_PORT}`)
|
||||
return result
|
||||
}
|
||||
|
||||
interface WsData {
|
||||
upstream: WebSocket
|
||||
isDevice: boolean
|
||||
}
|
||||
|
||||
Bun.serve<WsData>({
|
||||
port: PROXY_PORT,
|
||||
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// WebSocket upgrade — bridge to Metro's ws endpoint
|
||||
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
||||
const wsUrl = `ws://127.0.0.1:${METRO_PORT}${url.pathname}${url.search}`
|
||||
const upstream = new WebSocket(wsUrl)
|
||||
|
||||
// Wait for upstream to connect before upgrading the client
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.addEventListener("open", () => resolve())
|
||||
upstream.addEventListener("error", () => reject(new Error("upstream ws failed")))
|
||||
})
|
||||
} catch {
|
||||
return new Response("Upstream WebSocket unavailable", { status: 502 })
|
||||
}
|
||||
|
||||
const isDevice = url.pathname.startsWith("/inspector/device")
|
||||
const ok = server.upgrade(req, { data: { upstream, isDevice } })
|
||||
if (!ok) {
|
||||
upstream.close()
|
||||
return new Response("WebSocket upgrade failed", { status: 500 })
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// HTTP proxy
|
||||
const upstream = `${METRO_BASE}${url.pathname}${url.search}`
|
||||
const res = await fetch(upstream, {
|
||||
method: req.method,
|
||||
headers: forwardHeaders(req.headers),
|
||||
body: req.body,
|
||||
redirect: "manual",
|
||||
})
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
})
|
||||
},
|
||||
|
||||
websocket: {
|
||||
message(ws: ServerWebSocket<WsData>, msg) {
|
||||
ws.data.upstream.send(msg)
|
||||
},
|
||||
open(ws: ServerWebSocket<WsData>) {
|
||||
const { upstream } = ws.data
|
||||
upstream.addEventListener("message", (ev) => {
|
||||
if (typeof ev.data === "string") {
|
||||
ws.send(ev.data)
|
||||
} else if (ev.data instanceof ArrayBuffer) {
|
||||
ws.sendBinary(new Uint8Array(ev.data))
|
||||
}
|
||||
})
|
||||
upstream.addEventListener("close", () => ws.close())
|
||||
upstream.addEventListener("error", () => ws.close())
|
||||
|
||||
// Print debugger URL shortly after a device connects,
|
||||
// giving Metro time to register the target.
|
||||
if (ws.data.isDevice) {
|
||||
setTimeout(() => printDebuggerUrl(), 1000)
|
||||
}
|
||||
},
|
||||
close(ws: ServerWebSocket<WsData>) {
|
||||
ws.data.upstream.close()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const tsIp = await Bun.$`tailscale ip -4`.text().then((s) => s.trim())
|
||||
|
||||
async function printDebuggerUrl() {
|
||||
const base = `http://${tsIp}:${PROXY_PORT}`
|
||||
const res = await fetch(`${METRO_BASE}/json`)
|
||||
if (!res.ok) return
|
||||
|
||||
interface DebugTarget {
|
||||
webSocketDebuggerUrl: string
|
||||
reactNative?: {
|
||||
capabilities?: { prefersFuseboxFrontend?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
const targets: DebugTarget[] = await res.json()
|
||||
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
|
||||
if (!target) return
|
||||
|
||||
const wsPath = target.webSocketDebuggerUrl
|
||||
.replace(/^ws:\/\//, "")
|
||||
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
|
||||
|
||||
console.log(
|
||||
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[proxy] listening on :${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`)
|
||||
52
apps/aris-client/scripts/open-debugger.ts
Normal file
52
apps/aris-client/scripts/open-debugger.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Opens React Native DevTools in Chrome, connected to the first
|
||||
// available Hermes debug target. Requires Metro + proxy to be running.
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
const PROXY_PORT = process.env.PROXY_PORT || "8080"
|
||||
const METRO_PORT = process.env.METRO_PORT || "8081"
|
||||
const tsIp = (await $`tailscale ip -4`.text()).trim()
|
||||
const base = `http://${tsIp}:${PROXY_PORT}`
|
||||
|
||||
interface DebugTarget {
|
||||
devtoolsFrontendUrl: string
|
||||
webSocketDebuggerUrl: string
|
||||
reactNative?: {
|
||||
capabilities?: {
|
||||
prefersFuseboxFrontend?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/json`)
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch /json — is Metro running?")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const targets: DebugTarget[] = await res.json()
|
||||
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
|
||||
|
||||
if (!target) {
|
||||
console.error("No debug target found. Is the app connected?")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const wsUrl = target.webSocketDebuggerUrl
|
||||
.replace(/^ws:\/\//, "")
|
||||
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
|
||||
|
||||
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
|
||||
|
||||
console.log(url)
|
||||
|
||||
// Open in Chrome app mode if on macOS
|
||||
try {
|
||||
await $`open -a "Google Chrome" --args --app=${url}`.quiet()
|
||||
} catch {
|
||||
try {
|
||||
await $`xdg-open ${url}`.quiet()
|
||||
} catch {
|
||||
console.log("Open the URL above in Chrome.")
|
||||
}
|
||||
}
|
||||
112
apps/aris-client/scripts/reset-project.js
Executable file
112
apps/aris-client/scripts/reset-project.js
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const readline = require("readline")
|
||||
|
||||
const root = process.cwd()
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"]
|
||||
const exampleDir = "app-example"
|
||||
const newAppDir = "app"
|
||||
const exampleDirPath = path.join(root, exampleDir)
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true })
|
||||
console.log(`📁 /${exampleDir} directory created.`)
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir)
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir)
|
||||
await fs.promises.rename(oldDirPath, newDirPath)
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`)
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true })
|
||||
console.log(`❌ /${dir} deleted.`)
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir)
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true })
|
||||
console.log("\n📁 New /app directory created.")
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx")
|
||||
await fs.promises.writeFile(indexPath, indexContent)
|
||||
console.log("📄 app/index.tsx created.")
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx")
|
||||
await fs.promises.writeFile(layoutPath, layoutContent)
|
||||
console.log("📄 app/_layout.tsx created.")
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:")
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y"
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close())
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.")
|
||||
rl.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
15
apps/aris-client/scripts/run-dev-server.sh
Executable file
15
apps/aris-client/scripts/run-dev-server.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROXY_PORT=8080
|
||||
METRO_PORT=8081
|
||||
|
||||
# Start a reverse proxy so Metro sees all requests as loopback.
|
||||
# This makes debugger endpoints (/debugger-frontend, /json, /open-debugger)
|
||||
# accessible through the Tailscale IP.
|
||||
PROXY_PORT=$PROXY_PORT METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts &
|
||||
PROXY_PID=$!
|
||||
trap "kill $PROXY_PID 2>/dev/null" EXIT
|
||||
|
||||
EXPO_PACKAGER_PROXY_URL=http://$(tailscale ip -4):$PROXY_PORT bunx expo start --localhost -p $METRO_PORT
|
||||
|
||||
Reference in New Issue
Block a user