Files
WRNexusJS/packages/cli/src/mobile.ts
T
ClintchizandClaude Opus 5 17aa3b98eb feat(islands): wire islands end to end
The island pieces existed but nothing connected .wrn compilation to island
emission. Now:

- codegen emits a data-wrn-island placeholder for component tags bound to
  .tsx imports, keeping .wrn components on the normal mount path
- the dev pipeline and static build resolve island imports, thread the
  names into codegen, and build the bundles
- collectScripts adds /__wrnexus/islands.js only when island markup is
  present, so island-free pages still ship nothing
- island routes classify as static-interactive via hasIslands

Three bugs found by driving a real page in the browser:

1. The mount runtime was never built anywhere, so the bootstrap 404'd and
   no island mounted.
2. Building the runtime separately from the islands gave each its own copy
   of React: "Cannot read properties of null (reading 'useState')". The
   runtime is now an entrypoint of the same build so React stays in one
   shared chunk. The existing single-React test only compared bundles
   within one build and could not see across build boundaries.
3. Island props arrived as attribute strings, so start={3} was "3" and
   incrementing produced "31" then "311". Props now follow JSX semantics:
   {…} parses as JSON, quoted values stay strings, and a runtime
   expression is a WRN-ISLAND-PROPS build error rather than a silent
   wrong value.

island-codegen.ts no longer imports @wrnexus/core. Compiler modules are
bundled into the Node-only VS Code extension, which contains no other
packages, so a runtime import of core broke the editor compiler; the two
helpers are implemented locally and the core dependency is dropped again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:16:03 +05:30

268 lines
12 KiB
TypeScript

/**
* `wrnexus generate mobile` — scaffold a WebView or fully native mobile app.
*
* WrNexus remains the hosted SSR/API/WebSocket server. Capacitor loads that
* server in a native WebView and provides the bridge for native plugins.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { loadAppConfig } from "@wrnexus/styles";
export type MobileMode = "webview" | "native";
export interface MobileOptions {
appId?: string;
appName?: string;
serverUrl?: string;
mode?: MobileMode;
}
function slug(value: string): string {
const cleaned = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "")
.replace(/^\d+/, "");
return cleaned || "app";
}
function projectName(root: string): string {
try {
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: string };
if (pkg.name) return pkg.name.split("/").pop() || basename(root);
} catch {
// A package manifest is helpful but not required to generate the shell.
}
return basename(root);
}
function writeIfAbsent(path: string, content: string, label: string): void {
if (existsSync(path)) {
console.warn(` • ${label} already exists — skipped`);
return;
}
mkdirSync(resolve(path, ".."), { recursive: true });
writeFileSync(path, content, "utf8");
console.log(` ✓ ${label}`);
}
/** Scaffold a Capacitor mobile wrapper under `<appRoot>/mobile`. */
export async function generateMobile(appRoot: string, options: MobileOptions = {}): Promise<void> {
const root = resolve(appRoot);
const appConfig = await loadAppConfig(root);
const configured = appConfig.mobile ?? {};
const mode = options.mode ?? configured.mode ?? "webview";
if (mode !== "webview" && mode !== "native") {
throw new Error('mobile.mode must be either "webview" or "native"');
}
const name = options.appName || configured.appName || projectName(root);
const appId = options.appId || configured.appId || `com.example.${slug(name)}`;
const serverUrl = options.serverUrl || configured.serverUrl || "http://localhost:3000";
const mobile = join(root, "mobile");
if (mode === "native") {
generateNativeMobile(mobile, {
name,
appId,
apiUrl: configured.apiUrl ?? serverUrl,
configured,
});
return;
}
const pkg = {
name: `${slug(name)}-mobile`,
version: "0.1.0",
private: true,
wrnexus: { mode: "webview" },
type: "module",
scripts: {
"add:ios": "cap add ios",
"add:android": "cap add android",
sync: "cap sync",
"open:ios": "cap open ios",
"open:android": "cap open android",
},
dependencies: {
"@capacitor/core": "^8.0.0",
"@capacitor/app": "^8.0.0",
"@capacitor/ios": "^8.0.0",
"@capacitor/android": "^8.0.0",
},
devDependencies: {
"@capacitor/cli": "^8.0.0",
"@capacitor/assets": "^3.0.0",
typescript: "^6.0.3",
},
};
const config = `import type { CapacitorConfig } from "@capacitor/cli";
import appConfig from "../wrnexus.config.ts";
const mobile = appConfig.mobile ?? {};
const serverUrl = process.env.WRNEXUS_MOBILE_URL ?? mobile.serverUrl ?? ${JSON.stringify(serverUrl)};
const config: CapacitorConfig = {
appId: mobile.appId ?? ${JSON.stringify(appId)},
appName: mobile.appName ?? ${JSON.stringify(name)},
webDir: "web",
appendUserAgent: mobile.userAgent ?? " WrNexusMobile",
backgroundColor: mobile.backgroundColor,
...(mobile.capacitor ?? {}),
server: {
...((mobile.capacitor?.server as CapacitorConfig["server"]) ?? {}),
// Development bridge only: Capacitor does not recommend server.url in production.
url: serverUrl,
cleartext: serverUrl.startsWith("http://"),
},
};
export default config;
`;
const errorPage = `<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Connection unavailable</title>
<style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px system-ui;background:#0f172a;color:#e2e8f0}.card{max-width:28rem;padding:2rem;text-align:center}button{padding:.8rem 1.2rem;border:0;border-radius:.75rem;background:#6c8cff;color:white;font-weight:700}</style></head>
<body><main class="card"><h1>Connection unavailable</h1><p>Check your Wi-Fi and make sure the WrNexus server is running.</p><button id="retry">Try again</button></main><script>document.getElementById("retry").onclick=()=>location.replace(${JSON.stringify(serverUrl)});</script></body>
</html>
`;
const fallback = `<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${name}</title></head>
<body><p>Run the WrNexus server and then sync this mobile project.</p></body>
</html>
`;
const readme = `# ${name} mobile
Capacitor development shell for the hosted WrNexus application. The Bun server,
SSR, APIs, database, and WebSockets continue to run on your server; this directory
contains the native iOS and Android projects and native plugin dependencies.
> Capacitor documents \`server.url\` as a live-reload option that is not intended
> for production. This shell is useful for native development and testing, but a
> store release needs a bundled client build/static export that WrNexus does not
> currently produce.
## Setup
\`\`\`bash
node --version # Capacitor 8 requires Node.js 22+
bun install
bun run add:android
bun run add:ios # macOS with Xcode is required
bun run sync
\`\`\`
Set \`WRNEXUS_MOBILE_URL\` to a reachable URL before syncing the development shell:
\`\`\`bash
WRNEXUS_MOBILE_URL=https://app.example.com bun run sync
\`\`\`
For a physical device, \`localhost\` refers to the device, not your computer.
Use your computer's LAN URL during development. Do not ship the generated
\`server.url\` configuration as a production store build.
Open the native projects with \`bun run open:android\` or \`bun run open:ios\`.
Add native features with Capacitor plugins and run \`bun run sync\` afterward.
`;
console.log("Scaffolding Capacitor mobile app:");
writeIfAbsent(
join(mobile, "package.json"),
`${JSON.stringify(pkg, null, 2)}\n`,
"mobile/package.json",
);
writeIfAbsent(join(mobile, "capacitor.config.ts"), config, "mobile/capacitor.config.ts");
writeIfAbsent(join(mobile, "web", "index.html"), fallback, "mobile/web/index.html");
writeIfAbsent(join(mobile, "web", "error.html"), errorPage, "mobile/web/error.html");
writeIfAbsent(
join(mobile, ".gitignore"),
"node_modules\nandroid/.gradle\nios/App/Pods\n",
"mobile/.gitignore",
);
writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md");
console.log("\nNext: cd mobile && bun install && bun run add:android");
console.log("iOS generation requires macOS with Xcode: bun run add:ios");
}
/** Convert CLI flags into generator options. */
export function mobileOptions(args: string[]): MobileOptions {
const value = (flag: string) =>
args.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1);
const modeValue = value("--mode");
if (modeValue && modeValue !== "webview" && modeValue !== "native") {
throw new Error('--mode must be either "webview" or "native"');
}
const mode: MobileMode | undefined =
modeValue === "webview" || modeValue === "native" ? modeValue : undefined;
return {
appId: value("--app-id"),
appName: value("--app-name"),
serverUrl: value("--url"),
mode,
};
}
function generateNativeMobile(
mobile: string,
input: { name: string; appId: string; apiUrl: string; configured: { scheme?: string } },
): void {
const { name, appId, apiUrl, configured } = input;
const scheme = configured.scheme ?? slug(name);
const pkg = {
name: `${slug(name)}-mobile`,
version: "0.1.0",
private: true,
main: "expo-router/entry",
wrnexus: { mode: "native" },
scripts: {
compile: "cd .. && wrnexus mobile compile",
prestart: "bun run compile",
start: "expo start",
android: "expo run:android",
ios: "expo run:ios",
web: "expo start --web",
prebuild: "expo prebuild",
},
dependencies: {
expo: "^57.0.0",
"expo-router": "~57.0.4",
"expo-status-bar": "~57.0.0",
react: "19.2.3",
"react-native": "0.86.0",
"react-native-safe-area-context": "^5.6.0",
"react-native-screens": "^4.23.0",
},
devDependencies: { "@types/react": "^19.2.0", typescript: "^6.0.3" },
};
const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`;
const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`;
const screen = `import { StyleSheet, Text, View } from "react-native";\nimport { StatusBar } from "expo-status-bar";\n\nexport default function Home() {\n return <View style={styles.container}><StatusBar style="auto" /><Text style={styles.title}>${name}</Text><Text>Fully native WrNexus client</Text></View>;\n}\nconst styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, title: { fontSize: 28, fontWeight: "700", marginBottom: 8 } });\n`;
const readme = `# ${name} native mobile\n\nThis is a fully native Expo/React Native client: it does not use a WebView. Portable pages from \`../app/pages/**/*.wrn\` compile into Expo routes with \`bun run compile\` (also run automatically before \`start\`). You can edit or add native-only TSX screens in \`app/\`, and call the shared WrNexus backend through \`src/wrnexus.ts\`.\n\n\`\`\`bash\nbun install\nbun run compile\nbun run start\nbun run android\n# macOS/Xcode: bun run ios\n\`\`\`\n\nServer API routes, authentication endpoints, uploads, and WebSockets remain reusable. Unsupported DOM-only markup fails compilation with a specific error. Override the backend per environment with \`EXPO_PUBLIC_WRNEXUS_URL\`. Add native modules with \`bunx expo install <package>\`.\n`;
console.log("Scaffolding fully native Expo mobile app:");
writeIfAbsent(
join(mobile, "package.json"),
`${JSON.stringify(pkg, null, 2)}\n`,
"mobile/package.json",
);
writeIfAbsent(join(mobile, "app.config.ts"), expo, "mobile/app.config.ts");
writeIfAbsent(join(mobile, "app", "index.tsx"), screen, "mobile/app/index.tsx");
writeIfAbsent(join(mobile, "src", "wrnexus.ts"), env, "mobile/src/wrnexus.ts");
writeIfAbsent(
join(mobile, "tsconfig.json"),
`${JSON.stringify({ extends: "expo/tsconfig.base", compilerOptions: { strict: true } }, null, 2)}\n`,
"mobile/tsconfig.json",
);
writeIfAbsent(
join(mobile, ".gitignore"),
"node_modules\n.expo\nandroid\nios\n",
"mobile/.gitignore",
);
writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md");
console.log("\nNext: cd mobile && bun install && bun run start");
}