Skip to content

Commit eeec4f5

Browse files
committed
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.
1 parent 1ba2ade commit eeec4f5

8 files changed

Lines changed: 278 additions & 17 deletions

File tree

public/sw.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Generic mock API Service Worker.
3+
*
4+
* Intercepts fetch requests to /api/* and forwards them to the main thread
5+
* via MessageChannel. The main thread runs registered route handlers and
6+
* sends the response back through the channel port.
7+
*
8+
* This SW is challenge-agnostic — each challenge registers its own handlers.
9+
*/
10+
11+
self.addEventListener('fetch', (event) => {
12+
const url = new URL(event.request.url);
13+
if (url.pathname.startsWith('/api/')) {
14+
event.respondWith(handleMockRequest(event));
15+
}
16+
});
17+
18+
async function handleMockRequest(event) {
19+
const client = await self.clients.get(event.clientId);
20+
if (!client) {
21+
return new Response(JSON.stringify({ error: 'No client found' }), {
22+
status: 500,
23+
headers: { 'Content-Type': 'application/json' },
24+
});
25+
}
26+
27+
let body = null;
28+
try {
29+
const text = await event.request.text();
30+
if (text) body = JSON.parse(text);
31+
} catch (_) {
32+
// No body or non-JSON body
33+
}
34+
35+
const { port1, port2 } = new MessageChannel();
36+
37+
return new Promise((resolve) => {
38+
port1.onmessage = (e) => {
39+
resolve(
40+
new Response(JSON.stringify(e.data.body), {
41+
status: e.data.status || 200,
42+
headers: { 'Content-Type': 'application/json' },
43+
})
44+
);
45+
};
46+
47+
const reqUrl = new URL(event.request.url);
48+
client.postMessage(
49+
{
50+
type: 'mock-api-request',
51+
url: reqUrl.pathname + reqUrl.search,
52+
method: event.request.method,
53+
body: body,
54+
},
55+
[port2]
56+
);
57+
});
58+
}
59+
60+
// Activate immediately and claim all clients so the SW is ready without a reload
61+
self.addEventListener('install', () => self.skipWaiting());
62+
self.addEventListener('activate', (event) => {
63+
event.waitUntil(self.clients.claim());
64+
});
65+
66+
// Allow the main thread to re-trigger claim (e.g. after unregister + re-register
67+
// where activate doesn't fire again because the SW script hasn't changed).
68+
self.addEventListener('message', (event) => {
69+
if (event.data?.type === 'claim') {
70+
self.clients.claim();
71+
}
72+
});

src/components/InterviewShell.tsx

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,30 @@
1-
import React, { useState, useEffect, useRef } from "react";
2-
import { InterviewPattern } from "../interviews/types";
1+
import React, {useEffect, useRef, useState} from "react";
2+
import {InterviewPattern} from "@/interviews/types";
3+
import {setupRoutes} from "@/server";
34
import Instructions from "./Instructions";
45
import CodingChallengeWrapper from "./CodingChallengeWrapper";
56
import CodeReviewInterface from "./CodeReviewInterface";
67
import "./InterviewShell.css";
7-
import { Button } from "./ui/button";
8-
import { ArrowLeftIcon } from "lucide-react";
9-
import { ThemeSwitcher } from "./theme-switcher";
10-
import { Badge } from "./ui/badge";
8+
import {Button} from "./ui/button";
9+
import {ThemeSwitcher} from "./theme-switcher";
10+
import {Badge} from "./ui/badge";
1111
import {
1212
Breadcrumb,
13-
BreadcrumbList,
1413
BreadcrumbItem,
1514
BreadcrumbLink,
16-
BreadcrumbSeparator,
15+
BreadcrumbList,
1716
BreadcrumbPage,
17+
BreadcrumbSeparator,
1818
} from "@/components/ui/breadcrumb";
1919
import {
2020
Sidebar,
2121
SidebarContent,
22-
SidebarGroup,
23-
SidebarGroupLabel,
24-
SidebarGroupContent,
25-
SidebarMenu,
26-
SidebarMenuItem,
27-
SidebarMenuButton,
28-
SidebarProvider,
2922
SidebarInset,
23+
SidebarProvider,
3024
SidebarTrigger,
3125
useSidebar,
3226
} from "@/components/ui/sidebar";
33-
import { Separator } from "./ui/separator";
27+
import {Separator} from "./ui/separator";
3428

3529
interface InterviewShellProps {
3630
pattern: InterviewPattern;
@@ -108,6 +102,15 @@ const SIDEBAR_OPEN_KEY_PREFIX = "sidebar-open-";
108102
const InterviewShell: React.FC<InterviewShellProps> = ({ pattern, onBack }) => {
109103
const [showInstructions, setShowInstructions] = useState(false);
110104
const [hasViewedInstructions, setHasViewedInstructions] = useState(false);
105+
const [serverReady, setServerReady] = useState(!pattern.routes?.length);
106+
107+
// Initialize mock API server if the pattern declares routes
108+
useEffect(() => {
109+
if (pattern.routes?.length) {
110+
setServerReady(false);
111+
setupRoutes(pattern.routes).then(() => setServerReady(true));
112+
}
113+
}, [pattern]);
111114
const [sidebarWidth, setSidebarWidth] = useState(640); // 40rem in pixels
112115
const [isDragging, setIsDragging] = useState(false);
113116
const sidebarRef = useRef<HTMLDivElement>(null);
@@ -252,7 +255,11 @@ const InterviewShell: React.FC<InterviewShellProps> = ({ pattern, onBack }) => {
252255
</div>
253256
</header>
254257
<main className="bg-white flex-1 overflow-y-auto">
255-
{showInstructions && pattern.readmes ? (
258+
{!serverReady ? (
259+
<div className="flex items-center justify-center h-full text-muted-foreground">
260+
Starting server...
261+
</div>
262+
) : showInstructions && pattern.readmes ? (
256263
<Instructions
257264
readmes={pattern.readmes}
258265
onClose={() => setShowInstructions(false)}

src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import ReactDOM from 'react-dom/client';
33
import App from './App';
44
import './index.css';
5+
import './server'; // Register SW eagerly at startup
56

67
const root = ReactDOM.createRoot(
78
document.getElementById('root') as Element

src/interviews/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ export interface ImplementationDetails {
99
testCases?: string;
1010
}
1111

12+
export interface ApiRequest {
13+
params: Record<string, string>;
14+
query: Record<string, string>;
15+
body: unknown;
16+
}
17+
18+
export interface ApiRoute {
19+
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
20+
path: string;
21+
handler: (req: ApiRequest) => Promise<unknown>;
22+
}
23+
1224
export interface InterviewPattern {
1325
id: string;
1426
name: string;
@@ -21,6 +33,7 @@ export interface InterviewPattern {
2133
component?: React.ComponentType; // Optional for code-review type
2234
type?: 'react' | 'coding-challenge' | 'code-review'; // Added code-review type
2335
implementationDetails?: ImplementationDetails;
36+
routes?: ApiRoute[];
2437
}
2538

2639
export interface InterviewConfig {

src/server/concurrency.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export function createPool(maxConcurrency: number) {
2+
let active = 0;
3+
const queue: (() => void)[] = [];
4+
5+
function acquire(): Promise<void> {
6+
if (active < maxConcurrency) {
7+
active++;
8+
return Promise.resolve();
9+
}
10+
return new Promise((resolve) =>
11+
queue.push(() => {
12+
active++;
13+
resolve();
14+
})
15+
);
16+
}
17+
18+
function release(): void {
19+
active--;
20+
const next = queue.shift();
21+
if (next) next();
22+
}
23+
24+
return { acquire, release };
25+
}

src/server/index.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { ApiRoute } from "@/interviews/types";
2+
import { createPool } from "@/server/concurrency";
3+
import { compilePath, matchRoute, type CompiledRoute } from "@/server/router";
4+
import { ensureController } from "@/server/serviceWorkerController";
5+
6+
let compiledRoutes: CompiledRoute[] = [];
7+
8+
const pool = createPool(5);
9+
10+
function mockDelay(): Promise<void> {
11+
const ms = 300 + (Math.random() * 400 - 200); // 100–500ms
12+
return new Promise((resolve) => setTimeout(resolve, ms));
13+
}
14+
15+
/**
16+
* Registers the Service Worker and message listener.
17+
* Call once at app startup — resolves when the SW is controlling the page.
18+
*/
19+
export const initServer: Promise<void> = (async () => {
20+
if (!("serviceWorker" in navigator)) {
21+
console.warn("[codeflow] Service Workers not supported. Server will not work");
22+
return;
23+
}
24+
25+
await ensureController();
26+
console.log("[codeflow] Server started");
27+
28+
navigator.serviceWorker.addEventListener("message", async (event) => {
29+
if (event.data?.type !== "mock-api-request") return;
30+
31+
const { method, url, body } = event.data;
32+
const parsed = new URL(url, location.origin);
33+
const matched = matchRoute(compiledRoutes, method, parsed.pathname);
34+
35+
if (matched) {
36+
await pool.acquire();
37+
try {
38+
await mockDelay();
39+
const result = await matched.handler({
40+
params: matched.params,
41+
query: Object.fromEntries(parsed.searchParams),
42+
body,
43+
});
44+
event.ports[0].postMessage({ status: 200, body: result });
45+
} catch (err) {
46+
event.ports[0].postMessage({
47+
status: 500,
48+
body: { error: String(err) },
49+
});
50+
} finally {
51+
pool.release();
52+
}
53+
} else {
54+
event.ports[0].postMessage({
55+
status: 404,
56+
body: { error: `No handler for ${method} ${url}` },
57+
});
58+
}
59+
});
60+
})();
61+
62+
/**
63+
* Replaces the active set of mock API routes.
64+
* Waits for the SW to be ready before resolving.
65+
*/
66+
export async function setupRoutes(apiRoutes: ApiRoute[]): Promise<void> {
67+
await initServer;
68+
compiledRoutes = apiRoutes.map((route) => ({
69+
method: route.method,
70+
regex: compilePath(route.path),
71+
handler: route.handler,
72+
}));
73+
}

src/server/router.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { ApiRequest } from "@/interviews/types";
2+
3+
export interface CompiledRoute {
4+
method: string;
5+
regex: RegExp;
6+
handler: (req: ApiRequest) => Promise<unknown>;
7+
}
8+
9+
function escapeRegex(str: string): string {
10+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11+
}
12+
13+
export function compilePath(path: string): RegExp {
14+
const pattern = path
15+
.split("/")
16+
.map((seg) =>
17+
seg.startsWith(":") ? `(?<${seg.slice(1)}>[^/]+)` : escapeRegex(seg)
18+
)
19+
.join("/");
20+
return new RegExp(`^${pattern}$`);
21+
}
22+
23+
export function matchRoute(
24+
routes: CompiledRoute[],
25+
method: string,
26+
pathname: string
27+
): { handler: CompiledRoute["handler"]; params: Record<string, string> } | null {
28+
for (const route of routes) {
29+
if (route.method !== method) continue;
30+
const match = route.regex.exec(pathname);
31+
if (match) {
32+
return { handler: route.handler, params: match.groups ?? {} };
33+
}
34+
}
35+
return null;
36+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Registers the SW and ensures it controls this page.
3+
* If an existing-but-unregistered SW fails to claim within a short window,
4+
* it unregisters the stale registration and retries from scratch.
5+
*/
6+
export async function ensureController(): Promise<void> {
7+
if (navigator.serviceWorker.controller) return;
8+
9+
const controllerChanged = new Promise<void>((resolve) => {
10+
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), {
11+
once: true,
12+
});
13+
});
14+
15+
const registration = await navigator.serviceWorker.register("/sw.js");
16+
17+
// If the SW is already active but not controlling (e.g. after unregister +
18+
// re-register where activate doesn't re-fire), ask it to claim explicitly.
19+
if (registration.active && !navigator.serviceWorker.controller) {
20+
registration.active.postMessage({ type: "claim" });
21+
}
22+
23+
// If the controller does not connect in 500ms, SW is likely stale. Unregister and retry.
24+
const result = await Promise.race([
25+
controllerChanged.then(() => "ok" as const),
26+
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 500))
27+
]);
28+
29+
if (result === "timeout") {
30+
console.warn("[codeflow] Server initialization failed. Retrying...");
31+
await registration.unregister();
32+
return ensureController();
33+
}
34+
}

0 commit comments

Comments
 (0)