mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 21:03:46 +00:00
Integrate OpenHandoff factory workspace (#212)
This commit is contained in:
parent
3d9476ed0b
commit
bf282199b5
251 changed files with 42824 additions and 692 deletions
277
factory/packages/frontend-errors/src/router.ts
Normal file
277
factory/packages/frontend-errors/src/router.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import type { FrontendErrorContext, FrontendErrorKind, FrontendErrorLogEvent } from "./types.js";
|
||||
|
||||
const DEFAULT_RELATIVE_LOG_PATH = ".openhandoff/logs/frontend-errors.ndjson";
|
||||
const DEFAULT_REPORTER = "openhandoff-frontend";
|
||||
const MAX_FIELD_LENGTH = 12_000;
|
||||
|
||||
export interface FrontendErrorCollectorRouterOptions {
|
||||
logFilePath?: string;
|
||||
reporter?: string;
|
||||
}
|
||||
|
||||
export function findProjectRoot(startDirectory: string = process.cwd()): string {
|
||||
let currentDirectory = resolve(startDirectory);
|
||||
while (true) {
|
||||
if (existsSync(join(currentDirectory, ".git"))) {
|
||||
return currentDirectory;
|
||||
}
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
return resolve(startDirectory);
|
||||
}
|
||||
currentDirectory = parentDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultFrontendErrorLogPath(startDirectory: string = process.cwd()): string {
|
||||
const root = findProjectRoot(startDirectory);
|
||||
return resolve(root, DEFAULT_RELATIVE_LOG_PATH);
|
||||
}
|
||||
|
||||
export function createFrontendErrorCollectorRouter(
|
||||
options: FrontendErrorCollectorRouterOptions = {}
|
||||
): Hono {
|
||||
const logFilePath = options.logFilePath ?? defaultFrontendErrorLogPath();
|
||||
const reporter = trimText(options.reporter, 128) ?? DEFAULT_REPORTER;
|
||||
let ensureLogPathPromise: Promise<void> | null = null;
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/healthz", (c) =>
|
||||
c.json({
|
||||
ok: true,
|
||||
logFilePath,
|
||||
reporter,
|
||||
})
|
||||
);
|
||||
|
||||
app.post("/events", async (c) => {
|
||||
let parsedBody: unknown;
|
||||
try {
|
||||
parsedBody = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ ok: false, error: "Expected JSON body" }, 400);
|
||||
}
|
||||
|
||||
const inputEvents = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
|
||||
if (inputEvents.length === 0) {
|
||||
return c.json({ ok: false, error: "Expected at least one event" }, 400);
|
||||
}
|
||||
|
||||
const receivedAt = Date.now();
|
||||
const userAgent = trimText(c.req.header("user-agent"), 512);
|
||||
const clientIp = readClientIp(c.req.header("x-forwarded-for"));
|
||||
const normalizedEvents: FrontendErrorLogEvent[] = [];
|
||||
|
||||
for (const candidate of inputEvents) {
|
||||
if (!isObject(candidate)) {
|
||||
continue;
|
||||
}
|
||||
normalizedEvents.push(
|
||||
normalizeEvent({
|
||||
candidate,
|
||||
reporter,
|
||||
userAgent: userAgent ?? null,
|
||||
clientIp: clientIp ?? null,
|
||||
receivedAt,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedEvents.length === 0) {
|
||||
return c.json({ ok: false, error: "No valid events found in request" }, 400);
|
||||
}
|
||||
|
||||
await ensureLogPath();
|
||||
|
||||
const payload = `${normalizedEvents.map((event) => JSON.stringify(event)).join("\n")}\n`;
|
||||
await appendFile(logFilePath, payload, "utf8");
|
||||
|
||||
return c.json(
|
||||
{
|
||||
ok: true,
|
||||
accepted: normalizedEvents.length,
|
||||
},
|
||||
202
|
||||
);
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
async function ensureLogPath(): Promise<void> {
|
||||
ensureLogPathPromise ??= mkdir(dirname(logFilePath), { recursive: true }).then(() => undefined);
|
||||
await ensureLogPathPromise;
|
||||
}
|
||||
}
|
||||
|
||||
interface NormalizeEventInput {
|
||||
candidate: Record<string, unknown>;
|
||||
reporter: string;
|
||||
userAgent: string | null;
|
||||
clientIp: string | null;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
function normalizeEvent(input: NormalizeEventInput): FrontendErrorLogEvent {
|
||||
const kind = normalizeKind(input.candidate.kind);
|
||||
return {
|
||||
id: createEventId(),
|
||||
kind,
|
||||
message: trimText(input.candidate.message, MAX_FIELD_LENGTH) ?? "(no message)",
|
||||
stack: trimText(input.candidate.stack, MAX_FIELD_LENGTH) ?? null,
|
||||
source: trimText(input.candidate.source, 1024) ?? null,
|
||||
line: normalizeNumber(input.candidate.line),
|
||||
column: normalizeNumber(input.candidate.column),
|
||||
url: trimText(input.candidate.url, 2048) ?? null,
|
||||
timestamp: normalizeTimestamp(input.candidate.timestamp),
|
||||
receivedAt: input.receivedAt,
|
||||
userAgent: input.userAgent,
|
||||
clientIp: input.clientIp,
|
||||
reporter: input.reporter,
|
||||
context: normalizeContext(input.candidate.context),
|
||||
extra: normalizeExtra(input.candidate.extra),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKind(value: unknown): FrontendErrorKind {
|
||||
switch (value) {
|
||||
case "window-error":
|
||||
case "resource-error":
|
||||
case "unhandled-rejection":
|
||||
case "console-error":
|
||||
case "fetch-error":
|
||||
case "fetch-response-error":
|
||||
return value;
|
||||
default:
|
||||
return "window-error";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): number {
|
||||
const parsed = normalizeNumber(value);
|
||||
if (parsed === null) {
|
||||
return Date.now();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeContext(value: unknown): FrontendErrorContext {
|
||||
if (!isObject(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const context: FrontendErrorContext = {};
|
||||
for (const [key, candidate] of Object.entries(value)) {
|
||||
if (!isAllowedContextValue(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const safeKey = trimText(key, 128);
|
||||
if (!safeKey) {
|
||||
continue;
|
||||
}
|
||||
if (typeof candidate === "string") {
|
||||
context[safeKey] = trimText(candidate, 1024);
|
||||
continue;
|
||||
}
|
||||
context[safeKey] = candidate;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function normalizeExtra(value: unknown): Record<string, unknown> {
|
||||
if (!isObject(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, candidate] of Object.entries(value)) {
|
||||
const safeKey = trimText(key, 128);
|
||||
if (!safeKey) {
|
||||
continue;
|
||||
}
|
||||
normalized[safeKey] = normalizeUnknown(candidate);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeUnknown(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return trimText(value, 1024) ?? "";
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || value === null) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 25).map((item) => normalizeUnknown(item));
|
||||
}
|
||||
if (isObject(value)) {
|
||||
const output: Record<string, unknown> = {};
|
||||
const entries = Object.entries(value).slice(0, 25);
|
||||
for (const [key, candidate] of entries) {
|
||||
const safeKey = trimText(key, 128);
|
||||
if (!safeKey) {
|
||||
continue;
|
||||
}
|
||||
output[safeKey] = normalizeUnknown(candidate);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function trimText(value: unknown, maxLength: number): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, maxLength)}...(truncated)`;
|
||||
}
|
||||
|
||||
function createEventId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function readClientIp(forwardedFor: string | undefined): string | null {
|
||||
if (!forwardedFor) {
|
||||
return null;
|
||||
}
|
||||
const [first] = forwardedFor.split(",");
|
||||
return trimText(first, 64) ?? null;
|
||||
}
|
||||
|
||||
function isAllowedContextValue(
|
||||
value: unknown
|
||||
): value is string | number | boolean | null | undefined {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue