diff --git a/.dockerignore b/.dockerignore index 10f3922..7f4c414 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,3 +20,19 @@ test-results # System files *.log .DS_Store + +# Editor/IDE +.claude +.flox + +# Lock files (deno.lock regenerated during build) +deno.lock + +# Documentation/config not needed in image +*.md +.prettierrc +.licenserc.yaml +.gitattributes +playwright.config.ts +eslint.config.mjs +vercel.json diff --git a/.gitignore b/.gitignore index f513d29..2fd1f3e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ build-archive.log test-results/ .react-router/ + +# Deno +deno.lock +rfd-server +.flox/ diff --git a/.infra/README.md b/.infra/README.md index 7451a38..a586adb 100644 --- a/.infra/README.md +++ b/.infra/README.md @@ -20,6 +20,40 @@ Note: Infrastructure configuration is stored in this repository until a point in we have RFD infrastructure that is separate from `cio`. At that point, this infrastructure should be owned by the RFD service. +## Storage Provider Configuration + +The application supports two storage backends for serving static assets: GCS (Google Cloud +Storage) and S3 (AWS S3 or S3-compatible services). + +### Common Environment Variables + +| Variable | Description | +| ------------------ | -------------------------------------------------------------------------- | +| `STORAGE_PROVIDER` | Storage backend to use: `gcs` (default) or `s3` | +| `STORAGE_URL_TTL` | Pre-signed URL expiration time in seconds (optional, defaults to 24 hours) | + +### GCS Configuration + +| Variable | Description | +| ------------------ | ------------------------------ | +| `STORAGE_URL` | Base URL of the GCS CDN bucket | +| `STORAGE_KEY_NAME` | Name of the signing key | +| `STORAGE_KEY` | Base64-encoded signing key | + +### S3 Configuration + +| Variable | Description | +| ----------------------- | ------------------------------------------------------------ | +| `S3_BUCKET` | S3 bucket name | +| `AWS_REGION` | AWS region (standard AWS SDK variable) | +| `AWS_ACCESS_KEY_ID` | AWS access key (standard AWS SDK variable, or use IAM roles) | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key (standard AWS SDK variable, or use IAM roles) | +| `AWS_ENDPOINT_URL` | Custom endpoint for S3-compatible services (optional) | + +The S3 integration uses the AWS SDK default credential chain, so credentials can be provided +via environment variables, IAM instance roles, ECS task roles, or other standard AWS +methods. + ### GCP Infrastructure Image storage and serving is handled by diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..977f1e9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this +repository. + +## Project Overview + +RFD Site is Oxide Computer Company's web frontend for browsing, searching, and reading RFDs +(Requests for Discussion). Built with React Router v7 (formerly Remix). Can be deployed on +Vercel or run as a Deno container. + +## Commands + +### Development (Node/npm) + +```bash +npm run dev # Start dev server on localhost:3000 +npm run build # Production build (react-router build) +npm run test # Run unit tests with Vitest +npm run tsc # Type check without emitting +npm run lint # ESLint +npm run fmt # Format with Prettier +npm run fmt:check # Check formatting +npm run e2ec # Run Playwright E2E tests (Chrome) +``` + +### Deno Runtime + +```bash +deno task dev # Dev server with Deno (requires build first) +deno task start # Production server with Deno +deno task build # Build using Deno's npm compatibility +deno task compile # Compile to standalone binary +``` + +### Container Build (Podman) + +```bash +podman build -t rfd-site . # Build container +podman run -p 3000:3000 rfd-site # Run container +podman run -p 3000:3000 -e PORT=8080 rfd-site # Custom port +``` + +The Containerfile uses a multi-stage build: + +1. Node stage: Builds the React Router app with npm +2. Deno stage: Compiles server.ts with embedded assets into a standalone binary +3. Scratch stage: Minimal final image with just the binary and CA certs + +### Running a Single Test + +```bash +npm run test -- path/to/file.test.ts # Run specific test file +npm run test -- --grep "test name" # Run tests matching pattern +npx playwright test --project=chrome test.ts # Run specific E2E test +``` + +### Local RFD Authoring Mode + +Preview RFDs from a local clone of the rfd repo: + +```bash +LOCAL_RFD_REPO=~/oxide/rfd npm run dev +``` + +This mode reads RFD files directly from the specified directory without needing API +credentials. + +## Architecture + +### Data Flow: Local vs Remote Mode + +The app operates in two modes controlled by `LOCAL_RFD_REPO` env var: + +- **Local mode** (`app/services/rfd.local.server.ts`): Reads AsciiDoc files directly from a + local rfd repo clone. Used for authoring/previewing. +- **Remote mode** (`app/services/rfd.remote.server.ts`): Fetches from the rfd-api backend. + Used in production with OAuth authentication. + +The unified interface in `app/services/rfd.server.ts` abstracts this, calling either backend +based on `isLocalMode()`. + +### Routing + +Uses React Router v7 file-based routing (`@react-router/fs-routes`). Routes are in +`app/routes/`: + +- `_index.tsx` - RFD listing page +- `rfd.$slug.tsx` - Individual RFD view +- `auth.*.tsx` - OAuth flows (GitHub, Google) +- `api.*.tsx` - API endpoints + +### Content Rendering + +RFDs are written in AsciiDoc and rendered with `@oxide/react-asciidoc`. Custom block +renderers live in `app/components/AsciidocBlocks/` (Mermaid diagrams, syntax-highlighted +code listings, images). + +### Path Alias + +`~/` maps to `./app/` (configured in tsconfig.json). + +## Key Dependencies + +- `@oxide/react-asciidoc` - AsciiDoc renderer +- `@oxide/rfd.ts` - TypeScript client for rfd-api +- `@oxide/design-system` - Oxide's component library +- `@tanstack/react-query` - Data fetching for PR discussions +- `shiki` - Syntax highlighting +- `mermaid` - Diagram rendering diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..23c258c --- /dev/null +++ b/Containerfile @@ -0,0 +1,84 @@ +# ============================================================================= +# Stage 1: Install npm dependencies and build the React Router app +# ============================================================================= +FROM docker.io/node:25-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ + +# Install dependencies +RUN npm ci + +# Copy source files +COPY app ./app +COPY public ./public +COPY vite ./vite +COPY types ./types +COPY tsconfig.json ./ +COPY vite.config.ts ./ +COPY react-router.config.ts ./ +COPY svgr.config.js ./ +COPY site.config.ts ./ + +# Build the application +RUN BUILD_TARGET=deno npm run build + +# ============================================================================= +# Stage 2: Compile Deno server into standalone binary +# ============================================================================= +FROM docker.io/denoland/deno:2.6.4 AS compiler + +WORKDIR /app + +# Copy deno config and server +COPY deno.json ./ +COPY server.ts ./ + +# Copy package.json for npm dependency resolution +COPY package.json ./ + +# Copy the built application from builder stage +COPY --from=builder /app/build ./build +COPY --from=builder /app/node_modules ./node_modules + +# Cache dependencies +RUN deno install \ + --node-modules-dir=manual + +# Compile to standalone binary with embedded assets +# --include embeds the entire build directory into the binary +# --unstable-bare-node-builtins allows imports like 'fs' instead of 'node:fs' +RUN deno compile \ + --allow-net \ + --allow-read \ + --allow-env \ + --unstable-bare-node-builtins \ + --include=build \ + --output=rfd-server \ + --node-modules-dir=manual \ + --allow-sys \ + server.ts + +FROM docker.io/debian:12-slim as tools + +RUN apt update -y && apt install tini -y +# ============================================================================= +# Stage 3: Create minimal Alpine image +# ============================================================================= +FROM docker.io/debian:12-slim + +# Copy the compiled binary (includes embedded static assets) +COPY --from=compiler /app/rfd-server /rfd-server +COPY --from=tools /usr/bin/tini /tini + +# Set environment variables +ENV PORT=3000 +ENV NODE_ENV=production + +# Expose the port +EXPOSE 3000 + +# Run the server +ENTRYPOINT ["/tini", "/rfd-server"] diff --git a/README.md b/README.md index f54d5b4..5c0b0fd 100644 --- a/README.md +++ b/README.md @@ -101,19 +101,55 @@ combined branch that contains both. When running in a non-local mode, the following settings must be specified: - `SESSION_SECRET` - Key that will be used to signed cookies +- `TELEMETRY_DISABLE` - Disable all remote telemetry. Set to any non-empty value to disable (e.g., `1`, `true`, `yes`). Leave unset or set to empty string to enable. -- `RFD_API` - Backend RFD API to communicate with (i.e. https://api.server.com) -- `RFD_API_CLIENT_ID` - OAuth client id create via the RFD API -- `RFD_API_CLIENT_SECRET` - OAuth client secret create via the RFD API +#### Authentication + +##### API URL Configuration + +The RFD API URL can be configured in two ways: + +- `RFD_API` - Single URL for both server-to-server calls and OAuth redirects (legacy, + simplest) +- `RFD_API_BACKEND_URL` + `RFD_API_FRONTEND_URL` - Split URLs for deployments where rfd-site + uses internal networking to reach rfd-api while users access a public endpoint + +When using split URLs: + +- `RFD_API_BACKEND_URL` - URL for server-to-server API calls (e.g., internal load balancer) +- `RFD_API_FRONTEND_URL` - URL for OAuth redirects where user's browser is directed + +You can mix configurations: set one of the new vars and use `RFD_API` as fallback for the +other. Existing deployments using only `RFD_API` will continue to work unchanged. + +##### OAuth Credentials + +- `RFD_API_CLIENT_ID` - OAuth client id created via the RFD API +- `RFD_API_CLIENT_SECRET` - OAuth client secret created via the RFD API - `RFD_API_GOOGLE_CALLBACK_URL` - Should be of the form of `https://{rfd_site_hostname}/auth/google/callback` - `RFD_API_GITHUB_CALLBACK_URL` - Should be of the form of `https://{rfd_site_hostname}/auth/github/callback` +- `RFD_API_MLINK_SECRET` - Client secret for magic link (email) authentication + +- `AUTH_PROVIDERS` - Comma-delimited list of enabled authentication providers. Valid values + are `github`, `google`, and `email`. If not set, all providers are enabled by default. + Providers with missing required environment variables are automatically disabled. + Examples: + - `AUTH_PROVIDERS=github,google` - Enable only GitHub and Google OAuth + - `AUTH_PROVIDERS=email` - Enable only email (magic link) authentication + +#### Storage - `STORAGE_URL` - Url of bucket for static assets - `STORAGE_KEY_NAME` - Name of the key defined in `STORAGE_KEY` - `STORAGE_KEY` - Key for generating signed static asset urls +#### GitHub Integration + +- `GITHUB_HOST` - GitHub host for the RFD repository. Defaults to + `github.com/oxidecomputer/rfd`. Set this to use a GitHub Enterprise instance or a + different repository location (e.g., `github.example.com/org/rfd`). - `GITHUB_APP_ID` - App id for fetching GitHub PR discussions - `GITHUB_INSTALLATION_ID` - Installation id of GitHub App - `GITHUB_PRIVATE_KEY` - Private key of the GitHub app for discussion fetching diff --git a/app/components/rfd/RfdDiscussionDialog.tsx b/app/components/rfd/RfdDiscussionDialog.tsx index 249012e..0176633 100644 --- a/app/components/rfd/RfdDiscussionDialog.tsx +++ b/app/components/rfd/RfdDiscussionDialog.tsx @@ -22,6 +22,7 @@ import { useMemo } from 'react' import Icon from '~/components/Icon' import { useDiscussionQuery } from '~/hooks/use-discussion-query' +import { useRootLoaderData } from '~/root' import type { IssueCommentType, ListIssueCommentsType, @@ -225,6 +226,7 @@ const DialogContent = ({ discussions: Discussions pullNumber: number }) => { + const { githubRepoUrl } = useRootLoaderData() return ( @@ -262,6 +264,7 @@ const DiscussionReviewGroup = ({ discussions: Discussions pullNumber: number }) => { + const { githubRepoUrl } = useRootLoaderData() const reviewCount = Object.keys(discussions).length return ( @@ -306,7 +309,7 @@ const DiscussionReviewGroup = ({ This discussion has no reviews or comments

, + { + onError(error: unknown) { + console.error(error) + responseStatusCode = 500 + }, + }, + ) + + // For bots, wait for all content to be ready + if (isBot) { + await stream.allReady + } + + responseHeaders.set('Content-Type', 'text/html') + + return new Response(stream, { + status: responseStatusCode, + headers: responseHeaders, + }) +} diff --git a/app/root.tsx b/app/root.tsx index 171a134..1f25b2b 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -61,6 +61,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const theme = (await themeCookie.parse(request.headers.get('Cookie'))) ?? 'dark-mode' const inlineComments = (await inlineCommentsCookie.parse(request.headers.get('Cookie'))) ?? true + const githubRepoUrl = `https://${process.env.GITHUB_HOST || 'github.com/oxidecomputer/rfd'}` const user = await authenticate(request) try { @@ -82,6 +83,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { labels, localMode: isLocalMode(), newRfdNumber: provideNewRfdNumber([...rfds]), + githubRepoUrl, } } catch { // The only error that should be caught here is the unauthenticated error. @@ -101,6 +103,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { labels: [], localMode: isLocalMode(), newRfdNumber: undefined, + githubRepoUrl, } } @@ -147,6 +150,10 @@ const Layout = ({ + {/* Use plausible analytics only on Vercel */} + {process.env.NODE_ENV === 'production' && !process.env.TELEMETRY_DISABLE && ( +