161 lines
6.6 KiB
TypeScript
161 lines
6.6 KiB
TypeScript
export const APP_DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
|
|
export const MAX_APP_DIAGNOSTIC_ENTRIES = 200;
|
|
|
|
export const APP_DIAGNOSTIC_MESSAGES = {
|
|
VIEWPORT_INIT_FAILED: "Viewport: unavailable",
|
|
ACTION_CONFLICT: "Action: another project operation is running",
|
|
ACTION_LOCK_FAILED: "Action: retry required",
|
|
RECENT_PROJECT_LIST_FAILED: "Storage: recent projects unavailable",
|
|
RECENT_PROJECT_REPAIR_FAILED: "Storage: recent project repair failed",
|
|
RECENT_PROJECT_UPDATE_FAILED: "Storage: recent project update failed",
|
|
STORAGE_BUDGET_FAILED: "Storage: budget unavailable",
|
|
STORAGE_CLEANUP_FAILED: "Storage: cleanup failed",
|
|
OPERATION_LOG_FAILED: "Storage: operation log unavailable",
|
|
COMMAND_FAILED: "Engine: command failed",
|
|
IMAGE_IMPORT_FAILED: "Image import failed",
|
|
GREASE_PENCIL_EDIT_FAILED: "Grease Pencil edit failed",
|
|
CURVE_EDIT_FAILED: "Curve edit failed",
|
|
LOD_CACHE_READ_FAILED: "Storage: LOD cache unavailable; generated geometry retained",
|
|
LOD_GENERATION_FAILED: "Engine: LOD generation failed",
|
|
ENGINE_WORKER_TERMINATED: "Engine: Worker stopped; project remains available",
|
|
ENGINE_START_FAILED: "Engine: unavailable",
|
|
MANIFEST_REJECTED: "Manifest: rejected",
|
|
STORAGE_WORKER_TERMINATED: "Storage: Worker stopped; project remains available",
|
|
STORAGE_START_FAILED: "Storage: unavailable",
|
|
PBR_ASSET_INVALID: "PBR asset unavailable",
|
|
BLEND_OPEN_FAILED: "Engine: .blend open failed",
|
|
POST_COMMIT_MAINTENANCE_FAILED: "Storage: post-commit maintenance failed",
|
|
PROJECT_RECOVERY_FAILED: "Recovery: project could not be restored",
|
|
WORKER_RECOVERY_FAILED: "Recovery: Worker restart failed",
|
|
BLEND_SAVE_FAILED: "Engine: .blend save failed",
|
|
BLEND_DOWNLOAD_FAILED: "Engine: .blend download failed",
|
|
GLB_PROJECT_UNAVAILABLE: "GLB: no open project",
|
|
GLB_EXPORT_BLOCKED: "GLB: export blocked",
|
|
GLB_EXPORT_FAILED: "GLB: export failed",
|
|
AUTOSAVE_FAILED: "Engine: autosave failed",
|
|
} as const;
|
|
|
|
export type AppDiagnosticCode = keyof typeof APP_DIAGNOSTIC_MESSAGES;
|
|
export type AppDiagnosticArea = "ACTION" | "ENGINE" | "STORAGE" | "VIEWPORT" | "EXPORT" | "RUNTIME";
|
|
export type AppDiagnosticContextValue = string | number | boolean | null;
|
|
|
|
export interface AppDiagnosticEntry {
|
|
schemaVersion: typeof APP_DIAGNOSTIC_SCHEMA_VERSION;
|
|
sequence: number;
|
|
occurredAt: string;
|
|
area: AppDiagnosticArea;
|
|
code: AppDiagnosticCode;
|
|
summary: string;
|
|
detail: string;
|
|
sourceCode?: string;
|
|
stack?: string;
|
|
cause?: string;
|
|
context?: Record<string, AppDiagnosticContextValue>;
|
|
}
|
|
|
|
export interface AppDiagnosticReport {
|
|
schemaVersion: typeof APP_DIAGNOSTIC_SCHEMA_VERSION;
|
|
product: "Web Blender Modeler V1";
|
|
generatedAt: string;
|
|
runtime: {
|
|
url: string;
|
|
userAgent: string;
|
|
language: string;
|
|
crossOriginIsolated: boolean;
|
|
};
|
|
project: {
|
|
projectId: string;
|
|
revision: number;
|
|
};
|
|
entries: AppDiagnosticEntry[];
|
|
}
|
|
|
|
function objectString(value: unknown): string | undefined {
|
|
if (typeof value !== "object" || value === null) return undefined;
|
|
try {
|
|
const seen = new WeakSet<object>();
|
|
return JSON.stringify(value, (_key, candidate: unknown) => {
|
|
if (candidate instanceof Error) {
|
|
return { name: candidate.name, message: candidate.message, stack: candidate.stack, cause: candidate.cause };
|
|
}
|
|
if (typeof candidate === "object" && candidate !== null) {
|
|
if (seen.has(candidate)) return "[Circular]";
|
|
seen.add(candidate);
|
|
}
|
|
return candidate;
|
|
});
|
|
}
|
|
catch {
|
|
return "[Unserializable diagnostic detail]";
|
|
}
|
|
}
|
|
|
|
function propertyString(value: unknown, property: string): string | undefined {
|
|
if (typeof value !== "object" || value === null || !(property in value)) return undefined;
|
|
const candidate = (value as Record<string, unknown>)[property];
|
|
return typeof candidate === "string" && candidate ? candidate : undefined;
|
|
}
|
|
|
|
function diagnosticDetail(error: unknown): Pick<AppDiagnosticEntry, "detail" | "sourceCode" | "stack" | "cause"> {
|
|
if (error instanceof Error) {
|
|
return {
|
|
detail: error.message || error.name,
|
|
sourceCode: propertyString(error, "code"),
|
|
stack: error.stack,
|
|
cause: error.cause instanceof Error ? error.cause.message : typeof error.cause === "string" ? error.cause : objectString(error.cause),
|
|
};
|
|
}
|
|
if (typeof error === "string") return { detail: error };
|
|
const message = propertyString(error, "message");
|
|
const extraDetail = propertyString(error, "detail");
|
|
return {
|
|
detail: [message, extraDetail].filter(Boolean).join("; ") || objectString(error) || String(error),
|
|
sourceCode: propertyString(error, "code"),
|
|
stack: propertyString(error, "stack"),
|
|
cause: propertyString(error, "cause"),
|
|
};
|
|
}
|
|
|
|
export function createAppDiagnosticEntry(input: {
|
|
sequence: number;
|
|
occurredAt: string;
|
|
area: AppDiagnosticArea;
|
|
code: AppDiagnosticCode;
|
|
error: unknown;
|
|
context?: Record<string, AppDiagnosticContextValue>;
|
|
}): AppDiagnosticEntry {
|
|
if (!Number.isSafeInteger(input.sequence) || input.sequence <= 0) throw new Error("APP_DIAGNOSTIC_SEQUENCE_INVALID");
|
|
if (!Number.isFinite(Date.parse(input.occurredAt)) || new Date(input.occurredAt).toISOString() !== input.occurredAt) throw new Error("APP_DIAGNOSTIC_TIMESTAMP_INVALID");
|
|
const detail = diagnosticDetail(input.error);
|
|
return {
|
|
schemaVersion: APP_DIAGNOSTIC_SCHEMA_VERSION,
|
|
sequence: input.sequence,
|
|
occurredAt: input.occurredAt,
|
|
area: input.area,
|
|
code: input.code,
|
|
summary: APP_DIAGNOSTIC_MESSAGES[input.code],
|
|
detail: detail.detail,
|
|
...(detail.sourceCode ? { sourceCode: detail.sourceCode } : {}),
|
|
...(detail.stack ? { stack: detail.stack } : {}),
|
|
...(detail.cause ? { cause: detail.cause } : {}),
|
|
...(input.context ? { context: { ...input.context } } : {}),
|
|
};
|
|
}
|
|
|
|
export function appendAppDiagnostic(entries: readonly AppDiagnosticEntry[], entry: AppDiagnosticEntry, limit = MAX_APP_DIAGNOSTIC_ENTRIES): AppDiagnosticEntry[] {
|
|
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("APP_DIAGNOSTIC_LIMIT_INVALID");
|
|
return [...entries, entry].slice(-limit);
|
|
}
|
|
|
|
export function createAppDiagnosticReport(input: Omit<AppDiagnosticReport, "schemaVersion" | "product" | "entries"> & { entries: readonly AppDiagnosticEntry[] }): AppDiagnosticReport {
|
|
if (!Number.isFinite(Date.parse(input.generatedAt)) || new Date(input.generatedAt).toISOString() !== input.generatedAt) throw new Error("APP_DIAGNOSTIC_REPORT_TIMESTAMP_INVALID");
|
|
return {
|
|
schemaVersion: APP_DIAGNOSTIC_SCHEMA_VERSION,
|
|
product: "Web Blender Modeler V1",
|
|
generatedAt: input.generatedAt,
|
|
runtime: { ...input.runtime },
|
|
project: { ...input.project },
|
|
entries: [...input.entries].sort((left, right) => left.sequence - right.sequence),
|
|
};
|
|
}
|