Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
72 changes: 72 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
41 changes: 24 additions & 17 deletions src/components/InterviewShell.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -108,6 +102,15 @@ const SIDEBAR_OPEN_KEY_PREFIX = "sidebar-open-";
const InterviewShell: React.FC<InterviewShellProps> = ({ 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<HTMLDivElement>(null);
Expand Down Expand Up @@ -252,7 +255,11 @@ const InterviewShell: React.FC<InterviewShellProps> = ({ pattern, onBack }) => {
</div>
</header>
<main className="bg-white flex-1 overflow-y-auto">
{showInstructions && pattern.readmes ? (
{!serverReady ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
Starting server...
</div>
) : showInstructions && pattern.readmes ? (
<Instructions
readmes={pattern.readmes}
onClose={() => setShowInstructions(false)}
Expand Down
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion src/interviews/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}
}
]
};
```
Expand Down Expand Up @@ -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
- **`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.
13 changes: 13 additions & 0 deletions src/interviews/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ export interface ImplementationDetails {
testCases?: string;
}

export interface ApiRequest {
params: Record<string, string>;
query: Record<string, string>;
body: unknown;
}

export interface ApiRoute {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
handler: (req: ApiRequest) => Promise<unknown>;
}

export interface InterviewPattern {
id: string;
name: string;
Expand All @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions src/server/concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export function createPool(maxConcurrency: number) {
let active = 0;
const queue: (() => void)[] = [];

function acquire(): Promise<void> {
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 };
}
73 changes: 73 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> = (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<void> {
await initServer;
compiledRoutes = apiRoutes.map((route) => ({
method: route.method,
regex: compilePath(route.path),
handler: route.handler,
}));
}
36 changes: 36 additions & 0 deletions src/server/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ApiRequest } from "@/interviews/types";

export interface CompiledRoute {
method: string;
regex: RegExp;
handler: (req: ApiRequest) => Promise<unknown>;
}

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<string, string> } | 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;
}
Loading