Skip to content
Cloudflare Docs
非官方翻译 - 此文档为非官方中文翻译版本,仅供参考。如有疑问请以 英文官方文档 为准。

使用 Workers AI 构建面试练习工具

Last reviewed: 7 months ago

Developer Spotlight community contribution

Written by: Vasyl

Profile: GitHub

求职面试可能会让人感到压力,而练习是建立信心的关键。虽然与朋友或导师进行的传统模拟面试很有价值,但并非总能在需要时获得。在本教程中,您将学习如何构建一个由 AI 驱动的面试练习工具,该工具可提供实时反馈以帮助提高面试技巧。

在本教程结束时,您将构建一个完整的面试练习工具,具有以下核心功能:

  • 使用 WebSocket 连接的实时面试模拟工具
  • 将音频转换为文本的 AI 驱动的语音处理管道
  • 提供类似面试官互动的智能响应系统
  • 使用 Durable Objects 管理面试会话和历史记录的持久存储系统

Before you start

All of the tutorials assume you have already completed the Get started guide, which gets you set up with a Cloudflare Workers account, C3, and Wrangler.

  1. Sign up for a Cloudflare account.
  2. Install Node.js.

Node.js version manager

Use a Node version manager like Volta or nvm to avoid permission issues and change Node.js versions. Wrangler, discussed later in this guide, requires a Node version of 16.17.0 or later.

先决条件

本教程演示了如何使用多个 Cloudflare 产品,虽然许多功能在免费套餐中可用,但 Workers AI 的某些组件可能会产生基于使用量的费用。在继续之前,请查看 Workers AI 的定价文档。

1. 创建一个新的 Worker 项目

使用 Create Cloudflare CLI (C3) 工具和 Hono 框架创建一个 Cloudflare Workers 项目。

通过运行以下命令创建一个新的 Worker 项目,使用 ai-interview-tool 作为 Worker 名称:

Terminal window
npm create cloudflare@latest -- ai-interview-tool

For setup, select the following options:

  • For What would you like to start with?, choose Framework Starter.
  • For Which development framework do you want to use?, choose Hono.
  • Complete the framework's own CLI wizard.
  • For Do you want to use git for version control?, choose Yes.
  • For Do you want to deploy your application?, choose No (we will be making some changes before deploying).

要在本地开发和测试您的 Cloudflare Workers 应用程序:

  1. 在您的终端中导航到您的 Workers 项目目录:
Terminal window
cd ai-interview-tool
  1. 通过运行以下命令启动开发服务器:
Terminal window
npx wrangler dev

当您运行 wrangler dev 时,该命令会启动一个本地开发服务器,并提供一个 localhost URL,您可以在其中预览您的应用程序。 您现在可以对代码进行更改,并在提供的 localhost 地址上实时查看它们。

2. 为面试系统定义 TypeScript 类型

项目设置好后,创建将构成面试系统基础的 TypeScript 类型。这些类型将帮助您维护类型安全,并为应用程序的不同组件提供清晰的接口。

创建一个新的 types.ts 文件,其中将包含以下内容的基本类型和枚举:

  • 可以评估的面试技能(JavaScript、React 等)
  • 不同的面试职位(初级开发人员、高级开发人员等)
  • 面试状态跟踪
  • 用户与 AI 之间的消息处理
  • 核心面试数据结构
src/types.ts
import { Context } from "hono";
// API 端点的上下文类型,包括环境绑定和用户信息
export interface ApiContext {
Bindings: CloudflareBindings;
Variables: {
username: string;
};
}
export type HonoCtx = Context<ApiContext>;
// 您可以在模拟面试期间评估的技术技能列表。
// 此应用程序侧重于在真实面试中通常测试的流行 Web 技术和编程语言。
export enum InterviewSkill {
JavaScript = "JavaScript",
TypeScript = "TypeScript",
React = "React",
NodeJS = "NodeJS",
Python = "Python",
}
// 基于不同工程职位的可用面试类型。
// 这有助于根据候选人的目标职位定制面试体验和问题。
export enum InterviewTitle {
JuniorDeveloper = "初级开发人员面试",
SeniorDeveloper = "高级开发人员面试",
FullStackDeveloper = "全栈开发人员面试",
FrontendDeveloper = "前端开发人员面试",
BackendDeveloper = "后端开发人员面试",
SystemArchitect = "系统架构师面试",
TechnicalLead = "技术主管面试",
}
// 跟踪面试会话的当前状态。
// 这将帮助您管理面试流程,并在流程的每个阶段显示适当的 UI/操作。
export enum InterviewStatus {
Created = "created", // 面试已创建但未开始
Pending = "pending", // 等待面试官/系统
InProgress = "in_progress", // 进行中的面试会话
Completed = "completed", // 面试成功完成
Cancelled = "cancelled", // 面试提前终止
}
// 定义在面试聊天中发送消息的人
export type MessageRole = "user" | "assistant" | "system";
// 面试期间交换的单个消息的结构
export interface Message {
messageId: string; // 消息的唯一标识符
interviewId: string; // 将消息链接到特定面试
role: MessageRole; // 谁发送了消息
content: string; // 实际消息内容
timestamp: number; // 消息发送时间
}
// 保存有关面试会话的所有信息的主要数据结构。
// 这包括元数据、交换的消息和当前状态。
export interface InterviewData {
interviewId: string;
title: InterviewTitle;
skills: InterviewSkill[];
messages: Message[];
status: InterviewStatus;
createdAt: number;
updatedAt: number;
}
// 创建新面试会话的输入格式。
// 简化接口,接受开始面试所需的基本参数。
export interface InterviewInput {
title: string;
skills: string[];
}

3. 为不同服务配置错误类型

接下来,设置自定义错误类型以处理应用程序中可能发生的不同类型的错误。这包括:

  • 数据库错误(例如,连接问题、查询失败)
  • 与面试相关的错误(例如,无效输入、转录失败)
  • 身份验证错误(例如,无效会话)

创建以下 errors.ts 文件:

src/errors.ts
export const ErrorCodes = {
INVALID_MESSAGE: "INVALID_MESSAGE",
TRANSCRIPTION_FAILED: "TRANSCRIPTION_FAILED",
LLM_FAILED: "LLM_FAILED",
DATABASE_ERROR: "DATABASE_ERROR",
} as const;
export class AppError extends Error {
constructor(
message: string,
public statusCode: number,
) {
super(message);
this.name = this.constructor.name;
}
}
export class UnauthorizedError extends AppError {
constructor(message: string) {
super(message, 401);
}
}
export class BadRequestError extends AppError {
constructor(message: string) {
super(message, 400);
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, 404);
}
}
export class InterviewError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500,
) {
super(message);
this.name = "InterviewError";
}
}

4. 配置身份验证中间件和用户路由

在此步骤中,您将实现一个基本的身份验证系统,以跟踪和识别与您的 AI 面试练习工具交互的用户。该系统使用仅 HTTP 的 cookie 来存储用户名,使您能够识别请求发送者及其相应的 Durable Object。这种直接的身份验证方法要求用户提供一个用户名,然后将其安全地存储在 cookie 中。这种方法使您能够:

  • 跨请求识别用户
  • 将面试会话与特定用户关联
  • 保护对与面试相关的端点的访问

创建身份验证中间件

创建一个中间件函数,用于检查是否存在有效的身份验证 cookie。此中间件将用于保护需要身份验证的路由。

创建一个新的中间件文件 middleware/auth.ts

src/middleware/auth.ts
import { Context } from "hono";
import { getCookie } from "hono/cookie";
import { UnauthorizedError } from "../errors";
export const requireAuth = async (ctx: Context, next: () => Promise<void>) => {
// Get username from cookie
const username = getCookie(ctx, "username");
if (!username) {
throw new UnauthorizedError("User is not logged in");
}
// Make username available to route handlers
ctx.set("username", username);
await next();
};

This middleware:

  • Checks for a username cookie
  • Throws an Error if the cookie is missing
  • Makes the username available to downstream handlers via the context

Create Authentication Routes

Next, create the authentication routes that will handle user login. Create a new file routes/auth.ts:

src/routes/auth.ts
import { Context, Hono } from "hono";
import { setCookie } from "hono/cookie";
import { BadRequestError } from "../errors";
import { ApiContext } from "../types";
export const authenticateUser = async (ctx: Context) => {
// Extract username from request body
const { username } = await ctx.req.json();
// Make sure username was provided
if (!username) {
throw new BadRequestError("Username is required");
}
// Create a secure cookie to track the user's session
// This cookie will:
// - Be HTTP-only for security (no JS access)
// - Work across all routes via path="/"
// - Last for 24 hours
// - Only be sent in same-site requests to prevent CSRF
setCookie(ctx, "username", username, {
httpOnly: true,
path: "/",
maxAge: 60 * 60 * 24,
sameSite: "Strict",
});
// Let the client know login was successful
return ctx.json({ success: true });
};
// Set up authentication-related routes
export const configureAuthRoutes = () => {
const router = new Hono<ApiContext>();
// POST /login - Authenticate user and create session
router.post("/login", authenticateUser);
return router;
};

Finally, update main application file to include the authentication routes. Modify src/index.ts:

src/index.ts
import { configureAuthRoutes } from "./routes/auth";
import { Hono } from "hono";
import { logger } from "hono/logger";
import type { ApiContext } from "./types";
import { requireAuth } from "./middleware/auth";
// Create our main Hono app instance with proper typing
const app = new Hono<ApiContext>();
// Create a separate router for API endpoints to keep things organized
const api = new Hono<ApiContext>();
// Set up global middleware that runs on every request
// - Logger gives us visibility into what is happening
app.use("*", logger());
// Wire up all our authentication routes (login, etc)
// These will be mounted under /api/v1/auth/
api.route("/auth", configureAuthRoutes());
// Mount all API routes under the version prefix (for example, /api/v1)
// This allows us to make breaking changes in v2 without affecting v1 users
app.route("/api/v1", api);
export default app;

Now we have a basic authentication system that:

  1. Provides a login endpoint at /api/v1/auth/login
  2. Securely stores the username in a cookie
  3. Includes middleware to protect authenticated routes

5. Create a Durable Object to manage interviews

Now that you have your authentication system in place, create a Durable Object to manage interview sessions. Durable Objects are perfect for this interview practice tool because they provide the following functionalities:

  • Maintains states between connections, so users can reconnect without losing progress.
  • Provides a SQLite database to store all interview Q&A, feedback and metrics.
  • Enables smooth real-time interactions between the interviewer AI and candidate.
  • Handles multiple interview sessions efficiently without performance issues.
  • Creates a dedicated instance for each user, giving them their own isolated environment.

First, you will need to configure the Durable Object in Wrangler file. Add the following configuration:

wrangler.toml
[[durable_objects.bindings]]
name = "INTERVIEW"
class_name = "Interview"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Interview"]

Next, create a new file interview.ts to define our Interview Durable Object:

src/interview.ts
import { DurableObject } from "cloudflare:workers";
export class Interview extends DurableObject<CloudflareBindings> {
// We will use it to keep track of all active WebSocket connections for real-time communication
private sessions: Map<WebSocket, { interviewId: string }>;
constructor(state: DurableObjectState, env: CloudflareBindings) {
super(state, env);
// Initialize empty sessions map - we will add WebSocket connections as users join
this.sessions = new Map();
}
// Entry point for all HTTP requests to this Durable Object
// This will handle both initial setup and WebSocket upgrades
async fetch(request: Request) {
// For now, just confirm the object is working
// We'll add WebSocket upgrade logic and request routing later
return new Response("Interview object initialized");
}
// Broadcasts a message to all connected WebSocket clients.
private broadcast(message: string) {
this.ctx.getWebSockets().forEach((ws) => {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
} catch (error) {
console.error(
"Error broadcasting message to a WebSocket client:",
error,
);
}
});
}
}

Now we need to export the Durable Object in our main src/index.ts file:

src/index.ts
import { Interview } from "./interview";
// ... previous code ...
export { Interview };
export default app;

Since the Worker code is written in TypeScript, you should run the following command to add the necessary type definitions:

Terminal window
npm run cf-typegen

Set up SQLite database schema to store interview data

Now you will use SQLite at the Durable Object level for data persistence. This gives each user their own isolated database instance. You will need two main tables:

  • interviews: Stores interview session data
  • messages: Stores all messages exchanged during interviews

Before you create these tables, create a service class to handle your database operations. This encapsulates database logic and helps you:

  • Manage database schema changes
  • Handle errors consistently
  • Keep database queries organized

Create a new file called services/InterviewDatabaseService.ts:

src/services/InterviewDatabaseService.ts
import {
InterviewData,
Message,
InterviewStatus,
InterviewTitle,
InterviewSkill,
} from "../types";
import { InterviewError, ErrorCodes } from "../errors";
const CONFIG = {
database: {
tables: {
interviews: "interviews",
messages: "messages",
},
indexes: {
messagesByInterview: "idx_messages_interviewId",
},
},
} as const;
export class InterviewDatabaseService {
constructor(private sql: SqlStorage) {}
/**
* Sets up the database schema by creating tables and indexes if they do not exist.
* This is called when initializing a new Durable Object instance to ensure
* we have the required database structure.
*
* The schema consists of:
* - interviews table: Stores interview metadata like title, skills, and status
* - messages table: Stores the conversation history between user and AI
* - messages index: Helps optimize queries when fetching messages for a specific interview
*/
createTables() {
try {
// Get list of existing tables to avoid recreating them
const cursor = this.sql.exec(`PRAGMA table_list`);
const existingTables = new Set([...cursor].map((table) => table.name));
// The interviews table is our main table storing interview sessions.
// We only create it if it does not exist yet.
if (!existingTables.has(CONFIG.database.tables.interviews)) {
this.sql.exec(InterviewDatabaseService.QUERIES.CREATE_INTERVIEWS_TABLE);
}
// The messages table stores the actual conversation history.
// It references interviews table via foreign key for data integrity.
if (!existingTables.has(CONFIG.database.tables.messages)) {
this.sql.exec(InterviewDatabaseService.QUERIES.CREATE_MESSAGES_TABLE);
}
// Add an index on interviewId to speed up message retrieval.
// This is important since we will frequently query messages by interview.
this.sql.exec(InterviewDatabaseService.QUERIES.CREATE_MESSAGE_INDEX);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new InterviewError(
`Failed to initialize database: ${message}`,
ErrorCodes.DATABASE_ERROR,
);
}
}
private static readonly QUERIES = {
CREATE_INTERVIEWS_TABLE: `
CREATE TABLE IF NOT EXISTS interviews (
interviewId TEXT PRIMARY KEY,
title TEXT NOT NULL,
skills TEXT NOT NULL,
createdAt INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000),
updatedAt INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000),
status TEXT NOT NULL DEFAULT 'pending'
)
`,
CREATE_MESSAGES_TABLE: `
CREATE TABLE IF NOT EXISTS messages (
messageId TEXT PRIMARY KEY,
interviewId TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
FOREIGN KEY (interviewId) REFERENCES interviews(interviewId)
)
`,
CREATE_MESSAGE_INDEX: `
CREATE INDEX IF NOT EXISTS idx_messages_interview ON messages(interviewId)
`,
};
}

Update the Interview Durable Object to use the database service by modifying src/interview.ts:

src/interview.ts
import { InterviewDatabaseService } from "./services/InterviewDatabaseService";
export class Interview extends DurableObject<CloudflareBindings> {
// Database service for persistent storage of interview data and messages
private readonly db: InterviewDatabaseService;
private sessions: Map<WebSocket, { interviewId: string }>;
constructor(state: DurableObjectState, env: CloudflareBindings) {
// ... previous code ...
// Set up our database connection using the DO's built-in SQLite instance
this.db = new InterviewDatabaseService(state.storage.sql);
// First-time setup: ensure our database tables exist
// This is idempotent so safe to call on every instantiation
this.db.createTables();
}
}

Add methods to create and retrieve interviews in services/InterviewDatabaseService.ts:

src/services/InterviewDatabaseService.ts
export class InterviewDatabaseService {
/**
* Creates a new interview session in the database.
*
* This is the main entry point for starting a new interview. It handles all the
* initial setup like:
* - Generating a unique ID using crypto.randomUUID() for reliable uniqueness
* - Recording the interview title and required skills
* - Setting up timestamps for tracking interview lifecycle
* - Setting the initial status to "Created"
*
*/
createInterview(title: InterviewTitle, skills: InterviewSkill[]): string {
try {
const interviewId = crypto.randomUUID();
const currentTime = Date.now();
this.sql.exec(
InterviewDatabaseService.QUERIES.INSERT_INTERVIEW,
interviewId,
title,
JSON.stringify(skills), // Store skills as JSON for flexibility
InterviewStatus.Created,
currentTime,
currentTime,
);
return interviewId;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new InterviewError(
`Failed to create interview: ${message}`,
ErrorCodes.DATABASE_ERROR,
);
}
}
/**
* Fetches all interviews from the database, ordered by creation date.
*
* This is useful for displaying interview history and letting users
* resume previous sessions. We order by descending creation date since
* users typically want to see their most recent interviews first.
*
* Returns an array of InterviewData objects with full interview details
* including metadata and message history.
*/
getAllInterviews(): InterviewData[] {
try {
const cursor = this.sql.exec(
InterviewDatabaseService.QUERIES.GET_ALL_INTERVIEWS,
);
return [...cursor].map(this.parseInterviewRecord);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new InterviewError(
`Failed to retrieve interviews: ${message}`,
ErrorCodes.DATABASE_ERROR,
);
}
}
// Retrieves an interview and its messages by ID
getInterview(interviewId: string): InterviewData | null {
try {
const cursor = this.sql.exec(
InterviewDatabaseService.QUERIES.GET_INTERVIEW,
interviewId,
);
const record = [...cursor][0];
if (!record) return null;
return this.parseInterviewRecord(record);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new InterviewError(
`Failed to retrieve interview: ${message}`,
ErrorCodes.DATABASE_ERROR,
);
}
}
addMessage(
interviewId: string,
role: Message["role"],
content: string,
messageId: string,
): Message {
try {
const timestamp = Date.now();
this.sql.exec(
InterviewDatabaseService.QUERIES.INSERT_MESSAGE,
messageId,
interviewId,
role,
content,
timestamp,
);
return {
messageId,
interviewId,
role,
content,
timestamp,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new InterviewError(
`Failed to add message: ${message}`,
ErrorCodes.DATABASE_ERROR,
);
}
}
/**
* Transforms raw database records into structured InterviewData objects.
*
* This helper does the heavy lifting of:
* - Type checking critical fields to catch database corruption early
* - Converting stored JSON strings back into proper objects
* - Filtering out any null messages that might have snuck in
* - Ensuring timestamps are proper numbers
*
* If any required data is missing or malformed, it throws an error
* rather than returning partially valid data that could cause issues
* downstream.
*/
private parseInterviewRecord(record: any): InterviewData {
const interviewId = record.interviewId as string;
const createdAt = Number(record.createdAt);
const updatedAt = Number(record.updatedAt);
if (!interviewId || !createdAt || !updatedAt) {
throw new InterviewError(
"Invalid interview data in database",
ErrorCodes.DATABASE_ERROR,
);
}
return {
interviewId,
title: record.title as InterviewTitle,
skills: JSON.parse(record.skills as string) as InterviewSkill[],
messages: record.messages
? JSON.parse(record.messages)
.filter((m: any) => m !== null)
.map((m: any) => ({
messageId: m.messageId,
role: m.role,
content: m.content,
timestamp: m.timestamp,
}))
: [],
status: record.status as InterviewStatus,
createdAt,
updatedAt,
};
}
// Add these SQL queries to the QUERIES object
private static readonly QUERIES = {
// ... previous queries ...
INSERT_INTERVIEW: `
INSERT INTO ${CONFIG.database.tables.interviews}
(interviewId, title, skills, status, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?)
`,
GET_ALL_INTERVIEWS: `
SELECT
interviewId,
title,
skills,
createdAt,
updatedAt,
status
FROM ${CONFIG.database.tables.interviews}
ORDER BY createdAt DESC
`,
INSERT_MESSAGE: `
INSERT INTO ${CONFIG.database.tables.messages}
(messageId, interviewId, role, content, timestamp)
VALUES (?, ?, ?, ?, ?)
`,
GET_INTERVIEW: `
SELECT
i.interviewId,
i.title,
i.skills,
i.status,
i.createdAt,
i.updatedAt,
COALESCE(
json_group_array(
CASE WHEN m.messageId IS NOT NULL THEN
json_object(
'messageId', m.messageId,
'role', m.role,
'content', m.content,
'timestamp', m.timestamp
)
END
),
'[]'
) as messages
FROM ${CONFIG.database.tables.interviews} i
LEFT JOIN ${CONFIG.database.tables.messages} m ON i.interviewId = m.interviewId
WHERE i.interviewId = ?
GROUP BY i.interviewId
`,
};
}

Add RPC methods to the Interview Durable Object to expose database operations through API. Add this code to src/interview.ts:

src/interview.ts
import {
InterviewData,
InterviewTitle,
InterviewSkill,
Message,
} from "./types";
export class Interview extends DurableObject<CloudflareBindings> {
// Creates a new interview session
createInterview(title: InterviewTitle, skills: InterviewSkill[]): string {
return this.db.createInterview(title, skills);
}
// Retrieves all interview sessions
getAllInterviews(): InterviewData[] {
return this.db.getAllInterviews();
}
// Adds a new message to the 'messages' table and broadcasts it to all connected WebSocket clients.
addMessage(
interviewId: string,
role: "user" | "assistant",
content: string,
messageId: string,
): Message {
const newMessage = this.db.addMessage(
interviewId,
role,
content,
messageId,
);
this.broadcast(
JSON.stringify({
...newMessage,
type: "message",
}),
);
return newMessage;
}
}

6. Create REST API endpoints

With your Durable Object and database service ready, create REST API endpoints to manage interviews. You will need endpoints to:

  • Create new interviews
  • Retrieve all interviews for a user

Create a new file for your interview routes at routes/interview.ts:

src/routes/interview.ts
import { Hono } from "hono";
import { BadRequestError } from "../errors";
import {
InterviewInput,
ApiContext,
HonoCtx,
InterviewTitle,
InterviewSkill,
} from "../types";
import { requireAuth } from "../middleware/auth";
/**
* Gets the Interview Durable Object instance for a given user.
* We use the username as a stable identifier to ensure each user
* gets their own dedicated DO instance that persists across requests.
*/
const getInterviewDO = (ctx: HonoCtx) => {
const username = ctx.get("username");
const id = ctx.env.INTERVIEW.idFromName(username);
return ctx.env.INTERVIEW.get(id);
};
/**
* Validates the interview creation payload.
* Makes sure we have all required fields in the correct format:
* - title must be present
* - skills must be a non-empty array
* Throws an error if validation fails.
*/
const validateInterviewInput = (input: InterviewInput) => {
if (
!input.title ||
!input.skills ||
!Array.isArray(input.skills) ||
input.skills.length === 0
) {
throw new BadRequestError("Invalid input");
}
};
/**
* GET /interviews
* Retrieves all interviews for the authenticated user.
* The interviews are stored and managed by the user's DO instance.
*/
const getAllInterviews = async (ctx: HonoCtx) => {
const interviewDO = getInterviewDO(ctx);
const interviews = await interviewDO.getAllInterviews();
return ctx.json(interviews);
};
/**
* POST /interviews
* Creates a new interview session with the specified title and skills.
* Each interview gets a unique ID that can be used to reference it later.
* Returns the newly created interview ID on success.
*/
const createInterview = async (ctx: HonoCtx) => {
const body = await ctx.req.json<InterviewInput>();
validateInterviewInput(body);
const interviewDO = getInterviewDO(ctx);
const interviewId = await interviewDO.createInterview(
body.title as InterviewTitle,
body.skills as InterviewSkill[],
);
return ctx.json({ success: true, interviewId });
};
/**
* Sets up all interview-related routes.
* Currently supports:
* - GET / : List all interviews
* - POST / : Create a new interview
*/
export const configureInterviewRoutes = () => {
const router = new Hono<ApiContext>();
router.use("*", requireAuth);
router.get("/", getAllInterviews);
router.post("/", createInterview);
return router;
};

The getInterviewDO helper function uses the username from our authentication cookie to create a unique Durable Object ID. This ensures each user has their own isolated interview state.

Update your main application file to include the routes and protect them with authentication middleware. Update src/index.ts:

src/index.ts
import { configureAuthRoutes } from "./routes/auth";
import { configureInterviewRoutes } from "./routes/interview";
import { Hono } from "hono";
import { Interview } from "./interview";
import { logger } from "hono/logger";
import type { ApiContext } from "./types";
const app = new Hono<ApiContext>();
const api = new Hono<ApiContext>();
app.use("*", logger());
api.route("/auth", configureAuthRoutes());
api.route("/interviews", configureInterviewRoutes());
app.route("/api/v1", api);
export { Interview };
export default app;

Now you have two new API endpoints:

  • POST /api/v1/interviews: Creates a new interview session
  • GET /api/v1/interviews: Retrieves all interviews for the authenticated user

You can test these endpoints running the following command:

  1. Create a new interview:
Terminal window
curl -X POST http://localhost:8787/api/v1/interviews \
-H "Content-Type: application/json" \
-H "Cookie: username=testuser; HttpOnly" \
-d '{"title":"Frontend Developer Interview","skills":["JavaScript","React","CSS"]}'
  1. Get all interviews:
Terminal window
curl http://localhost:8787/api/v1/interviews \
-H "Cookie: username=testuser; HttpOnly"

7. Set up WebSockets to handle real-time communication

With the basic interview management system in place, you will now implement Durable Objects to handle real-time message processing and maintain WebSocket connections.

Update the Interview Durable Object to handle WebSocket connections by adding the following code to src/interview.ts:

src/interview.ts
export class Interview extends DurableObject<CloudflareBindings> {
// Services for database operations and managing WebSocket sessions
private readonly db: InterviewDatabaseService;
private sessions: Map<WebSocket, { interviewId: string }>;
constructor(state: DurableObjectState, env: CloudflareBindings) {
// ... previous code ...
// Keep WebSocket connections alive by automatically responding to pings
// This prevents timeouts and connection drops
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong"),
);
}
async fetch(request: Request): Promise<Response> {
// Check if this is a WebSocket upgrade request
const upgradeHeader = request.headers.get("Upgrade");
if (upgradeHeader?.toLowerCase().includes("websocket")) {
return this.handleWebSocketUpgrade(request);
}
// If it is not a WebSocket request, we don't handle it
return new Response("Not found", { status: 404 });
}
private async handleWebSocketUpgrade(request: Request): Promise<Response> {
// Extract the interview ID from the URL - it should be the last segment
const url = new URL(request.url);
const interviewId = url.pathname.split("/").pop();
if (!interviewId) {
return new Response("Missing interviewId parameter", { status: 400 });
}
// Create a new WebSocket connection pair - one for the client, one for the server
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Keep track of which interview this WebSocket is connected to
// This is important for routing messages to the right interview session
this.sessions.set(server, { interviewId });
// Tell the Durable Object to start handling this WebSocket
this.ctx.acceptWebSocket(server);
// Send the current interview state to the client right away
// This helps initialize their UI with the latest data
const interviewData = await this.db.getInterview(interviewId);
if (interviewData) {
server.send(
JSON.stringify({
type: "interview_details",
data: interviewData,
}),
);
}
// Return the client WebSocket as part of the upgrade response
return new Response(null, {
status: 101,
webSocket: client,
});
}
async webSocketClose(
ws: WebSocket,
code: number,
reason: string,
wasClean: boolean,
) {
// Clean up when a connection closes to prevent memory leaks
// This is especially important in long-running Durable Objects
console.log(
`WebSocket closed: Code ${code}, Reason: ${reason}, Clean: ${wasClean}`,
);
}
}

Next, update the interview routes to include a WebSocket endpoint. Add the following to routes/interview.ts:

src/routes/interview.ts
// ... previous code ...
const streamInterviewProcess = async (ctx: HonoCtx) => {
const interviewDO = getInterviewDO(ctx);
return await interviewDO.fetch(ctx.req.raw);
};
export const configureInterviewRoutes = () => {
const router = new Hono<ApiContext>();
router.get("/", getAllInterviews);
router.post("/", createInterview);
// Add WebSocket route
router.get("/:interviewId", streamInterviewProcess);
return router;
};

The WebSocket system provides real-time communication features for interview practice tool:

  • Each interview session gets its own dedicated WebSocket connection, allowing seamless communication between the candidate and AI interviewer
  • The Durable Object maintains the connection state, ensuring no messages are lost even if the client temporarily disconnects
  • To keep connections stable, it automatically responds to ping messages with pongs, preventing timeouts
  • Candidates and interviewers receive instant updates as the interview progresses, creating a natural conversational flow

8. Add audio processing capabilities with Workers AI

Now that WebSocket connection set up, the next step is to add speech-to-text capabilities using Workers AI. Let's use Cloudflare's Whisper model to transcribe audio in real-time during the interview.

The audio processing pipeline will work like this:

  1. Client sends audio through the WebSocket connection
  2. Our Durable Object receives the binary audio data
  3. We pass the audio to Whisper for transcription
  4. The transcribed text is saved as a new message
  5. We immediately send the transcription back to the client
  6. The client receives a notification that the AI interviewer is generating a response

Create audio processing pipeline

In this step you will update the Interview Durable Object to handle the following:

  1. Detect binary audio data sent through WebSocket
  2. Create a unique message ID for tracking the processing status
  3. Notify clients that audio processing has begun
  4. Include error handling for failed audio processing
  5. Broadcast status updates to all connected clients

First, update Interview Durable Object to handle binary WebSocket messages. Add the following methods to your src/interview.ts file:

src/interview.ts
// ... previous code ...
/**
* Handles incoming WebSocket messages, both binary audio data and text messages.
* This is the main entry point for all WebSocket communication.
*/
async webSocketMessage(ws: WebSocket, eventData: ArrayBuffer | string): Promise<void> {
try {
// Handle binary audio data from the client's microphone
if (eventData instanceof ArrayBuffer) {
await this.handleBinaryAudio(ws, eventData);
return;
}
// Text messages will be handled by other methods
} catch (error) {
this.handleWebSocketError(ws, error);
}
}
/**
* Processes binary audio data received from the client.
* Converts audio to text using Whisper and broadcasts processing status.
*/
private async handleBinaryAudio(ws: WebSocket, audioData: ArrayBuffer): Promise<void> {
try {
const uint8Array = new Uint8Array(audioData);
// Retrieve the associated interview session
const session = this.sessions.get(ws);
if (!session?.interviewId) {
throw new Error("No interview session found");
}
// Generate unique ID to track this message through the system
const messageId = crypto.randomUUID();
// Let the client know we're processing their audio
this.broadcast(
JSON.stringify({
type: "message",
status: "processing",
role: "user",
messageId,
interviewId: session.interviewId,
}),
);
// TODO: Implement Whisper transcription in next section
// For now, just log the received audio data size
console.log(`Received audio data of length: ${uint8Array.length}`);
} catch (error) {
console.error("Audio processing failed:", error);
this.handleWebSocketError(ws, error);
}
}
/**
* Handles WebSocket errors by logging them and notifying the client.
* Ensures errors are properly communicated back to the user.
*/
private handleWebSocketError(ws: WebSocket, error: unknown): void {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
console.error("WebSocket error:", errorMessage);
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: errorMessage,
}),
);
}
}

Your handleBinaryAudio method currently logs when it receives audio data. Next, you'll enhance it to transcribe speech using Workers AI's Whisper model.

Configure speech-to-text

Now that audio processing pipeline is set up, you will now integrate Workers AI's Whisper model for speech-to-text transcription.

Configure the Worker AI binding in your Wrangler file by adding:

# ... previous configuration ...
[ai]
binding = "AI"

Next, generate TypeScript types for our AI binding. Run the following command:

Terminal window
npm run cf-typegen

You will need a new service class for AI operations. Create a new file called services/AIService.ts:

src/services/AIService.ts
import { InterviewError, ErrorCodes } from "../errors";
export class AIService {
constructor(private readonly AI: Ai) {}
async transcribeAudio(audioData: Uint8Array): Promise<string> {
try {
// Call the Whisper model to transcribe the audio
const response = await this.AI.run("@cf/openai/whisper-tiny-en", {
audio: Array.from(audioData),
});
if (!response?.text) {
throw new Error("Failed to transcribe audio content.");
}
return response.text;
} catch (error) {
throw new InterviewError(
"Failed to transcribe audio content",
ErrorCodes.TRANSCRIPTION_FAILED,
);
}
}
}

You will need to update the Interview Durable Object to use this new AI service. To do this, update the handleBinaryAudio method in src/interview.ts:

src/interview.ts
import { AIService } from "./services/AIService";
export class Interview extends DurableObject<CloudflareBindings> {
private readonly aiService: AIService;
constructor(state: DurableObjectState, env: Env) {
// ... previous code ...
// Initialize the AI service with the Workers AI binding
this.aiService = new AIService(this.env.AI);
}
private async handleBinaryAudio(ws: WebSocket, audioData: ArrayBuffer): Promise<void> {
try {
const uint8Array = new Uint8Array(audioData);
const session = this.sessions.get(ws);
if (!session?.interviewId) {
throw new Error("No interview session found");
}
// Create a message ID for tracking
const messageId = crypto.randomUUID();
// Send processing state to client
this.broadcast(
JSON.stringify({
type: "message",
status: "processing",
role: "user",
messageId,
interviewId: session.interviewId,
}),
);
// NEW: Use AI service to transcribe the audio
const transcribedText = await this.aiService.transcribeAudio(uint8Array);
// Store the transcribed message
await this.addMessage(session.interviewId, "user", transcribedText, messageId);
} catch (error) {
console.error("Audio processing failed:", error);
this.handleWebSocketError(ws, error);
}
}

When users speak during the interview, their audio will be automatically transcribed and stored as messages in the interview session. The transcribed text will be immediately available to both the user and the AI interviewer for generating appropriate responses.

9. Integrate AI response generation

Now that you have audio transcription working, let's implement AI interviewer response generation using Workers AI's LLM capabilities. You'll create an interview system that:

  • Maintains context of the conversation
  • Provides relevant follow-up questions
  • Gives constructive feedback
  • Stays in character as a professional interviewer

Set up Workers AI LLM integration

First, update the AIService class to handle LLM interactions. You will need to add methods for:

  • Processing interview context
  • Generating appropriate responses
  • Handling conversation flow

Update the services/AIService.ts class to include LLM functionality:

src/services/AIService.ts
import { InterviewData, Message } from "../types";
export class AIService {
async processLLMResponse(interview: InterviewData): Promise<string> {
const messages = this.prepareLLMMessages(interview);
try {
const { response } = await this.AI.run("@cf/meta/llama-2-7b-chat-int8", {
messages,
});
if (!response) {
throw new Error("Failed to generate a response from the LLM model.");
}
return response;
} catch (error) {
throw new InterviewError("Failed to generate a response from the LLM model.", ErrorCodes.LLM_FAILED);
}
}
private prepareLLMMessages(interview: InterviewData) {
const messageHistory = interview.messages.map((msg: Message) => ({
role: msg.role,
content: msg.content,
}));
return [
{
role: "system",
content: this.createSystemPrompt(interview),
},
...messageHistory,
];
}

Create the conversation prompt

Prompt engineering is crucial for getting high-quality responses from the LLM. Next, you will create a system prompt that:

  • Sets the context for the interview
  • Defines the interviewer's role and behavior
  • Specifies the technical focus areas
  • Guides the conversation flow

Add the following method to your services/AIService.ts class:

src/services/AIService.ts
private createSystemPrompt(interview: InterviewData): string {
const basePrompt = "You are conducting a technical interview.";
const rolePrompt = `The position is for ${interview.title}.`;
const skillsPrompt = `Focus on topics related to: ${interview.skills.join(", ")}.`;
const instructionsPrompt = "Ask relevant technical questions and provide constructive feedback.";
return `${basePrompt} ${rolePrompt} ${skillsPrompt} ${instructionsPrompt}`;
}

Implement response generation logic

Finally, integrate the LLM response generation into the interview flow. Update the handleBinaryAudio method in the src/interview.ts Durable Object to:

  • Process transcribed user responses
  • Generate appropriate AI interviewer responses
  • Maintain conversation context

Update the handleBinaryAudio method in src/interview.ts:

src/interview.ts
private async handleBinaryAudio(ws: WebSocket, audioData: ArrayBuffer): Promise<void> {
try {
// Convert raw audio buffer to uint8 array for processing
const uint8Array = new Uint8Array(audioData);
const session = this.sessions.get(ws);
if (!session?.interviewId) {
throw new Error("No interview session found");
}
// Generate a unique ID to track this message through the system
const messageId = crypto.randomUUID();
// Let the client know we're processing their audio
// This helps provide immediate feedback while transcription runs
this.broadcast(
JSON.stringify({
type: "message",
status: "processing",
role: "user",
messageId,
interviewId: session.interviewId,
}),
);
// Convert the audio to text using our AI transcription service
// This typically takes 1-2 seconds for normal speech
const transcribedText = await this.aiService.transcribeAudio(uint8Array);
// Save the user's message to our database so we maintain chat history
await this.addMessage(session.interviewId, "user", transcribedText, messageId);
// Look up the full interview context - we need this to generate a good response
const interview = await this.db.getInterview(session.interviewId);
if (!interview) {
throw new Error(`Interview not found: ${session.interviewId}`);
}
// Now it's the AI's turn to respond
// First generate an ID for the assistant's message
const assistantMessageId = crypto.randomUUID();
// Let the client know we're working on the AI response
this.broadcast(
JSON.stringify({
type: "message",
status: "processing",
role: "assistant",
messageId: assistantMessageId,
interviewId: session.interviewId,
}),
);
// Generate the AI interviewer's response based on the conversation history
const llmResponse = await this.aiService.processLLMResponse(interview);
await this.addMessage(session.interviewId, "assistant", llmResponse, assistantMessageId);
} catch (error) {
// Something went wrong processing the audio or generating a response
// Log it and let the client know there was an error
console.error("Audio processing failed:", error);
this.handleWebSocketError(ws, error);
}
}

Conclusion

You have successfully built an AI-powered interview practice tool using Cloudflare's Workers AI. In summary, you have:

  • Created a real-time WebSocket communication system using Durable Objects
  • Implemented speech-to-text processing with Workers AI Whisper model
  • Built an intelligent interview system using Workers AI LLM capabilities
  • Designed a persistent storage system with SQLite in Durable Objects

The complete source code for this tutorial is available on GitHub: ai-interview-practice-tool