mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 09:01:17 +00:00
Remove frontend errors and app passthrough (#251)
This commit is contained in:
parent
d8b8b49f37
commit
8fb19b50da
23 changed files with 146 additions and 1119 deletions
|
|
@ -9,7 +9,6 @@ import { createBackends, createNotificationService } from "./notifications/index
|
|||
import { createDefaultDriver } from "./driver.js";
|
||||
import { createProviderRegistry } from "./providers/index.js";
|
||||
import { createClient } from "rivetkit/client";
|
||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { initBetterAuthService } from "./services/better-auth.js";
|
||||
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js";
|
||||
|
|
@ -205,23 +204,6 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
}
|
||||
};
|
||||
|
||||
const appWorkspaceAction = async <T>(action: string, run: (workspace: any) => Promise<T>, context: AppWorkspaceLogContext = {}): Promise<T> => {
|
||||
try {
|
||||
return await run(await appWorkspace({ ...context, action }));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...context,
|
||||
action,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"app_workspace_action_failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const requestLogContext = (c: any, sessionId?: string): AppWorkspaceLogContext => ({
|
||||
...requestHeaderContext(c),
|
||||
method: c.req.method,
|
||||
|
|
@ -235,30 +217,6 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
return session?.session?.id ?? null;
|
||||
};
|
||||
|
||||
app.get("/v1/app/snapshot", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.json({
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
users: [],
|
||||
organizations: [],
|
||||
});
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction("getAppSnapshot", async (workspace) => await workspace.getAppSnapshot({ sessionId }), requestLogContext(c, sessionId)),
|
||||
);
|
||||
});
|
||||
|
||||
app.all("/v1/auth/*", async (c) => {
|
||||
return await betterAuth.auth.handler(c.req.raw);
|
||||
});
|
||||
|
|
@ -289,126 +247,6 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
});
|
||||
});
|
||||
|
||||
app.post("/v1/app/onboarding/starter-repo/skip", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction("skipAppStarterRepo", async (workspace) => await workspace.skipAppStarterRepo({ sessionId }), requestLogContext(c, sessionId)),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/v1/app/organizations/:organizationId/starter-repo/star", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"starAppStarterRepo",
|
||||
async (workspace) =>
|
||||
await workspace.starAppStarterRepo({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/v1/app/organizations/:organizationId/select", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"selectAppOrganization",
|
||||
async (workspace) =>
|
||||
await workspace.selectAppOrganization({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.patch("/v1/app/organizations/:organizationId/profile", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"updateAppOrganizationProfile",
|
||||
async (workspace) =>
|
||||
await workspace.updateAppOrganizationProfile({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
displayName: typeof body?.displayName === "string" ? body.displayName : "",
|
||||
slug: typeof body?.slug === "string" ? body.slug : "",
|
||||
primaryDomain: typeof body?.primaryDomain === "string" ? body.primaryDomain : "",
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/v1/app/organizations/:organizationId/import", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"triggerAppRepoImport",
|
||||
async (workspace) =>
|
||||
await workspace.triggerAppRepoImport({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/v1/app/organizations/:organizationId/reconnect", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"beginAppGithubInstall",
|
||||
async (workspace) =>
|
||||
await workspace.beginAppGithubInstall({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/v1/app/organizations/:organizationId/billing/checkout", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FoundryBillingPlanId) : "team";
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).createAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
planId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/v1/billing/checkout/complete", async (c) => {
|
||||
const organizationId = c.req.query("organizationId");
|
||||
const checkoutSessionId = c.req.query("session_id");
|
||||
|
|
@ -427,58 +265,6 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
return Response.redirect(result.redirectTo, 302);
|
||||
});
|
||||
|
||||
app.post("/v1/app/organizations/:organizationId/billing/portal", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).createAppBillingPortalSession({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/v1/app/organizations/:organizationId/billing/cancel", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).cancelAppScheduledRenewal({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/v1/app/organizations/:organizationId/billing/resume", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).resumeAppSubscription({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/v1/app/workspaces/:workspaceId/seat-usage", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
if (!sessionId) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
return c.json(
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).recordAppSeatUsage({
|
||||
sessionId,
|
||||
workspaceId: c.req.param("workspaceId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const handleStripeWebhook = async (c: any) => {
|
||||
const payload = await c.req.text();
|
||||
await (await appWorkspace(requestLogContext(c))).handleAppStripeWebhook({
|
||||
|
|
|
|||
|
|
@ -114,6 +114,22 @@ interface WorkspaceHandle {
|
|||
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
|
||||
}
|
||||
|
||||
interface AppWorkspaceHandle {
|
||||
connect(): ActorConn;
|
||||
getAppSnapshot(input: { sessionId: string }): Promise<FoundryAppSnapshot>;
|
||||
skipAppStarterRepo(input: { sessionId: string }): Promise<FoundryAppSnapshot>;
|
||||
starAppStarterRepo(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
|
||||
selectAppOrganization(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
|
||||
updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput & { sessionId: string }): Promise<FoundryAppSnapshot>;
|
||||
triggerAppRepoImport(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
|
||||
beginAppGithubInstall(input: { sessionId: string; organizationId: string }): Promise<{ url: string }>;
|
||||
createAppCheckoutSession(input: { sessionId: string; organizationId: string; planId: FoundryBillingPlanId }): Promise<{ url: string }>;
|
||||
createAppBillingPortalSession(input: { sessionId: string; organizationId: string }): Promise<{ url: string }>;
|
||||
cancelAppScheduledRenewal(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
|
||||
resumeAppSubscription(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
|
||||
recordAppSeatUsage(input: { sessionId: string; workspaceId: string }): Promise<FoundryAppSnapshot>;
|
||||
}
|
||||
|
||||
interface TaskHandle {
|
||||
getTaskSummary(): Promise<WorkbenchTaskSummary>;
|
||||
getTaskDetail(): Promise<WorkbenchTaskDetail>;
|
||||
|
|
@ -317,6 +333,24 @@ function deriveBackendEndpoints(endpoint: string): { appEndpoint: string; rivetE
|
|||
};
|
||||
}
|
||||
|
||||
function signedOutAppSnapshot(): FoundryAppSnapshot {
|
||||
return {
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
users: [],
|
||||
organizations: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createBackendClient(options: BackendClientOptions): BackendClient {
|
||||
if (options.mode === "mock") {
|
||||
return createMockBackendClient(options.defaultWorkspaceId);
|
||||
|
|
@ -362,11 +396,19 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
const redirectTo = async (path: string, init?: RequestInit): Promise<void> => {
|
||||
const response = await appRequest<{ url: string }>(path, init);
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(response.url);
|
||||
const getSessionId = async (): Promise<string | null> => {
|
||||
const res = await fetch(`${appApiEndpoint}/auth/get-session`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`auth session request failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as { session?: { id?: string | null } | null } | null;
|
||||
const sessionId = data?.session?.id;
|
||||
return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
|
||||
};
|
||||
|
||||
const workspace = async (workspaceId: string): Promise<WorkspaceHandle> =>
|
||||
|
|
@ -374,6 +416,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
createWithInput: workspaceId,
|
||||
});
|
||||
|
||||
const appWorkspace = async (): Promise<AppWorkspaceHandle> =>
|
||||
client.workspace.getOrCreate(workspaceKey("app"), {
|
||||
createWithInput: "app",
|
||||
}) as unknown as AppWorkspaceHandle;
|
||||
|
||||
const task = async (workspaceId: string, repoId: string, taskId: string): Promise<TaskHandle> => client.task.get(taskKey(workspaceId, repoId, taskId));
|
||||
|
||||
const sandboxByKey = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<SandboxInstanceHandle> => {
|
||||
|
|
@ -634,7 +681,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
|
||||
if (!appSubscriptions.disposeConnPromise) {
|
||||
appSubscriptions.disposeConnPromise = (async () => {
|
||||
const handle = await workspace("app");
|
||||
const handle = await appWorkspace();
|
||||
const conn = (handle as any).connect();
|
||||
const unsubscribeEvent = conn.on("appUpdated", () => {
|
||||
for (const currentListener of [...appSubscriptions.listeners]) {
|
||||
|
|
@ -665,7 +712,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
|
||||
return {
|
||||
async getAppSnapshot(): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>("/app/snapshot");
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
return signedOutAppSnapshot();
|
||||
}
|
||||
return await (await appWorkspace()).getAppSnapshot({ sessionId });
|
||||
},
|
||||
|
||||
async connectWorkspace(workspaceId: string): Promise<ActorConn> {
|
||||
|
|
@ -704,75 +755,106 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
},
|
||||
|
||||
async skipAppStarterRepo(): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>("/app/onboarding/starter-repo/skip", {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
return await (await appWorkspace()).skipAppStarterRepo({ sessionId });
|
||||
},
|
||||
|
||||
async starAppStarterRepo(organizationId: string): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/starter-repo/star`, {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
return await (await appWorkspace()).starAppStarterRepo({ sessionId, organizationId });
|
||||
},
|
||||
|
||||
async selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/select`, {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
return await (await appWorkspace()).selectAppOrganization({ sessionId, organizationId });
|
||||
},
|
||||
|
||||
async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${input.organizationId}/profile`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
displayName: input.displayName,
|
||||
slug: input.slug,
|
||||
primaryDomain: input.primaryDomain,
|
||||
}),
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
return await (await appWorkspace()).updateAppOrganizationProfile({
|
||||
sessionId,
|
||||
organizationId: input.organizationId,
|
||||
displayName: input.displayName,
|
||||
slug: input.slug,
|
||||
primaryDomain: input.primaryDomain,
|
||||
});
|
||||
},
|
||||
|
||||
async triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/import`, {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
return await (await appWorkspace()).triggerAppRepoImport({ sessionId, organizationId });
|
||||
},
|
||||
|
||||
async reconnectAppGithub(organizationId: string): Promise<void> {
|
||||
await redirectTo(`/app/organizations/${organizationId}/reconnect`, {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
const response = await (await appWorkspace()).beginAppGithubInstall({ sessionId, organizationId });
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(response.url);
|
||||
}
|
||||
},
|
||||
|
||||
async completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
|
||||
await redirectTo(`/app/organizations/${organizationId}/billing/checkout`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ planId }),
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
const response = await (await appWorkspace()).createAppCheckoutSession({ sessionId, organizationId, planId });
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(response.url);
|
||||
}
|
||||
},
|
||||
|
||||
async openAppBillingPortal(organizationId: string): Promise<void> {
|
||||
await redirectTo(`/app/organizations/${organizationId}/billing/portal`, {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
const response = await (await appWorkspace()).createAppBillingPortalSession({ sessionId, organizationId });
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(response.url);
|
||||
}
|
||||
},
|
||||
|
||||
async cancelAppScheduledRenewal(organizationId: string): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/billing/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
return await (await appWorkspace()).cancelAppScheduledRenewal({ sessionId, organizationId });
|
||||
},
|
||||
|
||||
async resumeAppSubscription(organizationId: string): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/billing/resume`, {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
return await (await appWorkspace()).resumeAppSubscription({ sessionId, organizationId });
|
||||
},
|
||||
|
||||
async recordAppSeatUsage(workspaceId: string): Promise<FoundryAppSnapshot> {
|
||||
return await appRequest<FoundryAppSnapshot>(`/app/workspaces/${workspaceId}/seat-usage`, {
|
||||
method: "POST",
|
||||
});
|
||||
const sessionId = await getSessionId();
|
||||
if (!sessionId) {
|
||||
throw new Error("No active auth session");
|
||||
}
|
||||
return await (await appWorkspace()).recordAppSeatUsage({ sessionId, workspaceId });
|
||||
},
|
||||
|
||||
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
{
|
||||
"name": "@sandbox-agent/foundry-frontend-errors",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client.d.ts",
|
||||
"default": "./dist/client.js"
|
||||
},
|
||||
"./vite": {
|
||||
"types": "./dist/vite.d.ts",
|
||||
"default": "./dist/vite.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts src/client.ts src/vite.ts --format esm --dts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"hono": "^4.11.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.0",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { FrontendErrorContext } from "./types.js";
|
||||
|
||||
interface FrontendErrorCollectorGlobal {
|
||||
setContext: (context: FrontendErrorContext) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__FOUNDRY_FRONTEND_ERROR_COLLECTOR__?: FrontendErrorCollectorGlobal;
|
||||
__FOUNDRY_FRONTEND_ERROR_CONTEXT__?: FrontendErrorContext;
|
||||
}
|
||||
}
|
||||
|
||||
export function setFrontendErrorContext(context: FrontendErrorContext): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextContext = sanitizeContext(context);
|
||||
window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ = {
|
||||
...(window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ ?? {}),
|
||||
...nextContext,
|
||||
};
|
||||
window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__?.setContext(nextContext);
|
||||
}
|
||||
|
||||
function sanitizeContext(input: FrontendErrorContext): FrontendErrorContext {
|
||||
const output: FrontendErrorContext = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
output[key] = value;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./router.js";
|
||||
export * from "./script.js";
|
||||
export * from "./types.js";
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
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 = ".foundry/logs/frontend-errors.ndjson";
|
||||
const DEFAULT_REPORTER = "foundry-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;
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
import type { FrontendErrorCollectorScriptOptions } from "./types.js";
|
||||
|
||||
const DEFAULT_REPORTER = "foundry-frontend";
|
||||
|
||||
export function createFrontendErrorCollectorScript(options: FrontendErrorCollectorScriptOptions): string {
|
||||
const config = {
|
||||
endpoint: options.endpoint,
|
||||
reporter: options.reporter ?? DEFAULT_REPORTER,
|
||||
includeConsoleErrors: options.includeConsoleErrors ?? true,
|
||||
includeFetchErrors: options.includeFetchErrors ?? true,
|
||||
};
|
||||
|
||||
return `(function () {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__) {
|
||||
return;
|
||||
}
|
||||
|
||||
var config = ${JSON.stringify(config)};
|
||||
var sharedContext = window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ || {};
|
||||
window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ = sharedContext;
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function clampText(input, maxLength) {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
var value = input.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.slice(0, maxLength) + "...(truncated)";
|
||||
}
|
||||
|
||||
function currentRoute() {
|
||||
return location.pathname + location.search + location.hash;
|
||||
}
|
||||
|
||||
function safeContext() {
|
||||
var copy = {};
|
||||
for (var key in sharedContext) {
|
||||
if (!Object.prototype.hasOwnProperty.call(sharedContext, key)) {
|
||||
continue;
|
||||
}
|
||||
var candidate = sharedContext[key];
|
||||
if (
|
||||
candidate === null ||
|
||||
candidate === undefined ||
|
||||
typeof candidate === "string" ||
|
||||
typeof candidate === "number" ||
|
||||
typeof candidate === "boolean"
|
||||
) {
|
||||
copy[key] = candidate;
|
||||
}
|
||||
}
|
||||
copy.route = currentRoute();
|
||||
return copy;
|
||||
}
|
||||
|
||||
function stringifyUnknown(input) {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof Error) {
|
||||
return input.stack || input.message || String(input);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
|
||||
var internalSendInFlight = false;
|
||||
|
||||
function send(eventPayload) {
|
||||
var payload = {
|
||||
kind: eventPayload.kind || "window-error",
|
||||
message: clampText(eventPayload.message || "(no message)", 12000),
|
||||
stack: clampText(eventPayload.stack, 12000),
|
||||
source: clampText(eventPayload.source, 1024),
|
||||
line: typeof eventPayload.line === "number" ? eventPayload.line : null,
|
||||
column: typeof eventPayload.column === "number" ? eventPayload.column : null,
|
||||
url: clampText(eventPayload.url || location.href, 2048),
|
||||
timestamp: typeof eventPayload.timestamp === "number" ? eventPayload.timestamp : now(),
|
||||
context: safeContext(),
|
||||
extra: eventPayload.extra || {},
|
||||
};
|
||||
|
||||
var body = JSON.stringify(payload);
|
||||
|
||||
if (navigator.sendBeacon && body.length < 60000) {
|
||||
var blob = new Blob([body], { type: "application/json" });
|
||||
navigator.sendBeacon(config.endpoint, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
if (internalSendInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
internalSendInFlight = true;
|
||||
fetch(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
keepalive: true,
|
||||
body: body,
|
||||
}).catch(function () {
|
||||
return;
|
||||
}).finally(function () {
|
||||
internalSendInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__ = {
|
||||
setContext: function (nextContext) {
|
||||
if (!nextContext || typeof nextContext !== "object") {
|
||||
return;
|
||||
}
|
||||
for (var key in nextContext) {
|
||||
if (!Object.prototype.hasOwnProperty.call(nextContext, key)) {
|
||||
continue;
|
||||
}
|
||||
sharedContext[key] = nextContext[key];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (config.includeConsoleErrors) {
|
||||
var originalConsoleError = console.error.bind(console);
|
||||
console.error = function () {
|
||||
var message = "";
|
||||
var values = [];
|
||||
for (var index = 0; index < arguments.length; index += 1) {
|
||||
values.push(stringifyUnknown(arguments[index]));
|
||||
}
|
||||
message = values.join(" ");
|
||||
send({
|
||||
kind: "console-error",
|
||||
message: message || "console.error called",
|
||||
timestamp: now(),
|
||||
extra: { args: values.slice(0, 10) },
|
||||
});
|
||||
return originalConsoleError.apply(console, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("error", function (event) {
|
||||
var target = event.target;
|
||||
var hasResourceTarget = target && target !== window && typeof target === "object";
|
||||
if (hasResourceTarget) {
|
||||
var url = null;
|
||||
if ("src" in target && typeof target.src === "string") {
|
||||
url = target.src;
|
||||
} else if ("href" in target && typeof target.href === "string") {
|
||||
url = target.href;
|
||||
}
|
||||
send({
|
||||
kind: "resource-error",
|
||||
message: "Resource failed to load",
|
||||
source: event.filename || null,
|
||||
line: typeof event.lineno === "number" ? event.lineno : null,
|
||||
column: typeof event.colno === "number" ? event.colno : null,
|
||||
url: url || location.href,
|
||||
stack: null,
|
||||
timestamp: now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var message = clampText(event.message, 12000) || "Unhandled window error";
|
||||
var stack = event.error && event.error.stack ? String(event.error.stack) : null;
|
||||
send({
|
||||
kind: "window-error",
|
||||
message: message,
|
||||
stack: stack,
|
||||
source: event.filename || null,
|
||||
line: typeof event.lineno === "number" ? event.lineno : null,
|
||||
column: typeof event.colno === "number" ? event.colno : null,
|
||||
url: location.href,
|
||||
timestamp: now(),
|
||||
});
|
||||
}, true);
|
||||
|
||||
window.addEventListener("unhandledrejection", function (event) {
|
||||
var reason = event.reason;
|
||||
var stack = reason && reason.stack ? String(reason.stack) : null;
|
||||
send({
|
||||
kind: "unhandled-rejection",
|
||||
message: stringifyUnknown(reason),
|
||||
stack: stack,
|
||||
url: location.href,
|
||||
timestamp: now(),
|
||||
});
|
||||
});
|
||||
|
||||
if (config.includeFetchErrors && typeof window.fetch === "function") {
|
||||
var originalFetch = window.fetch.bind(window);
|
||||
window.fetch = function () {
|
||||
var args = arguments;
|
||||
var requestUrl = null;
|
||||
if (typeof args[0] === "string") {
|
||||
requestUrl = args[0];
|
||||
} else if (args[0] && typeof args[0].url === "string") {
|
||||
requestUrl = args[0].url;
|
||||
}
|
||||
|
||||
return originalFetch.apply(window, args).then(function (response) {
|
||||
if (!response.ok && response.status >= 500) {
|
||||
send({
|
||||
kind: "fetch-response-error",
|
||||
message: "Fetch returned HTTP " + response.status,
|
||||
url: requestUrl || location.href,
|
||||
timestamp: now(),
|
||||
extra: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
},
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}).catch(function (error) {
|
||||
send({
|
||||
kind: "fetch-error",
|
||||
message: stringifyUnknown(error),
|
||||
stack: error && error.stack ? String(error.stack) : null,
|
||||
url: requestUrl || location.href,
|
||||
timestamp: now(),
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
})();`;
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
export type FrontendErrorKind = "window-error" | "resource-error" | "unhandled-rejection" | "console-error" | "fetch-error" | "fetch-response-error";
|
||||
|
||||
export interface FrontendErrorContext {
|
||||
route?: string;
|
||||
workspaceId?: string;
|
||||
taskId?: string;
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
export interface FrontendErrorEventInput {
|
||||
kind?: string;
|
||||
message?: string;
|
||||
stack?: string | null;
|
||||
source?: string | null;
|
||||
line?: number | null;
|
||||
column?: number | null;
|
||||
url?: string | null;
|
||||
timestamp?: number;
|
||||
context?: FrontendErrorContext | null;
|
||||
extra?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface FrontendErrorLogEvent {
|
||||
id: string;
|
||||
kind: FrontendErrorKind;
|
||||
message: string;
|
||||
stack: string | null;
|
||||
source: string | null;
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
url: string | null;
|
||||
timestamp: number;
|
||||
receivedAt: number;
|
||||
userAgent: string | null;
|
||||
clientIp: string | null;
|
||||
reporter: string;
|
||||
context: FrontendErrorContext;
|
||||
extra: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FrontendErrorCollectorScriptOptions {
|
||||
endpoint: string;
|
||||
reporter?: string;
|
||||
includeConsoleErrors?: boolean;
|
||||
includeFetchErrors?: boolean;
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { getRequestListener } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import type { Plugin } from "vite";
|
||||
import { createFrontendErrorCollectorRouter, defaultFrontendErrorLogPath } from "./router.js";
|
||||
import { createFrontendErrorCollectorScript } from "./script.js";
|
||||
|
||||
const DEFAULT_MOUNT_PATH = "/__foundry/frontend-errors";
|
||||
const DEFAULT_EVENT_PATH = "/events";
|
||||
|
||||
export interface FrontendErrorCollectorVitePluginOptions {
|
||||
mountPath?: string;
|
||||
logFilePath?: string;
|
||||
reporter?: string;
|
||||
includeConsoleErrors?: boolean;
|
||||
includeFetchErrors?: boolean;
|
||||
}
|
||||
|
||||
export function frontendErrorCollectorVitePlugin(options: FrontendErrorCollectorVitePluginOptions = {}): Plugin {
|
||||
const mountPath = normalizePath(options.mountPath ?? DEFAULT_MOUNT_PATH);
|
||||
const logFilePath = options.logFilePath ?? defaultFrontendErrorLogPath(process.cwd());
|
||||
const reporter = options.reporter ?? "foundry-vite";
|
||||
const endpoint = `${mountPath}${DEFAULT_EVENT_PATH}`;
|
||||
|
||||
const router = createFrontendErrorCollectorRouter({
|
||||
logFilePath,
|
||||
reporter,
|
||||
});
|
||||
const mountApp = new Hono().route(mountPath, router);
|
||||
const listener = getRequestListener(mountApp.fetch);
|
||||
|
||||
return {
|
||||
name: "foundry:frontend-error-collector",
|
||||
apply: "serve",
|
||||
transformIndexHtml(html) {
|
||||
return {
|
||||
html,
|
||||
tags: [
|
||||
{
|
||||
tag: "script",
|
||||
attrs: { type: "module" },
|
||||
children: createFrontendErrorCollectorScript({
|
||||
endpoint,
|
||||
reporter,
|
||||
includeConsoleErrors: options.includeConsoleErrors,
|
||||
includeFetchErrors: options.includeFetchErrors,
|
||||
}),
|
||||
injectTo: "head-prepend",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url?.startsWith(mountPath)) {
|
||||
return next();
|
||||
}
|
||||
void listener(req, res).catch((error) => next(error));
|
||||
});
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url?.startsWith(mountPath)) {
|
||||
return next();
|
||||
}
|
||||
void listener(req, res).catch((error) => next(error));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path.startsWith("/")) {
|
||||
return `/${path}`;
|
||||
}
|
||||
return path.replace(/\/+$/, "");
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createFrontendErrorCollectorRouter } from "../src/router.js";
|
||||
import { createFrontendErrorCollectorScript } from "../src/script.js";
|
||||
|
||||
describe("frontend error collector router", () => {
|
||||
test("writes accepted event payloads to NDJSON", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "hf-frontend-errors-"));
|
||||
const logFilePath = join(directory, "events.ndjson");
|
||||
const app = createFrontendErrorCollectorRouter({ logFilePath, reporter: "test-suite" });
|
||||
|
||||
try {
|
||||
const response = await app.request("/events", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind: "window-error",
|
||||
message: "Boom",
|
||||
stack: "at app.tsx:1:1",
|
||||
context: { route: "/workspaces/default" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
|
||||
const written = await readFile(logFilePath, "utf8");
|
||||
const [firstLine] = written.trim().split("\n");
|
||||
expect(firstLine).toBeTruthy();
|
||||
const parsed = JSON.parse(firstLine ?? "{}") as {
|
||||
kind?: string;
|
||||
message?: string;
|
||||
reporter?: string;
|
||||
context?: { route?: string };
|
||||
};
|
||||
expect(parsed.kind).toBe("window-error");
|
||||
expect(parsed.message).toBe("Boom");
|
||||
expect(parsed.reporter).toBe("test-suite");
|
||||
expect(parsed.context?.route).toBe("/workspaces/default");
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("frontend error collector script", () => {
|
||||
test("embeds configured endpoint", () => {
|
||||
const script = createFrontendErrorCollectorScript({
|
||||
endpoint: "/__foundry/frontend-errors/events",
|
||||
});
|
||||
expect(script).toContain("/__foundry/frontend-errors/events");
|
||||
expect(script).toContain('window.addEventListener("error"');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "test", "vitest.config.ts"]
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
|
@ -12,7 +12,6 @@
|
|||
"dependencies": {
|
||||
"@sandbox-agent/react": "workspace:*",
|
||||
"@sandbox-agent/foundry-client": "workspace:*",
|
||||
"@sandbox-agent/foundry-frontend-errors": "workspace:*",
|
||||
"@sandbox-agent/foundry-shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-router": "^1.132.23",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { type ReactNode, useEffect } from "react";
|
||||
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
|
||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
|
||||
import { MockLayout } from "../components/mock-layout";
|
||||
import {
|
||||
MockAccountSettingsPage,
|
||||
|
|
@ -257,13 +256,6 @@ function WorkspaceView({
|
|||
selectedTaskId: string | null;
|
||||
selectedSessionId: string | null;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
taskId: undefined,
|
||||
});
|
||||
}, [workspaceId]);
|
||||
|
||||
return <MockLayout workspaceId={workspaceId} selectedTaskId={selectedTaskId} selectedSessionId={selectedSessionId} />;
|
||||
}
|
||||
|
||||
|
|
@ -278,14 +270,6 @@ function TaskRoute() {
|
|||
}
|
||||
|
||||
function TaskView({ workspaceId, taskId, sessionId }: { workspaceId: string; taskId: string; sessionId: string | null }) {
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
taskId,
|
||||
repoId: undefined,
|
||||
});
|
||||
}, [taskId, workspaceId]);
|
||||
|
||||
return <MockLayout workspaceId={workspaceId} selectedTaskId={taskId} selectedSessionId={sessionId} />;
|
||||
}
|
||||
|
||||
|
|
@ -326,13 +310,6 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil
|
|||
|
||||
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
taskId: undefined,
|
||||
repoId,
|
||||
});
|
||||
}, [repoId, workspaceId]);
|
||||
const activeTaskId = workspaceState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id;
|
||||
if (!activeTaskId) {
|
||||
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />;
|
||||
|
|
@ -343,22 +320,7 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
|
|||
function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<RouteContextSync />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteContextSync() {
|
||||
const location = useRouterState({
|
||||
select: (state) => state.location,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
route: `${location.pathname}${location.search}${location.hash}`,
|
||||
});
|
||||
}, [location.hash, location.pathname, location.search]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { resolve } from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { frontendErrorCollectorVitePlugin } from "@sandbox-agent/foundry-frontend-errors/vite";
|
||||
|
||||
const backendProxyTarget = process.env.HF_BACKEND_HTTP?.trim() || "http://127.0.0.1:7741";
|
||||
const cacheDir = process.env.HF_VITE_CACHE_DIR?.trim() || undefined;
|
||||
|
|
@ -9,7 +8,7 @@ export default defineConfig({
|
|||
define: {
|
||||
"import.meta.env.FOUNDRY_FRONTEND_CLIENT_MODE": JSON.stringify(process.env.FOUNDRY_FRONTEND_CLIENT_MODE?.trim() || "remote"),
|
||||
},
|
||||
plugins: [react(), frontendErrorCollectorVitePlugin()],
|
||||
plugins: [react()],
|
||||
cacheDir,
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue