From ae4e6dcd8babb44e93b9034fbe40d5fea8c71788 Mon Sep 17 00:00:00 2001 From: Daniel Gimenez Date: Tue, 31 Mar 2026 18:58:27 -0300 Subject: [PATCH] Mock Server Implementation Adds a service worker that captures all outgoing `/api/*` requests, and connects back to the main thread via a MessageChannel. That way, code running in the browser can implement the APIs. All requests can be inspected in the network tab. --- README.md | 3 +- public/sw.js | 72 ++++++++++++++++++++++++++ src/components/InterviewShell.tsx | 41 ++++++++------- src/index.tsx | 1 + src/interviews/README.md | 37 +++++++++++++- src/interviews/types.ts | 13 +++++ src/server/concurrency.ts | 25 +++++++++ src/server/index.ts | 73 +++++++++++++++++++++++++++ src/server/router.ts | 36 +++++++++++++ src/server/serviceWorkerController.ts | 34 +++++++++++++ 10 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 public/sw.js create mode 100644 src/server/concurrency.ts create mode 100644 src/server/index.ts create mode 100644 src/server/router.ts create mode 100644 src/server/serviceWorkerController.ts diff --git a/README.md b/README.md index e7f32c2..d53fcbb 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ codeflow/ │ ├── components/ # UI components │ ├── interviews/ # Interview patterns & challenges. This directory should NOT contain interviews at the repository level. │ ├── hooks/ # Custom React hooks -│ └── lib/ # Utility functions +│ ├── lib/ # Utility functions +│ └── server/ # Mock API server (SW-based request interception) ├── public/ # Static assets └── package.json # Dependencies ``` diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..6aa8130 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,72 @@ +/** + * Generic mock API Service Worker. + * + * Intercepts fetch requests to /api/* and forwards them to the main thread + * via MessageChannel. The main thread runs registered route handlers and + * sends the response back through the channel port. + * + * This SW is challenge-agnostic — each challenge registers its own handlers. + */ + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (url.pathname.startsWith('/api/')) { + event.respondWith(handleMockRequest(event)); + } +}); + +async function handleMockRequest(event) { + const client = await self.clients.get(event.clientId); + if (!client) { + return new Response(JSON.stringify({ error: 'No client found' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let body = null; + try { + const text = await event.request.text(); + if (text) body = JSON.parse(text); + } catch (_) { + // No body or non-JSON body + } + + const { port1, port2 } = new MessageChannel(); + + return new Promise((resolve) => { + port1.onmessage = (e) => { + resolve( + new Response(JSON.stringify(e.data.body), { + status: e.data.status || 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + }; + + const reqUrl = new URL(event.request.url); + client.postMessage( + { + type: 'mock-api-request', + url: reqUrl.pathname + reqUrl.search, + method: event.request.method, + body: body, + }, + [port2] + ); + }); +} + +// Activate immediately and claim all clients so the SW is ready without a reload +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +// Allow the main thread to re-trigger claim (e.g. after unregister + re-register +// where activate doesn't fire again because the SW script hasn't changed). +self.addEventListener('message', (event) => { + if (event.data?.type === 'claim') { + self.clients.claim(); + } +}); diff --git a/src/components/InterviewShell.tsx b/src/components/InterviewShell.tsx index b142746..356662b 100644 --- a/src/components/InterviewShell.tsx +++ b/src/components/InterviewShell.tsx @@ -1,36 +1,30 @@ -import React, { useState, useEffect, useRef } from "react"; -import { InterviewPattern } from "../interviews/types"; +import React, {useEffect, useRef, useState} from "react"; +import {InterviewPattern} from "@/interviews/types"; +import {setupRoutes} from "@/server"; import Instructions from "./Instructions"; import CodingChallengeWrapper from "./CodingChallengeWrapper"; import CodeReviewInterface from "./CodeReviewInterface"; import "./InterviewShell.css"; -import { Button } from "./ui/button"; -import { ArrowLeftIcon } from "lucide-react"; -import { ThemeSwitcher } from "./theme-switcher"; -import { Badge } from "./ui/badge"; +import {Button} from "./ui/button"; +import {ThemeSwitcher} from "./theme-switcher"; +import {Badge} from "./ui/badge"; import { Breadcrumb, - BreadcrumbList, BreadcrumbItem, BreadcrumbLink, - BreadcrumbSeparator, + BreadcrumbList, BreadcrumbPage, + BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Sidebar, SidebarContent, - SidebarGroup, - SidebarGroupLabel, - SidebarGroupContent, - SidebarMenu, - SidebarMenuItem, - SidebarMenuButton, - SidebarProvider, SidebarInset, + SidebarProvider, SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; -import { Separator } from "./ui/separator"; +import {Separator} from "./ui/separator"; interface InterviewShellProps { pattern: InterviewPattern; @@ -108,6 +102,15 @@ const SIDEBAR_OPEN_KEY_PREFIX = "sidebar-open-"; const InterviewShell: React.FC = ({ pattern, onBack }) => { const [showInstructions, setShowInstructions] = useState(false); const [hasViewedInstructions, setHasViewedInstructions] = useState(false); + const [serverReady, setServerReady] = useState(!pattern.routes?.length); + + // Initialize mock API server if the pattern declares routes + useEffect(() => { + if (pattern.routes?.length) { + setServerReady(false); + setupRoutes(pattern.routes).then(() => setServerReady(true)); + } + }, [pattern]); const [sidebarWidth, setSidebarWidth] = useState(640); // 40rem in pixels const [isDragging, setIsDragging] = useState(false); const sidebarRef = useRef(null); @@ -252,7 +255,11 @@ const InterviewShell: React.FC = ({ pattern, onBack }) => {
- {showInstructions && pattern.readmes ? ( + {!serverReady ? ( +
+ Starting server... +
+ ) : showInstructions && pattern.readmes ? ( setShowInstructions(false)} diff --git a/src/index.tsx b/src/index.tsx index c16d155..ae997df 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; +import './server'; // Register SW eagerly at startup const root = ReactDOM.createRoot( document.getElementById('root') as Element diff --git a/src/interviews/README.md b/src/interviews/README.md index b25c5f0..6559864 100644 --- a/src/interviews/README.md +++ b/src/interviews/README.md @@ -85,6 +85,22 @@ export const pattern: InterviewPattern = { title: 'Part 1: Requirements', content: 'More detailed instructions...' } + ], + routes: [ // Mock API routes (optional) + { + method: 'GET', + path: '/api/items', + handler: async (req) => { + return [{ id: 1, name: 'Item 1' }]; + } + }, + { + method: 'GET', + path: '/api/items/:id', + handler: async (req) => { + return { id: req.params.id, name: 'Item 1' }; + } + } ] }; ``` @@ -117,4 +133,23 @@ your-pattern/ - **`tags`**: Array of technology/skill tags for categorization and filtering - **`type`**: Pattern type - defaults to 'react' if not specified - **`component`**: Reference to the main React component candidates will work on -- **`readmes`**: Array of instruction tabs with markdown content for guidance \ No newline at end of file +- **`readmes`**: Array of instruction tabs with markdown content for guidance +- **`routes`**: Array of mock API routes (see below) + +### Mock API Routes + +Patterns can define `routes` to provide a mock API server. When a pattern with routes is loaded, Codeflow intercepts `fetch` calls to `/api/*` via a Service Worker and routes them to your handlers — no real backend needed. + +Each route has: + +- **`method`**: HTTP method — `'GET'`, `'POST'`, `'PUT'`, or `'DELETE'` +- **`path`**: URL path starting with `/api/`. Supports named parameters with `:param` syntax (e.g. `/api/items/:id`) +- **`handler`**: Async function that receives an `ApiRequest` and returns the response body + +The `ApiRequest` object contains: + +- **`params`**: Named path parameters (e.g. `{ id: '42' }` for `/api/items/:id`) +- **`query`**: Query string parameters (e.g. `{ search: 'foo' }` for `/api/items?search=foo`) +- **`body`**: Parsed JSON request body (for POST/PUT requests) + +Handlers run in the main thread with a simulated network delay. Applications use standard `fetch('/api/...')` calls as if the route handlers were running server-side. \ No newline at end of file diff --git a/src/interviews/types.ts b/src/interviews/types.ts index ccbde09..9b71b0b 100644 --- a/src/interviews/types.ts +++ b/src/interviews/types.ts @@ -9,6 +9,18 @@ export interface ImplementationDetails { testCases?: string; } +export interface ApiRequest { + params: Record; + query: Record; + body: unknown; +} + +export interface ApiRoute { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + handler: (req: ApiRequest) => Promise; +} + export interface InterviewPattern { id: string; name: string; @@ -21,6 +33,7 @@ export interface InterviewPattern { component?: React.ComponentType; // Optional for code-review type type?: 'react' | 'coding-challenge' | 'code-review'; // Added code-review type implementationDetails?: ImplementationDetails; + routes?: ApiRoute[]; } export interface InterviewConfig { diff --git a/src/server/concurrency.ts b/src/server/concurrency.ts new file mode 100644 index 0000000..655e80b --- /dev/null +++ b/src/server/concurrency.ts @@ -0,0 +1,25 @@ +export function createPool(maxConcurrency: number) { + let active = 0; + const queue: (() => void)[] = []; + + function acquire(): Promise { + if (active < maxConcurrency) { + active++; + return Promise.resolve(); + } + return new Promise((resolve) => + queue.push(() => { + active++; + resolve(); + }) + ); + } + + function release(): void { + active--; + const next = queue.shift(); + if (next) next(); + } + + return { acquire, release }; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..24910a5 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,73 @@ +import type { ApiRoute } from "@/interviews/types"; +import { createPool } from "@/server/concurrency"; +import { compilePath, matchRoute, type CompiledRoute } from "@/server/router"; +import { ensureController } from "@/server/serviceWorkerController"; + +let compiledRoutes: CompiledRoute[] = []; + +const pool = createPool(5); + +function mockDelay(): Promise { + const ms = 300 + (Math.random() * 400 - 200); // 100–500ms + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Registers the Service Worker and message listener. + * Call once at app startup — resolves when the SW is controlling the page. + */ +export const initServer: Promise = (async () => { + if (!("serviceWorker" in navigator)) { + console.warn("[codeflow] Service Workers not supported. Server will not work"); + return; + } + + await ensureController(); + console.log("[codeflow] Server started"); + + navigator.serviceWorker.addEventListener("message", async (event) => { + if (event.data?.type !== "mock-api-request") return; + + const { method, url, body } = event.data; + const parsed = new URL(url, location.origin); + const matched = matchRoute(compiledRoutes, method, parsed.pathname); + + if (matched) { + await pool.acquire(); + try { + await mockDelay(); + const result = await matched.handler({ + params: matched.params, + query: Object.fromEntries(parsed.searchParams), + body, + }); + event.ports[0].postMessage({ status: 200, body: result }); + } catch (err) { + event.ports[0].postMessage({ + status: 500, + body: { error: String(err) }, + }); + } finally { + pool.release(); + } + } else { + event.ports[0].postMessage({ + status: 404, + body: { error: `No handler for ${method} ${url}` }, + }); + } + }); +})(); + +/** + * Replaces the active set of mock API routes. + * Waits for the SW to be ready before resolving. + */ +export async function setupRoutes(apiRoutes: ApiRoute[]): Promise { + await initServer; + compiledRoutes = apiRoutes.map((route) => ({ + method: route.method, + regex: compilePath(route.path), + handler: route.handler, + })); +} diff --git a/src/server/router.ts b/src/server/router.ts new file mode 100644 index 0000000..b5dc56f --- /dev/null +++ b/src/server/router.ts @@ -0,0 +1,36 @@ +import type { ApiRequest } from "@/interviews/types"; + +export interface CompiledRoute { + method: string; + regex: RegExp; + handler: (req: ApiRequest) => Promise; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function compilePath(path: string): RegExp { + const pattern = path + .split("/") + .map((seg) => + seg.startsWith(":") ? `(?<${seg.slice(1)}>[^/]+)` : escapeRegex(seg) + ) + .join("/"); + return new RegExp(`^${pattern}$`); +} + +export function matchRoute( + routes: CompiledRoute[], + method: string, + pathname: string +): { handler: CompiledRoute["handler"]; params: Record } | null { + for (const route of routes) { + if (route.method !== method) continue; + const match = route.regex.exec(pathname); + if (match) { + return { handler: route.handler, params: match.groups ?? {} }; + } + } + return null; +} diff --git a/src/server/serviceWorkerController.ts b/src/server/serviceWorkerController.ts new file mode 100644 index 0000000..ee4d33f --- /dev/null +++ b/src/server/serviceWorkerController.ts @@ -0,0 +1,34 @@ +/** + * Registers the SW and ensures it controls this page. + * If an existing-but-unregistered SW fails to claim within a short window, + * it unregisters the stale registration and retries from scratch. + */ +export async function ensureController(): Promise { + if (navigator.serviceWorker.controller) return; + + const controllerChanged = new Promise((resolve) => { + navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { + once: true, + }); + }); + + const registration = await navigator.serviceWorker.register("/sw.js"); + + // If the SW is already active but not controlling (e.g. after unregister + + // re-register where activate doesn't re-fire), ask it to claim explicitly. + if (registration.active && !navigator.serviceWorker.controller) { + registration.active.postMessage({ type: "claim" }); + } + + // If the controller does not connect in 500ms, SW is likely stale. Unregister and retry. + const result = await Promise.race([ + controllerChanged.then(() => "ok" as const), + new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 500)) + ]); + + if (result === "timeout") { + console.warn("[codeflow] Server initialization failed. Retrying..."); + await registration.unregister(); + return ensureController(); + } +}