import * as React from "react";import * as Cause from "effect/Cause";import * as Data from "effect/Data";import * as Exit from "effect/Exit";import * as Option from "effect/Option";import * as Schema from "effect/Schema"; export type FrontendErrorContext = { readonly surface: string; readonly action: string; readonly message?: string; readonly severity?: "error" | "warning"; readonly metadata?: Record<string, string | number | boolean | null | undefined>;}; export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void; class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ readonly message: string; readonly cause: unknown; readonly context: FrontendErrorContext;}> {} // Effect's tagged error classes (`Schema.TaggedErrorClass`) often declare no// `message` field and expose it as a prototype getter instead, so a struct// decode — which only sees own properties — would miss the very sentence the// server wrote for the user. Read the property directly.const decodeErrorMessage = (value: unknown): Option.Option<{ readonly message: string }> => { if (typeof value !== "object" || value === null) return Option.none(); const message: unknown = Reflect.get(value, "message"); return typeof message === "string" && message.length > 0 ? Option.some({ message }) : Option.none();}; const TaggedValue = Schema.Struct({ _tag: Schema.String });const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue); export const messageFromUnknown = (error: unknown, fallback: string): string => Option.match(decodeErrorMessage(error), { onNone: () => (typeof error === "string" && error.length > 0 ? error : fallback), onSome: ({ message }) => message, }); // ---------------------------------------------------------------------------// Normalization//// Crash reporters (Sentry in cloud and in the desktop renderer) title and group// an event from the reported value's `name`, `message` and `stack`. Every// producer in this codebase starts from an Effect `Cause`, which is a plain// object with none of the three: Sentry then synthesizes `'CauseImpl' captured// as exception with keys: ...` and, having no message to group on, groups on// the reporting function's own stack frame — collapsing unrelated frontend// failures into a single message-less issue.//// `toReportableError` is the choke point that turns whatever a call site has// into a real `Error` that still says what went wrong. It is idempotent: an// `Error` in, the same `Error` out, so applying it at more than one layer is// safe.// --------------------------------------------------------------------------- const contextLabel = (context: FrontendErrorContext): string => `${context.surface}/${context.action}`; const hasText = (value: string | undefined): value is string => typeof value === "string" && value.trim().length > 0; /** The best sentence available for a value that carries no usable message. */const describeUnknown = (value: unknown, context: FrontendErrorContext): string => messageFromUnknown(value, context.message ?? contextLabel(context)); /** Effect's tagged errors identify themselves by `_tag`, not by `name`. */const nameFromUnknown = (value: unknown, fallback: string): string => Option.match(decodeTaggedValue(value), { onNone: () => fallback, onSome: ({ _tag }) => _tag, }); /** Keep the original value reachable without clobbering an existing chain. */const withOriginalCause = (error: Error, original: unknown): Error => { if (error.cause === undefined && original !== error) error.cause = original; return error;}; const errorFromValue = (value: unknown, context: FrontendErrorContext): Error => { // oxlint-disable-next-line executor/no-error-constructor -- boundary: a crash-reporting transport only understands built-in Errors; this module is the adapter that produces them const error = new Error(describeUnknown(value, context)); error.name = nameFromUnknown(value, context.surface); return withOriginalCause(error, value);}; const errorFromCause = (cause: Cause.Cause<unknown>, context: FrontendErrorContext): Error => { // `prettyErrors` is the non-lossy conversion: it renders every reason — // failures AND defects — as a freshly built `Error` that keeps the original's // name, message and stack frames. const [rendered] = Cause.prettyErrors(cause); if (rendered === undefined) return errorFromValue(Cause.squash(cause), context); if (!hasText(rendered.message)) { // A tagged error declared without a `message` field renders with an empty // one — that is what produced `FrontendHandledError: No error message`. // The rendered Error is ours, built just above, so filling it in cannot // mutate a value a call site still holds, and assigning (rather than // rebuilding) keeps the captured stack frames intact. rendered.message = describeUnknown(Cause.squash(cause), context); } return withOriginalCause(rendered, cause);}; export const toReportableError = (error: unknown, context: FrontendErrorContext): Error => { if (Cause.isCause(error)) return errorFromCause(error, context); // oxlint-disable-next-line executor/no-instanceof-error -- boundary: the report is already whatever a call site threw; deciding whether the transport can consume it as-is is this adapter's whole job if (error instanceof Error) { // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed to Error above; an empty message is exactly the case being repaired if (hasText(error.message)) return error; // Never mutate an Error a call site owns: rebuild it, carrying the stack. // oxlint-disable-next-line executor/no-error-constructor -- boundary: a crash-reporting transport only understands built-in Errors const titled = new Error(describeUnknown(error, context)); titled.name = error.name; if (typeof error.stack === "string") titled.stack = error.stack; return withOriginalCause(titled, error); } return errorFromValue(error, context);}; /** Wraps a reporter so it can only ever be handed a titled `Error`. */const normalizing = (reporter: FrontendErrorReporter): FrontendErrorReporter => (error, context) => { reporter(toReportableError(error, context), context); }; // ---------------------------------------------------------------------------// Reporters// --------------------------------------------------------------------------- /** * The fallback transport: re-throw the report as a global `error` event, which * an initialized crash reporter's global handlers pick up. Used by self-host * builds and by the desktop renderer before its Sentry client is ready. */export const reportViaGlobalErrorEvent: FrontendErrorReporter = (error, context) => { if (typeof globalThis.reportError !== "function") return; const reportable = toReportableError(error, context); globalThis.reportError( new FrontendHandledError({ message: `${contextLabel(context)}: ${reportable.message}`, cause: reportable, context, }), );}; export type FrontendErrorScope = { readonly setTag: (key: string, value: string) => unknown; readonly setContext: (key: string, value: Record<string, unknown> | null) => unknown;}; /** * The one Sentry-shaped reporter both product shells use. `captureException` * stays a parameter so this module never depends on a Sentry package — cloud * passes `@sentry/react`'s, the desktop renderer passes the lazily imported * `@sentry/browser` one. */export const createSentryFrontendErrorReporter = ( captureException: (error: Error, applyScope: (scope: FrontendErrorScope) => void) => void, ): FrontendErrorReporter => (error, context) => { captureException(toReportableError(error, context), (scope) => { scope.setTag("executor.ui.surface", context.surface); scope.setTag("executor.ui.action", context.action); scope.setTag("executor.ui.severity", context.severity ?? "error"); scope.setContext("executor.ui", { surface: context.surface, action: context.action, message: context.message, metadata: context.metadata, }); }); }; const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>( normalizing(reportViaGlobalErrorEvent),); let currentFrontendErrorReporter = normalizing(reportViaGlobalErrorEvent); export const reportHandledFrontendError = (error: unknown, context: FrontendErrorContext): void => { currentFrontendErrorReporter(error, context);}; export const FrontendErrorReporterProvider = ( props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>,) => { // Memoized so the wrapper keeps a stable identity across renders: consumers // list it in `useCallback` dependencies. const reporter = React.useMemo( () => normalizing(props.reporter ?? reportViaGlobalErrorEvent), [props.reporter], ); currentFrontendErrorReporter = reporter; return ( <FrontendErrorReporterContext.Provider value={reporter}> {props.children} </FrontendErrorReporterContext.Provider> );}; export const useReportHandledError = (): FrontendErrorReporter => React.useContext(FrontendErrorReporterContext); export const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { onNone: () => fallback, onSome: ({ message }) => message, }); export const reportExitFailure = ( report: FrontendErrorReporter, exit: Exit.Exit<unknown, unknown>, context: FrontendErrorContext,): void => { if (!Exit.isFailure(exit)) return; report(toReportableError(exit.cause, context), context);}; export const useErrorMessageFromExit = (): (( exit: Exit.Exit<unknown, unknown>, fallback: string, context: Omit<FrontendErrorContext, "message"> & { readonly message?: string },) => string) => { const report = useReportHandledError(); return React.useCallback( (exit, fallback, context) => { const message = messageFromExit(exit, fallback); reportExitFailure(report, exit, { ...context, message: context.message ?? message }); return message; }, [report], );}; export const reportCauseFailure = ( report: FrontendErrorReporter, cause: Cause.Cause<unknown>, context: FrontendErrorContext,): void => { report(toReportableError(cause, context), context);};
import * as React from "react";
import * as Cause from "effect/Cause";
import * as Data from "effect/Data";
import * as Exit from "effect/Exit";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
export type FrontendErrorContext = {
readonly surface: string;
readonly action: string;
readonly message?: string;
readonly severity?: "error" | "warning";
readonly metadata?: Record<string, string | number | boolean | null | undefined>;
};
export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void;
class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{
readonly message: string;
readonly cause: unknown;
readonly context: FrontendErrorContext;
}> {}
// Effect's tagged error classes (`Schema.TaggedErrorClass`) often declare no
// `message` field and expose it as a prototype getter instead, so a struct
// decode — which only sees own properties — would miss the very sentence the
// server wrote for the user. Read the property directly.
const decodeErrorMessage = (value: unknown): Option.Option<{ readonly message: string }> => {
if (typeof value !== "object" || value === null) return Option.none();
const message: unknown = Reflect.get(value, "message");
return typeof message === "string" && message.length > 0
? Option.some({ message })
: Option.none();
};
const TaggedValue = Schema.Struct({ _tag: Schema.String });
const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue);
export const messageFromUnknown = (error: unknown, fallback: string): string =>
Option.match(decodeErrorMessage(error), {
onNone: () => (typeof error === "string" && error.length > 0 ? error : fallback),
onSome: ({ message }) => message,
});
// ---------------------------------------------------------------------------
// Normalization
//
// Crash reporters (Sentry in cloud and in the desktop renderer) title and group
// an event from the reported value's `name`, `message` and `stack`. Every
// producer in this codebase starts from an Effect `Cause`, which is a plain
// object with none of the three: Sentry then synthesizes `'CauseImpl' captured
// as exception with keys: ...` and, having no message to group on, groups on
// the reporting function's own stack frame — collapsing unrelated frontend
// failures into a single message-less issue.
//
// `toReportableError` is the choke point that turns whatever a call site has
// into a real `Error` that still says what went wrong. It is idempotent: an
// `Error` in, the same `Error` out, so applying it at more than one layer is
// safe.
// ---------------------------------------------------------------------------
const contextLabel = (context: FrontendErrorContext): string =>
`${context.surface}/${context.action}`;
const hasText = (value: string | undefined): value is string =>
typeof value === "string" && value.trim().length > 0;
/** The best sentence available for a value that carries no usable message. */
const describeUnknown = (value: unknown, context: FrontendErrorContext): string =>
messageFromUnknown(value, context.message ?? contextLabel(context));
/** Effect's tagged errors identify themselves by `_tag`, not by `name`. */
const nameFromUnknown = (value: unknown, fallback: string): string =>
Option.match(decodeTaggedValue(value), {
onNone: () => fallback,
onSome: ({ _tag }) => _tag,
});
/** Keep the original value reachable without clobbering an existing chain. */
const withOriginalCause = (error: Error, original: unknown): Error => {
if (error.cause === undefined && original !== error) error.cause = original;
return error;
};
const errorFromValue = (value: unknown, context: FrontendErrorContext): Error => {
// oxlint-disable-next-line executor/no-error-constructor -- boundary: a crash-reporting transport only understands built-in Errors; this module is the adapter that produces them
const error = new Error(describeUnknown(value, context));
error.name = nameFromUnknown(value, context.surface);
return withOriginalCause(error, value);
};
const errorFromCause = (cause: Cause.Cause<unknown>, context: FrontendErrorContext): Error => {
// `prettyErrors` is the non-lossy conversion: it renders every reason —
// failures AND defects — as a freshly built `Error` that keeps the original's
// name, message and stack frames.
const [rendered] = Cause.prettyErrors(cause);
if (rendered === undefined) return errorFromValue(Cause.squash(cause), context);
if (!hasText(rendered.message)) {
// A tagged error declared without a `message` field renders with an empty
// one — that is what produced `FrontendHandledError: No error message`.
// The rendered Error is ours, built just above, so filling it in cannot
// mutate a value a call site still holds, and assigning (rather than
// rebuilding) keeps the captured stack frames intact.
rendered.message = describeUnknown(Cause.squash(cause), context);
}
return withOriginalCause(rendered, cause);
};
export const toReportableError = (error: unknown, context: FrontendErrorContext): Error => {
if (Cause.isCause(error)) return errorFromCause(error, context);
// oxlint-disable-next-line executor/no-instanceof-error -- boundary: the report is already whatever a call site threw; deciding whether the transport can consume it as-is is this adapter's whole job
if (error instanceof Error) {
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed to Error above; an empty message is exactly the case being repaired
if (hasText(error.message)) return error;
// Never mutate an Error a call site owns: rebuild it, carrying the stack.
// oxlint-disable-next-line executor/no-error-constructor -- boundary: a crash-reporting transport only understands built-in Errors
const titled = new Error(describeUnknown(error, context));
titled.name = error.name;
if (typeof error.stack === "string") titled.stack = error.stack;
return withOriginalCause(titled, error);
}
return errorFromValue(error, context);
};
/** Wraps a reporter so it can only ever be handed a titled `Error`. */
const normalizing =
(reporter: FrontendErrorReporter): FrontendErrorReporter =>
(error, context) => {
reporter(toReportableError(error, context), context);
};
// ---------------------------------------------------------------------------
// Reporters
// ---------------------------------------------------------------------------
/**
* The fallback transport: re-throw the report as a global `error` event, which
* an initialized crash reporter's global handlers pick up. Used by self-host
* builds and by the desktop renderer before its Sentry client is ready.
*/
export const reportViaGlobalErrorEvent: FrontendErrorReporter = (error, context) => {
if (typeof globalThis.reportError !== "function") return;
const reportable = toReportableError(error, context);
globalThis.reportError(
new FrontendHandledError({
message: `${contextLabel(context)}: ${reportable.message}`,
cause: reportable,
context,
}),
);
};
export type FrontendErrorScope = {
readonly setTag: (key: string, value: string) => unknown;
readonly setContext: (key: string, value: Record<string, unknown> | null) => unknown;
};
/**
* The one Sentry-shaped reporter both product shells use. `captureException`
* stays a parameter so this module never depends on a Sentry package — cloud
* passes `@sentry/react`'s, the desktop renderer passes the lazily imported
* `@sentry/browser` one.
*/
export const createSentryFrontendErrorReporter =
(
captureException: (error: Error, applyScope: (scope: FrontendErrorScope) => void) => void,
): FrontendErrorReporter =>
(error, context) => {
captureException(toReportableError(error, context), (scope) => {
scope.setTag("executor.ui.surface", context.surface);
scope.setTag("executor.ui.action", context.action);
scope.setTag("executor.ui.severity", context.severity ?? "error");
scope.setContext("executor.ui", {
surface: context.surface,
action: context.action,
message: context.message,
metadata: context.metadata,
});
});
};
const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>(
normalizing(reportViaGlobalErrorEvent),
);
let currentFrontendErrorReporter = normalizing(reportViaGlobalErrorEvent);
export const reportHandledFrontendError = (error: unknown, context: FrontendErrorContext): void => {
currentFrontendErrorReporter(error, context);
};
export const FrontendErrorReporterProvider = (
props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>,
) => {
// Memoized so the wrapper keeps a stable identity across renders: consumers
// list it in `useCallback` dependencies.
const reporter = React.useMemo(
() => normalizing(props.reporter ?? reportViaGlobalErrorEvent),
[props.reporter],
);
currentFrontendErrorReporter = reporter;
return (
<FrontendErrorReporterContext.Provider value={reporter}>
{props.children}
</FrontendErrorReporterContext.Provider>
);
};
export const useReportHandledError = (): FrontendErrorReporter =>
React.useContext(FrontendErrorReporterContext);
export const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
onNone: () => fallback,
onSome: ({ message }) => message,
});
export const reportExitFailure = (
report: FrontendErrorReporter,
exit: Exit.Exit<unknown, unknown>,
context: FrontendErrorContext,
): void => {
if (!Exit.isFailure(exit)) return;
report(toReportableError(exit.cause, context), context);
};
export const useErrorMessageFromExit = (): ((
exit: Exit.Exit<unknown, unknown>,
fallback: string,
context: Omit<FrontendErrorContext, "message"> & { readonly message?: string },
) => string) => {
const report = useReportHandledError();
return React.useCallback(
(exit, fallback, context) => {
const message = messageFromExit(exit, fallback);
reportExitFailure(report, exit, { ...context, message: context.message ?? message });
return message;
},
[report],
);
};
export const reportCauseFailure = (
report: FrontendErrorReporter,
cause: Cause.Cause<unknown>,
context: FrontendErrorContext,
): void => {
report(toReportableError(cause, context), context);
};