diff --git a/README.md b/README.md index 79f156d..02b0adf 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,19 @@ - bootstrap an anonymous local Convex deployment and run the backend health query: `pnpm bootstrap:backend:local` - keep the local Convex backend watcher running: `pnpm dev:backend:local` - run the one-shot local Convex health check alias: `pnpm run:backend:health:local` +- run the web app with the local Convex URL bridged into `NEXT_PUBLIC_CONVEX_URL`: `pnpm dev:web` +- typecheck the web app with the same Convex URL bridge when available: `pnpm typecheck:web` +- build the web app with the same Convex URL bridge when available: `pnpm build:web` - typecheck Convex backend files: `pnpm typecheck:backend` - re-run the local backend verification pass: `pnpm verify:backend:local` - confirm committed Convex codegen is current: `pnpm check:backend:generated` -- run the web app: `pnpm dev:web` - lint the web app: `pnpm lint:web` -- typecheck the web app: `pnpm typecheck:web` -- build the web app: `pnpm build:web` - run the baseline local verification pass: `pnpm verify` Convex writes repo-root deployment configuration to `.env.local` during local setup and keeps anonymous local state under `.convex-home/` plus `.convex-tmp/`. Keep all of those uncommitted. The committed `convex/_generated/` files are expected to stay clean after `pnpm check:backend:generated`. +The repo-root web commands bridge `CONVEX_URL` from `.env.local` into `NEXT_PUBLIC_CONVEX_URL` automatically when the file exists, so the first `apps/web -> convex/` runtime path works without a second hand-maintained env file. + `pnpm verify` is the full repo verification pass and now includes the local Convex bootstrap checks. If you are iterating on the web app only, use `pnpm verify:web` for the lighter web-only path. ## Start here diff --git a/apps/web/README.md b/apps/web/README.md index 5c47989..42196b2 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -8,12 +8,14 @@ From the repo root: ```bash pnpm install +pnpm dev:backend:local pnpm dev:web ``` Useful follow-up commands: ```bash +pnpm bootstrap:backend:local pnpm lint:web pnpm typecheck:web pnpm build:web @@ -25,4 +27,6 @@ pnpm build:web - framework baseline: `Next.js` App Router - language baseline: `TypeScript` - styling baseline: `Tailwind CSS` -- this scaffold intentionally stops before `Convex`, auth, billing, or deployment wiring +- repo-root web commands bridge `CONVEX_URL` from `.env.local` into `NEXT_PUBLIC_CONVEX_URL` when local Convex bootstrap has run +- the homepage now renders the placeholder public query `health:status` from `convex/` +- auth, billing, deployment hardening, and server-side Convex patterns still belong to follow-on issues diff --git a/apps/web/package.json b/apps/web/package.json index 3cbba71..e37940d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "typecheck": "next typegen && tsc --noEmit --incremental false" }, "dependencies": { + "convex": "^1.32.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" diff --git a/apps/web/src/app/convex-client-provider.tsx b/apps/web/src/app/convex-client-provider.tsx new file mode 100644 index 0000000..950976a --- /dev/null +++ b/apps/web/src/app/convex-client-provider.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import type { ReactNode } from "react"; + +let convexClient: ConvexReactClient | null = null; +let convexClientUrl: string | null = null; + +function getConvexClient(convexUrl: string | undefined) { + if (!convexUrl || typeof window === "undefined") { + return null; + } + + if (!convexClient || convexClientUrl !== convexUrl) { + convexClient?.close(); + convexClient = new ConvexReactClient(convexUrl); + convexClientUrl = convexUrl; + } + + return convexClient; +} + +export function resetConvexClientForTests() { + convexClient?.close(); + convexClient = null; + convexClientUrl = null; +} + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + const client = getConvexClient(convexUrl); + + if (!client) { + return <>{children}; + } + + return {children}; +} diff --git a/apps/web/src/app/convex-runtime-panel.tsx b/apps/web/src/app/convex-runtime-panel.tsx new file mode 100644 index 0000000..92ac24b --- /dev/null +++ b/apps/web/src/app/convex-runtime-panel.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { api } from "@convex/_generated/api"; +import { Component, type ReactNode, useState, useSyncExternalStore } from "react"; +import { useQuery } from "convex/react"; + +function subscribeToClientReady() { + return () => {}; +} + +class ConvexQueryErrorBoundary extends Component< + { children: ReactNode; onRetry: () => void }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: unknown, info: React.ErrorInfo) { + console.error("[ConvexQueryErrorBoundary] Caught backend query error:", error, info); + } + + render() { + if (this.state.hasError) { + return ( +
+
+

+ Live Convex status +

+

+ Backend query needs attention. +

+
+ +
+ The live Convex panel hit a backend error. Check{" "} + pnpm dev:backend:local and refresh once the local + backend is healthy again. +
+ + +
+ ); + } + + return this.props.children; + } +} + +function ConvexRuntimeStatus({ convexUrl }: { convexUrl: string }) { + const status = useQuery(api.health.status); + const statusHeading = status === undefined ? "Connecting to Convex..." : "The first runtime path is active."; + + return ( +
+
+

+ Live Convex status +

+

+ {statusHeading} +

+
+ +

+ This panel reads the placeholder health:status{" "} + query from the real backend bootstrap under convex/. +

+ +
+

+ Backend endpoint +

+

{convexUrl}

+
+ + {status === undefined ? ( +
+

+ Query state +

+

+ Connecting to Convex and waiting for the first placeholder payload. +

+
+ ) : status ? ( +
+
+
Status
+
{status.status}
+
+
+
Backend
+
{status.backend}
+
+
+
Project
+
{status.project}
+
+
+
Scope
+
{status.scope}
+
+
+
Backend note
+
{status.note}
+
+
+ ) : ( +
+

+ Query result +

+

+ Convex returned an empty placeholder payload. Refresh after restarting the local + backend if this sticks around. +

+
+ )} +
+ ); +} + +export function ConvexRuntimePanel() { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + const isClient = useSyncExternalStore(subscribeToClientReady, () => true, () => false); + const [retryKey, setRetryKey] = useState(0); + + if (!convexUrl) { + return ( +
+
+

+ Convex runtime path +

+

+ Waiting for local backend wiring. +

+
+ +

+ The app can render without a backend URL, but the live Convex read path only + turns on when the repo-root .env.local{" "} + file exists. +

+ +
+ Run pnpm bootstrap:backend:local once, keep{" "} + pnpm dev:backend:local running, and start the web + app with pnpm dev:web from the repo root. +
+
+ ); + } + + if (!isClient) { + return ( +
+
+

+ Live Convex status +

+

+ Connecting to Convex... +

+
+ +
+ Preparing the client-side Convex runtime. +
+
+ ); + } + + return ( + setRetryKey((current) => current + 1)}> + + + ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 25b69e4..13d76d9 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google"; +import { ConvexClientProvider } from "./convex-client-provider"; import "./globals.css"; const spaceGrotesk = Space_Grotesk({ @@ -28,7 +29,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 01f3d08..d8ee906 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,3 +1,5 @@ +import { ConvexRuntimePanel } from "./convex-runtime-panel"; + export default function Home() { return (
@@ -15,10 +17,10 @@ export default function Home() { Profiles, communities, and scene presence for VRChat.

- This is the first{" "} - Next.js surface for - VRDex. It gives the repo a real app shell without prematurely - locking in product flows that still belong to follow-on issues. + The first Next.js runtime + path into Convex is now in + place. This landing page stays lightweight while proving the stack is wired end + to end.

@@ -43,31 +45,7 @@ export default function Home() { @@ -78,12 +56,12 @@ export default function Home() { Now in place

- A clean frontend baseline + A live app-to-backend read

- The repo now has a real web surface under{" "} - apps/web, ready for - local development, linting, and production builds. + The web app now mounts the minimum Convex client/provider wiring and + reads the placeholder health:status{" "} + query from the real backend bootstrap.

@@ -92,13 +70,12 @@ export default function Home() { Deliberately deferred

- App integration and auth wiring + Schema, auth, and billing depth

- Convex now has a real backend foothold under{" "} - convex/, while app - integration, identity providers, billing, and deployment posture - stay in their own follow-on issues. + This slice avoids inventing product tables, auth flows, or payment + posture. The goal is one obvious runtime path, not premature app + architecture.

@@ -107,11 +84,11 @@ export default function Home() { Immediate follow-on

- Connect the real runtime path + Add the first server-side pattern

- The next meaningful milestone is wiring this app to the first Convex - backend path and making the stack visible end to end. + The next app/data milestone is the first intentional App Router + server-side Convex read path under #64.

diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 441538a..47daec4 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -19,6 +19,7 @@ } ], "paths": { + "@convex/*": ["../../convex/*"], "@/*": ["./src/*"] } }, diff --git a/convex/README.md b/convex/README.md index b1c830e..8d79e8e 100644 --- a/convex/README.md +++ b/convex/README.md @@ -6,11 +6,13 @@ This directory holds the initial Convex backend slice for `VRDex`. - `schema.ts` keeps the starting schema explicit and intentionally empty - `_generated/` contains committed Convex codegen output and should not be edited by hand - `tsconfig.json` is the Convex-managed TypeScript config for backend functions +- `apps/web` consumes `health:status` as the first live app-to-backend runtime path Use the repo-root scripts for local work: - `pnpm bootstrap:backend:local` - `pnpm dev:backend:local` +- `pnpm dev:web` - `pnpm run:backend:health:local` - `pnpm typecheck:backend` - `pnpm check:backend:generated` diff --git a/docs/backend/convex-bootstrap.md b/docs/backend/convex-bootstrap.md index a9b6c14..b61fcac 100644 --- a/docs/backend/convex-bootstrap.md +++ b/docs/backend/convex-bootstrap.md @@ -18,23 +18,26 @@ It is intentionally narrow: enough structure to run Convex locally, generate typ - `convex/health.ts` exposes a minimal public query, `health:status`, that confirms the backend is reachable without hard-coding early product domain records - `convex.json` pins Convex to Node `22` so local backend runtime expectations stay aligned with the repo's current Node baseline - `convex/tsconfig.json` provides the TypeScript settings Convex uses to typecheck backend source files +- `apps/web` now mounts a minimal Convex client/provider path and renders `health:status` on the landing page as the first end-to-end app read ## Local workflow 1. Install dependencies with `pnpm install`. 2. Bootstrap an anonymous local deployment and verify the health query with `pnpm bootstrap:backend:local`. 3. Keep `pnpm dev:backend:local` running while editing backend files. -4. Run `pnpm run:backend:health:local` from a second terminal when you want the same one-shot placeholder health check without using the verify script name directly. -5. Run `pnpm typecheck:backend` before shipping backend edits. -6. Run `pnpm check:backend:generated` before pushing schema or function changes that may affect `convex/_generated/`. +4. Start the web app with `pnpm dev:web` when you want the frontend to read the local backend through the bridged `NEXT_PUBLIC_CONVEX_URL` value. +5. Run `pnpm run:backend:health:local` from a second terminal when you want the same one-shot placeholder health check without using the verify script name directly. +6. Run `pnpm typecheck:backend` before shipping backend edits. +7. Run `pnpm check:backend:generated` before pushing schema or function changes that may affect `convex/_generated/`. If you want the full repo gate, use `pnpm verify`. If you only need the web app checks, keep using `pnpm verify:web`. Notes: - Convex writes deployment configuration to the repo-root `.env.local` file. +- repo-root web commands copy `CONVEX_URL` from that file into `NEXT_PUBLIC_CONVEX_URL` when available so `apps/web` can use the local backend without a duplicate env file. - Anonymous local backend state for this repo is kept under `.convex-home/` and `.convex-tmp/` so the bootstrap does not collide with other Convex projects on the same machine. -- The current bootstrap is local-development focused. Production deploy keys, preview deployments, and frontend environment wiring belong to follow-on issues. +- The current bootstrap is local-development focused. Production deploy keys, preview deployments, and server-side frontend data patterns belong to follow-on issues. - Committed files in `convex/_generated/` are treated as checked-in build artifacts and should remain diff-free after `pnpm check:backend:generated`. ## Structure rule @@ -47,5 +50,6 @@ Keep the initial backend slice simple: ## Follow-on issues -- `#55` should wire the web app to the Convex client/runtime path +- `#55` wires the web app to the first Convex client/runtime path +- `#64` should add the first intentional server-side `Next.js` data path into Convex - schema, auth, billing, and production deployment posture should land in their own issues instead of bloating the bootstrap diff --git a/docs/planning/engineering-strategy.md b/docs/planning/engineering-strategy.md index 6637471..3b19005 100644 --- a/docs/planning/engineering-strategy.md +++ b/docs/planning/engineering-strategy.md @@ -49,7 +49,7 @@ Current recommendation: - bootstrap Convex in the repo-root `convex/` directory so backend functions stay easy to discover in a small monorepo - keep the first backend slice schema-light with an explicit health query instead of guessing at product tables too early -- use local-development-friendly Convex setup first, then layer in frontend wiring, auth, billing, and production deployment posture through follow-on issues +- use local-development-friendly Convex setup first, then layer in the first frontend runtime wiring before auth, billing, server-side data patterns, and production deployment posture - once the local backend bootstrap is deterministic, include it in the baseline PR verification pass alongside the web checks ## Monetization direction diff --git a/package.json b/package.json index 73e568b..07ec28c 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "scripts": { "bootstrap:backend:local": "pnpm verify:backend:local", "dev:backend:local": "node scripts/run-convex-local.mjs dev --local", - "dev:web": "pnpm --filter web dev", - "build:web": "pnpm --filter web build", + "dev:web": "node scripts/run-web-with-convex-env.mjs pnpm --filter web dev", + "build:web": "node scripts/run-web-with-convex-env.mjs pnpm --filter web build", "lint:web": "pnpm --filter web lint", "typecheck:backend": "tsc --noEmit --project convex/tsconfig.json", "run:backend:health:local": "pnpm verify:backend:local", - "typecheck:web": "pnpm --filter web typecheck", + "typecheck:web": "node scripts/run-web-with-convex-env.mjs pnpm --filter web typecheck", "verify:backend:local": "node scripts/run-convex-local.mjs dev --local --once --run health:status --tail-logs disable", "check:backend:generated": "pnpm verify:backend:local && git diff --exit-code -- convex/_generated", "verify:web": "pnpm lint:web && pnpm typecheck:web && pnpm build:web", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6044a3..5d27538 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: apps/web: dependencies: + convex: + specifier: ^1.32.0 + version: 1.32.0(react@19.2.3) next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) diff --git a/scripts/run-web-with-convex-env.mjs b/scripts/run-web-with-convex-env.mjs new file mode 100644 index 0000000..62583c8 --- /dev/null +++ b/scripts/run-web-with-convex-env.mjs @@ -0,0 +1,167 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const envFilePath = path.join(repoRoot, ".env.local"); +const args = process.argv.slice(2); + +if (args.length === 0) { + console.error( + "Usage: node scripts/run-web-with-convex-env.mjs [args...]", + ); + process.exit(1); +} + +function parseEnvFile(filePath) { + if (!existsSync(filePath)) { + return {}; + } + + let contents; + + try { + contents = readFileSync(filePath, "utf8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[run-web-with-convex-env] Could not read ${filePath}: ${message}`); + return {}; + } + + const parsed = {}; + + for (const rawLine of contents.split(/\r?\n/u)) { + const line = rawLine.trim(); + + if (!line || line.startsWith("#")) { + continue; + } + + const normalizedLine = line.startsWith("export ") + ? line.slice("export ".length) + : line; + const separatorIndex = normalizedLine.indexOf("="); + + if (separatorIndex === -1) { + continue; + } + + const key = normalizedLine.slice(0, separatorIndex).trim(); + let value = normalizedLine.slice(separatorIndex + 1).trim(); + + if (isQuotedValue(value)) { + value = value.slice(1, -1); + } + + parsed[key] = value; + } + + return parsed; +} + +function isQuotedValue(value) { + if (value.length < 2) { + return false; + } + + const quote = value[0]; + + if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) { + return false; + } + + let escaped = false; + + for (let index = 1; index < value.length - 1; index += 1) { + const character = value[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + if (character === quote) { + return false; + } + } + + if (escaped) { + return false; + } + + return true; +} + +const fileEnv = parseEnvFile(envFilePath); +const env = { + ...process.env, +}; + +for (const key of ["CONVEX_DEPLOYMENT", "CONVEX_SITE_URL", "CONVEX_URL"]) { + if (Object.prototype.hasOwnProperty.call(fileEnv, key)) { + env[key] = fileEnv[key]; + } +} + +if ( + Object.prototype.hasOwnProperty.call(fileEnv, "NEXT_PUBLIC_CONVEX_URL") && + fileEnv.NEXT_PUBLIC_CONVEX_URL +) { + env.NEXT_PUBLIC_CONVEX_URL = fileEnv.NEXT_PUBLIC_CONVEX_URL; +} else if (Object.prototype.hasOwnProperty.call(fileEnv, "CONVEX_URL") && fileEnv.CONVEX_URL) { + env.NEXT_PUBLIC_CONVEX_URL = fileEnv.CONVEX_URL; +} else if (!env.NEXT_PUBLIC_CONVEX_URL && env.CONVEX_URL) { + env.NEXT_PUBLIC_CONVEX_URL = env.CONVEX_URL; +} + +const [command, ...commandArgs] = args; +const child = spawn(command, commandArgs, { + cwd: repoRoot, + env, + shell: process.platform === "win32", + stdio: "inherit", +}); + +const signalHandlers = new Map(); + +for (const signal of ["SIGINT", "SIGTERM"]) { + const handler = () => { + if (!child.killed) { + child.kill(signal); + } + }; + + signalHandlers.set(signal, handler); + process.on(signal, handler); +} + +child.on("error", (error) => { + const code = error.code ? ` [${error.code}]` : ""; + console.error(`Failed to spawn web command (${command})${code}: ${error.message}`); + process.exit(1); +}); + +child.on("exit", (code, signal) => { + if (signal) { + if (process.platform !== "win32") { + for (const [registeredSignal, handler] of signalHandlers) { + process.removeListener(registeredSignal, handler); + } + + process.kill(process.pid, signal); + return; + } + + process.exit(1); + return; + } + + process.exit(code ?? 1); +});