feat(HAI-002): complete React conversion with build fixes and project setup

- Fix Vite alias to resolve @hai/core to types-only module (avoids Node.js deps in browser bundle)
- Add tsconfig.app.json for client-side typecheck
- Fix server.ts return type annotation
- Update imports to use @hai/core (resolved via Vite alias)
- Add base project files needed for workspace
This commit is contained in:
Dustin Byrne
2026-03-25 18:29:50 -04:00
parent e0746fb387
commit 6a754cf199
31 changed files with 1534 additions and 3 deletions

View File

@@ -9,7 +9,7 @@
"build": "vite build && tsc",
"build:client": "vite build",
"dev:client": "vite",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"@hai/core": "workspace:*",

View File

@@ -0,0 +1 @@
export { createServer } from "./server.js";

View File

@@ -0,0 +1,92 @@
import { Router } from "express";
import type { TaskStore, Column } from "@hai/core";
import { COLUMNS } from "@hai/core";
export function createApiRoutes(store: TaskStore): Router {
const router = Router();
// List all tasks
router.get("/tasks", async (_req, res) => {
try {
const tasks = await store.listTasks();
res.json(tasks);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Get single task with prompt content
router.get("/tasks/:id", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
res.json(task);
} catch (err: any) {
res.status(404).json({ error: `Task ${req.params.id} not found` });
}
});
// Create task
router.post("/tasks", async (req, res) => {
try {
const { title, description, column, dependencies } = req.body;
if (!title || typeof title !== "string") {
res.status(400).json({ error: "title is required" });
return;
}
const task = await store.createTask({
title,
description,
column,
dependencies,
});
res.status(201).json(task);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Move task to column
router.post("/tasks/:id/move", async (req, res) => {
try {
const { column } = req.body;
if (!column || !COLUMNS.includes(column as Column)) {
res.status(400).json({
error: `Invalid column. Must be one of: ${COLUMNS.join(", ")}`,
});
return;
}
const task = await store.moveTask(req.params.id, column as Column);
res.json(task);
} catch (err: any) {
const status = err.message.includes("Invalid transition") ? 400 : 500;
res.status(status).json({ error: err.message });
}
});
// Update task
router.patch("/tasks/:id", async (req, res) => {
try {
const { title, description, prompt } = req.body;
const task = await store.updateTask(req.params.id, {
title,
description,
prompt,
});
res.json(task);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Delete task
router.delete("/tasks/:id", async (req, res) => {
try {
const task = await store.deleteTask(req.params.id);
res.json(task);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
return router;
}

View File

@@ -7,7 +7,7 @@ import { createSSE } from "./sse.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export function createServer(store: TaskStore) {
export function createServer(store: TaskStore): ReturnType<typeof express> {
const app = express();
app.use(express.json());

View File

@@ -0,0 +1,46 @@
import type { Request, Response } from "express";
import type { TaskStore } from "@hai/core";
export function createSSE(store: TaskStore) {
return (_req: Request, res: Response) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
// Send initial heartbeat
res.write(": connected\n\n");
const onCreated = (task: any) => {
res.write(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
};
const onMoved = (data: any) => {
res.write(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
};
const onUpdated = (task: any) => {
res.write(`event: task:updated\ndata: ${JSON.stringify(task)}\n\n`);
};
const onDeleted = (task: any) => {
res.write(`event: task:deleted\ndata: ${JSON.stringify(task)}\n\n`);
};
store.on("task:created", onCreated);
store.on("task:moved", onMoved);
store.on("task:updated", onUpdated);
store.on("task:deleted", onDeleted);
// Heartbeat every 30s to keep connection alive
const heartbeat = setInterval(() => {
res.write(": heartbeat\n\n");
}, 30_000);
_req.on("close", () => {
clearInterval(heartbeat);
store.off("task:created", onCreated);
store.off("task:moved", onMoved);
store.off("task:updated", onUpdated);
store.off("task:deleted", onDeleted);
});
};
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"module": "ESNext",
"noEmit": true
},
"include": ["app"]
}

View File

@@ -5,5 +5,5 @@
"rootDir": "src",
"jsx": "react-jsx"
},
"include": ["src", "app"]
"include": ["src"]
}

View File

@@ -1,9 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
export default defineConfig({
root: "app",
plugins: [react()],
resolve: {
alias: {
"@hai/core": resolve(__dirname, "../core/src/types.ts"),
},
},
build: {
outDir: "../dist/client",
emptyOutDir: true,