gantry-web 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gantry-web",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Frontend half of the Gantry desktop app framework: window chrome, native bridge, Tea runtime, Vite plugin",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/Await.tsx ADDED
@@ -0,0 +1,85 @@
1
+ // Loading states for Go calls. useCall() already exposes { loading,
2
+ // error, data }; Await is the declarative wrapper: show a developer-
3
+ // defined fallback (a spinner, <Skeleton/>, anything) while the call
4
+ // is in flight, an error card with retry when it fails, and the
5
+ // children once data is there.
6
+ //
7
+ // const users = useCall<User[]>("api", "listUsers");
8
+ // return (
9
+ // <Await call={users} fallback={<Skeleton lines={4} />}>
10
+ // {(list) => <ul>{list.map(...)}</ul>}
11
+ // </Await>
12
+ // );
13
+
14
+ import type { CSSProperties, ReactNode } from "react";
15
+ import type { CallResult } from "./service";
16
+
17
+ export interface AwaitProps<T> {
18
+ /** The in-flight call (from useCall, or anything CallResult-shaped). */
19
+ call: CallResult<T>;
20
+ /** What to show while loading: a spinner, <Skeleton/>, custom JSX.
21
+ * Default: <Skeleton lines={3}/>. */
22
+ fallback?: ReactNode;
23
+ /** Custom error rendering; default is a small card with a Retry
24
+ * button. */
25
+ renderError?: (error: string, code: string | null, reload: () => void) => ReactNode;
26
+ /** Renders the loaded data. */
27
+ children: (data: T) => ReactNode;
28
+ }
29
+
30
+ export function Await<T>({ call, fallback, renderError, children }: AwaitProps<T>) {
31
+ if (call.loading) {
32
+ return <>{fallback ?? <Skeleton lines={3} />}</>;
33
+ }
34
+ if (call.error !== null) {
35
+ if (renderError) return <>{renderError(call.error, call.code, call.reload)}</>;
36
+ return (
37
+ <div className="gantry-await-error">
38
+ <span className="gantry-await-error-message">{call.error}</span>
39
+ {call.code && <span className="gantry-await-error-code">{call.code}</span>}
40
+ <button className="gantry-await-retry" onClick={call.reload}>
41
+ Retry
42
+ </button>
43
+ </div>
44
+ );
45
+ }
46
+ return <>{children(call.data as T)}</>;
47
+ }
48
+
49
+ export interface SkeletonProps {
50
+ /** Placeholder text lines (the last one renders shorter). */
51
+ lines?: number;
52
+ /** Explicit size for a single block (a chart area, an avatar). */
53
+ width?: number | string;
54
+ height?: number | string;
55
+ /** Round the block fully (avatars). */
56
+ circle?: boolean;
57
+ className?: string;
58
+ style?: CSSProperties;
59
+ }
60
+
61
+ /**
62
+ * Skeleton renders shimmering placeholder blocks sized like the
63
+ * content they stand in for: <Skeleton lines={4}/> for text,
64
+ * <Skeleton width={240} height={120}/> for a block, circle for
65
+ * avatars.
66
+ */
67
+ export function Skeleton({ lines, width, height, circle, className, style }: SkeletonProps) {
68
+ if (lines === undefined && (width !== undefined || height !== undefined || circle)) {
69
+ const s: CSSProperties = {
70
+ width: width ?? (circle ? 40 : "100%"),
71
+ height: height ?? (circle ? 40 : 16),
72
+ borderRadius: circle ? "50%" : undefined,
73
+ ...style,
74
+ };
75
+ return <div className={"gantry-skeleton" + (className ? " " + className : "")} style={s} />;
76
+ }
77
+ const n = Math.max(1, lines ?? 3);
78
+ return (
79
+ <div className={"gantry-skeleton-lines" + (className ? " " + className : "")} style={style}>
80
+ {Array.from({ length: n }, (_, i) => (
81
+ <div key={i} className="gantry-skeleton" style={{ width: i === n - 1 && n > 1 ? "60%" : "100%" }} />
82
+ ))}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,98 @@
1
+ // The built-in error UI: a full-screen overlay for fatal render
2
+ // crashes and dismissible banners for non-fatal errors (Go panics,
3
+ // unhandled rejections). Development shows the full story - message,
4
+ // stack, the page, and the "what led here" action trail; production
5
+ // shows a friendly minimal card. Replace it per app via
6
+ // createApp({ errors: { screen: MyScreen } }).
7
+
8
+ import type { FC } from "react";
9
+ import type { GantryCrumb, GantryErrorInfo } from "./errors";
10
+
11
+ export interface ErrorScreenProps {
12
+ error: GantryErrorInfo;
13
+ /** Resolved app mode; null while unknown (treated as development). */
14
+ mode: "development" | "production" | null;
15
+ /** "fatal" = render crash overlay, "notice" = dismissible banner. */
16
+ variant: "fatal" | "notice";
17
+ onDismiss: () => void;
18
+ }
19
+
20
+ function ago(iso: string, now: number): string {
21
+ const t = Date.parse(iso);
22
+ if (Number.isNaN(t)) return "";
23
+ const s = Math.max(0, Math.round((now - t) / 1000));
24
+ if (s < 1) return "now";
25
+ if (s < 60) return `${s}s ago`;
26
+ return `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""} ago`;
27
+ }
28
+
29
+ function Trail({ trail }: { trail: GantryCrumb[] }) {
30
+ const now = Date.now();
31
+ return (
32
+ <div className="gantry-error-trail">
33
+ <div className="gantry-error-heading">What led here</div>
34
+ {trail.map((c, i) => (
35
+ <div key={i} className={"gantry-error-crumb" + (c.ok ? "" : " gantry-error-crumb-failed")}>
36
+ <span className="gantry-error-crumb-time">{ago(c.time, now)}</span>
37
+ <span className="gantry-error-crumb-type">{c.type}</span>
38
+ <span className="gantry-error-crumb-detail">{c.detail}</span>
39
+ </div>
40
+ ))}
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function Detail({ error }: { error: GantryErrorInfo }) {
46
+ return (
47
+ <>
48
+ <div className="gantry-error-meta">
49
+ <span className="gantry-error-kind">{error.kind}</span>
50
+ {error.code && <span className="gantry-error-code">{error.code}</span>}
51
+ {error.page && <span className="gantry-error-page">on {error.page}</span>}
52
+ {error.source && <span className="gantry-error-source">{error.source}</span>}
53
+ <span className="gantry-error-origin">{error.origin === "go" ? "Go" : "JS"}</span>
54
+ </div>
55
+ {error.stack && <pre className="gantry-error-stack">{error.stack}</pre>}
56
+ {error.componentStack && (
57
+ <>
58
+ <div className="gantry-error-heading">Component stack</div>
59
+ <pre className="gantry-error-stack">{error.componentStack}</pre>
60
+ </>
61
+ )}
62
+ {error.trail && error.trail.length > 0 && <Trail trail={error.trail} />}
63
+ </>
64
+ );
65
+ }
66
+
67
+ export const ErrorScreen: FC<ErrorScreenProps> = ({ error, mode, variant, onDismiss }) => {
68
+ const dev = mode !== "production";
69
+ if (variant === "fatal") {
70
+ return (
71
+ <div className="gantry-error-overlay">
72
+ <div className="gantry-error-card">
73
+ <div className="gantry-error-title">{dev ? "The page crashed" : "Something went wrong"}</div>
74
+ {dev ? <div className="gantry-error-message">{error.message}</div> : <div className="gantry-error-message">The app hit an unexpected error. Reloading usually fixes it.</div>}
75
+ {dev && <Detail error={error} />}
76
+ <div className="gantry-error-actions">
77
+ <button className="gantry-error-button" onClick={() => location.reload()}>
78
+ Reload
79
+ </button>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+ return (
86
+ <div className="gantry-error-banner">
87
+ <div className="gantry-error-banner-head">
88
+ <span className="gantry-error-banner-title">
89
+ {error.origin === "go" ? "Go error" : "Error"}: {error.message}
90
+ </span>
91
+ <button className="gantry-error-dismiss" onClick={onDismiss} aria-label="Dismiss">
92
+ ×
93
+ </button>
94
+ </div>
95
+ {dev && <Detail error={error} />}
96
+ </div>
97
+ );
98
+ };
package/src/app.tsx CHANGED
@@ -1,12 +1,15 @@
1
1
  /// <reference path="../types/index.d.ts" />
2
- import { StrictMode, useEffect, type FC, type ReactNode } from "react";
2
+ import { Component, StrictMode, useEffect, type ErrorInfo as ReactErrorInfo, type FC, type ReactNode } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { TitleBar, type TitleBarProps } from "./TitleBar";
5
5
  import { ResizeFrame } from "./ResizeFrame";
6
6
  import { installZoomGuard } from "./zoom";
7
- import { connect, ready } from "./socket";
7
+ import { connect, ready, callGo } from "./socket";
8
8
  import { useRoute, redirect } from "./router";
9
9
  import { setRegistry, type ComponentRegistry, type TeaComponentProps } from "./tea/Runtime";
10
+ import { installErrorHandlers, reportError, useGantryErrors, dismissNotice, clearFatal, setDevMode, type ErrorHandlingOptions, type GantryErrorInfo } from "./errors";
11
+ import { ErrorScreen, type ErrorScreenProps } from "./ErrorScreen";
12
+ import { fetchEnv, useMode } from "./env";
10
13
 
11
14
  /** The shape of a page module (a pages/<name>/<name>.tsx file). */
12
15
  export interface GantryPageModule {
@@ -72,6 +75,14 @@ export interface CreateAppOptions {
72
75
  socketURL?: string;
73
76
  /** Hide the TitleBar on every page (pages can also export chrome = false). */
74
77
  chrome?: boolean;
78
+ /**
79
+ * Crash detection and interception - on by default. A React render
80
+ * crash shows a full-screen error (full detail in development, a
81
+ * friendly card in production) instead of a white screen; Go panics
82
+ * and unhandled JS errors show dismissible notices in development.
83
+ * Replace the UI (screen), intercept (onError), or turn it off.
84
+ */
85
+ errors?: ErrorHandlingOptions & { screen?: FC<ErrorScreenProps> };
75
86
  }
76
87
 
77
88
  function keyClass(key: string): string {
@@ -134,6 +145,51 @@ function layoutsFor(page: GantryPage, chrome: boolean): FC<{ children?: ReactNod
134
145
  return out;
135
146
  }
136
147
 
148
+ // ErrorBoundary catches render crashes below it. It wraps the page
149
+ // content only - ResizeFrame and TitleBar stay outside, so a crashed
150
+ // page keeps its drag and close controls.
151
+ class ErrorBoundary extends Component<{ children: ReactNode }, { crashed: boolean }> {
152
+ state = { crashed: false };
153
+ static getDerivedStateFromError() {
154
+ return { crashed: true };
155
+ }
156
+ componentDidCatch(error: Error, info: ReactErrorInfo) {
157
+ reportError({
158
+ kind: "react-render",
159
+ code: "react.render",
160
+ message: error.message,
161
+ stack: error.stack,
162
+ componentStack: info.componentStack ?? undefined,
163
+ time: new Date().toISOString(),
164
+ origin: "js",
165
+ });
166
+ }
167
+ render() {
168
+ // The fatal overlay renders from the store (ErrorHost); the crashed
169
+ // subtree just goes away.
170
+ return this.state.crashed ? null : this.props.children;
171
+ }
172
+ }
173
+
174
+ // ErrorHost renders the (default or custom) error UI from the store:
175
+ // the fatal overlay and the notice banners.
176
+ function ErrorHost({ options }: { options: CreateAppOptions }) {
177
+ const { fatal, notices } = useGantryErrors();
178
+ const mode = useMode();
179
+ if (options.errors?.enabled === false) return null;
180
+ const Screen = options.errors?.screen ?? ErrorScreen;
181
+ return (
182
+ <>
183
+ {fatal && <Screen error={fatal} mode={mode} variant="fatal" onDismiss={clearFatal} />}
184
+ {notices.map((e, i) => (
185
+ // Newest first: the CSS shows only the first banner, dismissing
186
+ // it reveals the next.
187
+ <Screen key={i} error={e} mode={mode} variant="notice" onDismiss={() => dismissNotice(i)} />
188
+ )).reverse()}
189
+ </>
190
+ );
191
+ }
192
+
137
193
  function AppRoot({ options }: { options: CreateAppOptions }) {
138
194
  const path = useRoute();
139
195
  const page = match(path);
@@ -155,11 +211,15 @@ function AppRoot({ options }: { options: CreateAppOptions }) {
155
211
  for (const Layout of layoutsFor(page, chrome).reverse()) {
156
212
  content = <Layout>{content}</Layout>;
157
213
  }
214
+ // The boundary wraps the page and layouts only: a render crash takes
215
+ // the content down, never the window chrome - the error overlay
216
+ // appears and the TitleBar keeps drag/close working.
158
217
  return (
159
218
  <div className="gantry-app">
160
219
  <ResizeFrame prefix={options.prefix} />
161
220
  {chrome && <TitleBar title={options.title} prefix={options.prefix} {...options.titleBar} />}
162
- {content}
221
+ <ErrorBoundary>{content}</ErrorBoundary>
222
+ <ErrorHost options={options} />
163
223
  </div>
164
224
  );
165
225
  }
@@ -180,7 +240,22 @@ export function createApp(app: GantryAppModule, options: CreateAppOptions = {}):
180
240
  options = { ...options, ...reg.appConfig.default };
181
241
  }
182
242
  installZoomGuard();
243
+ installErrorHandlers(options.errors ?? {});
183
244
  connect(options.socketURL);
245
+ // Resolve the mode for the error UI (dev shows full detail), and
246
+ // surface a crash from the previous run if Go recorded one.
247
+ if (options.errors?.enabled !== false) {
248
+ fetchEnv()
249
+ .then((env) => setDevMode(env.mode === "development"))
250
+ .catch(() => {});
251
+ callGo("gantry", "errors")
252
+ .then((list) => {
253
+ const errs = (list ?? []) as (Omit<GantryErrorInfo, "origin"> & { kind: string })[];
254
+ const crash = errs.filter((e) => e.kind === "process-crash").at(-1);
255
+ if (crash) reportError({ ...crash, origin: "go" });
256
+ })
257
+ .catch(() => {});
258
+ }
184
259
 
185
260
  const registry: ComponentRegistry = {};
186
261
  for (const [key, mod] of Object.entries(reg.components)) {
package/src/env.ts ADDED
@@ -0,0 +1,71 @@
1
+ // The app's mode and declared args, served by the built-in "gantry"
2
+ // service the Go side registers in gantry.Run. Mode is "development"
3
+ // under gantry dev and "production" in a built binary; args are the
4
+ // gantry.json "args" declarations resolved from their environment
5
+ // variables. Use them to gate pages and features.
6
+
7
+ import { useEffect, useState } from "react";
8
+ import { callGo } from "./socket";
9
+
10
+ export type AppMode = "development" | "production";
11
+
12
+ export interface AppEnv {
13
+ /** "development" under gantry dev, "production" in a built binary. */
14
+ mode: AppMode;
15
+ /** Every declared arg (gantry.json "args"), resolved: env > default. */
16
+ args: Record<string, string | number | boolean>;
17
+ }
18
+
19
+ let cached: AppEnv | null = null;
20
+ let pending: Promise<AppEnv> | null = null;
21
+
22
+ /** fetchEnv resolves the mode and declared args once and caches them -
23
+ * the values cannot change while the app runs. */
24
+ export function fetchEnv(): Promise<AppEnv> {
25
+ if (cached) return Promise.resolve(cached);
26
+ pending ??= callGo("gantry", "env")
27
+ .then((v) => (cached = v as AppEnv))
28
+ .catch((err) => {
29
+ pending = null; // retry on the next call
30
+ throw err;
31
+ });
32
+ return pending;
33
+ }
34
+
35
+ /**
36
+ * useEnv returns { mode, args } - null until the first fetch resolves.
37
+ *
38
+ * const env = useEnv();
39
+ * if (env?.args["mock-data"]) ...
40
+ */
41
+ export function useEnv(): AppEnv | null {
42
+ const [env, setEnv] = useState(cached);
43
+ useEffect(() => {
44
+ if (env) return;
45
+ let alive = true;
46
+ fetchEnv()
47
+ .then((v) => {
48
+ if (alive) setEnv(v);
49
+ })
50
+ .catch(() => {});
51
+ return () => {
52
+ alive = false;
53
+ };
54
+ }, [env]);
55
+ return env;
56
+ }
57
+
58
+ /** useMode returns the app mode - null until the first fetch resolves. */
59
+ export function useMode(): AppMode | null {
60
+ return useEnv()?.mode ?? null;
61
+ }
62
+
63
+ /**
64
+ * useArg returns one declared arg's value - undefined until the first
65
+ * fetch resolves (and for undeclared names).
66
+ *
67
+ * const host = useArg<string>("api-host");
68
+ */
69
+ export function useArg<T extends string | number | boolean = string | number | boolean>(name: string): T | undefined {
70
+ return useEnv()?.args[name] as T | undefined;
71
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,214 @@
1
+ // The frontend half of Gantry's error pipeline. createApp installs the
2
+ // window-level handlers and the ErrorBoundary reports render crashes
3
+ // here; Go-side errors arrive over the websocket as {"t":"error"}
4
+ // frames. Every error lands in one store that drives the (default or
5
+ // custom) error UI, and JS-side errors are reported back to Go so the
6
+ // gantry dev terminal, the ring buffer and the app's OnError hook see
7
+ // them too.
8
+
9
+ import { useSyncExternalStore } from "react";
10
+ import { callGo, onServerError } from "./socket";
11
+
12
+ /** One breadcrumb in an error's trail (recorded by the Go server). */
13
+ export interface GantryCrumb {
14
+ time: string;
15
+ type: "navigate" | "event" | "call" | "state" | "custom";
16
+ detail: string;
17
+ ok: boolean;
18
+ }
19
+
20
+ /** One captured error, from either side of the app. */
21
+ export interface GantryErrorInfo {
22
+ /** "react-render" | "js-error" | "js-rejection" | the Go kinds
23
+ * ("call-panic", "process-crash", ...). */
24
+ kind: string;
25
+ /** gerr code: "panic.call", "js.error", ... */
26
+ code?: string;
27
+ /** Where it happened: "pages/index.increment", a component stack head. */
28
+ source?: string;
29
+ message: string;
30
+ stack?: string;
31
+ /** React component stack, for render crashes. */
32
+ componentStack?: string;
33
+ time: string;
34
+ /** Active page when it fired. */
35
+ page?: string;
36
+ /** Recent user actions leading up to it, oldest first. */
37
+ trail?: GantryCrumb[];
38
+ /** Which side captured it. */
39
+ origin: "go" | "js";
40
+ }
41
+
42
+ export interface ErrorHandlingOptions {
43
+ /** false turns the whole frontend pipeline off (default true). */
44
+ enabled?: boolean;
45
+ /** Replace the built-in error screen (see ErrorScreenProps). */
46
+ screen?: unknown; // typed as FC<ErrorScreenProps> in app.tsx to avoid a cycle
47
+ /** Intercept every error; return false to suppress the default UI. */
48
+ onError?: (e: GantryErrorInfo) => boolean | void;
49
+ /** Report JS-side errors to Go (terminal log, ring buffer, OnError
50
+ * hook). Default true. */
51
+ reportToGo?: boolean;
52
+ /** Show Go-side errors (panics) as dismissible notices: true, false,
53
+ * or only in development mode (the default). */
54
+ showGoErrors?: boolean | "development";
55
+ }
56
+
57
+ interface ErrorStore {
58
+ /** A render crash: the page is down, the overlay owns the screen. */
59
+ fatal: GantryErrorInfo | null;
60
+ /** Non-fatal errors (Go panics, unhandled rejections): banners. */
61
+ notices: GantryErrorInfo[];
62
+ }
63
+
64
+ let store: ErrorStore = { fatal: null, notices: [] };
65
+ const listeners = new Set<() => void>();
66
+ let opts: ErrorHandlingOptions = {};
67
+ let installed = false;
68
+ let devMode: boolean | null = null; // resolved from the env service
69
+
70
+ // StrictMode and error bubbling can fire the same error twice within a
71
+ // tick; dedupe by message+stack in a short window.
72
+ let lastSig = "";
73
+ let lastSigAt = 0;
74
+
75
+ function emit(): void {
76
+ listeners.forEach((fn) => fn());
77
+ }
78
+
79
+ function setStore(next: ErrorStore): void {
80
+ store = next;
81
+ emit();
82
+ }
83
+
84
+ /** setDevMode feeds the resolved mode in (createApp does this); the
85
+ * default UI shows full detail only in development. */
86
+ export function setDevMode(dev: boolean): void {
87
+ devMode = dev;
88
+ emit();
89
+ }
90
+
91
+ export function isDevMode(): boolean | null {
92
+ return devMode;
93
+ }
94
+
95
+ function shouldShowGoError(): boolean {
96
+ const pref = opts.showGoErrors ?? "development";
97
+ if (pref === "development") return devMode !== false; // unknown mode: show
98
+ return pref;
99
+ }
100
+
101
+ /**
102
+ * reportError feeds one error into the pipeline: dedupe, the app's
103
+ * onError hook, the store (driving the error UI), and - for JS-side
104
+ * errors - a best-effort report to Go.
105
+ */
106
+ export function reportError(e: GantryErrorInfo): void {
107
+ if (opts.enabled === false) return;
108
+ const sig = e.message + "\n" + (e.stack ?? "");
109
+ const now = Date.now();
110
+ if (sig === lastSig && now - lastSigAt < 500) return;
111
+ lastSig = sig;
112
+ lastSigAt = now;
113
+
114
+ if (!e.page && e.origin === "js") e.page = location.pathname;
115
+ if (!e.time) e.time = new Date().toISOString();
116
+
117
+ if (e.origin === "js" && opts.reportToGo !== false) {
118
+ // Best-effort: the socket may be down; the console always has it.
119
+ callGo("gantry", "reportError", e).catch(() => {});
120
+ }
121
+
122
+ const suppress = opts.onError?.(e) === false;
123
+ if (suppress) return;
124
+
125
+ if (e.kind === "react-render") {
126
+ setStore({ ...store, fatal: e });
127
+ return;
128
+ }
129
+ if (e.origin === "go" && !shouldShowGoError()) {
130
+ console.warn(`gantry: go error [${e.kind}] ${e.source ?? ""}: ${e.message}`);
131
+ return;
132
+ }
133
+ if (e.origin === "js" && devMode === false) {
134
+ // Production: non-fatal JS errors stay in the console.
135
+ console.warn(`gantry: error [${e.kind}]: ${e.message}`);
136
+ return;
137
+ }
138
+ setStore({ ...store, notices: [...store.notices, e] });
139
+ }
140
+
141
+ /** dismissNotice removes one banner (or all, with no index). */
142
+ export function dismissNotice(index?: number): void {
143
+ if (index === undefined) {
144
+ setStore({ ...store, notices: [] });
145
+ } else {
146
+ setStore({ ...store, notices: store.notices.filter((_, i) => i !== index) });
147
+ }
148
+ }
149
+
150
+ /** clearFatal drops the fatal overlay (the Reload button uses a real
151
+ * reload instead; this is for custom screens that can recover). */
152
+ export function clearFatal(): void {
153
+ setStore({ ...store, fatal: null });
154
+ }
155
+
156
+ /** useGantryErrors subscribes the error UI to the store. */
157
+ export function useGantryErrors(): ErrorStore {
158
+ return useSyncExternalStore(
159
+ (fn) => {
160
+ listeners.add(fn);
161
+ return () => listeners.delete(fn);
162
+ },
163
+ () => store,
164
+ );
165
+ }
166
+
167
+ /**
168
+ * addBreadcrumb records an app-specific action in the Go-side error
169
+ * trail ("sync started"), alongside the automatic navigate/event/call
170
+ * crumbs. Fire-and-forget.
171
+ */
172
+ export function addBreadcrumb(detail: string): void {
173
+ callGo("gantry", "breadcrumb", { detail }).catch(() => {});
174
+ }
175
+
176
+ /**
177
+ * installErrorHandlers wires the window-level capture: uncaught JS
178
+ * errors and unhandled promise rejections. createApp calls this once.
179
+ */
180
+ export function installErrorHandlers(options: ErrorHandlingOptions): void {
181
+ opts = options;
182
+ if (installed || options.enabled === false) return;
183
+ installed = true;
184
+
185
+ // Go-side errors (panics, last-run crashes) arrive as {"t":"error"}
186
+ // frames.
187
+ onServerError((p) => {
188
+ const e = p as Omit<GantryErrorInfo, "origin">;
189
+ if (e && e.message) reportError({ ...e, origin: "go" });
190
+ });
191
+
192
+ window.addEventListener("error", (ev) => {
193
+ reportError({
194
+ kind: "js-error",
195
+ code: "js.error",
196
+ message: ev.message || String(ev.error ?? "unknown error"),
197
+ stack: ev.error instanceof Error ? ev.error.stack : undefined,
198
+ source: ev.filename ? `${ev.filename}:${ev.lineno}` : undefined,
199
+ time: new Date().toISOString(),
200
+ origin: "js",
201
+ });
202
+ });
203
+ window.addEventListener("unhandledrejection", (ev) => {
204
+ const reason = ev.reason;
205
+ reportError({
206
+ kind: "js-rejection",
207
+ code: "js.rejection",
208
+ message: reason instanceof Error ? reason.message : String(reason),
209
+ stack: reason instanceof Error ? reason.stack : undefined,
210
+ time: new Date().toISOString(),
211
+ origin: "js",
212
+ });
213
+ });
214
+ }
package/src/index.ts CHANGED
@@ -16,7 +16,17 @@ export type { Paired } from "./paired";
16
16
  export { service, useService, useCall } from "./service";
17
17
  export type { Service, CallResult } from "./service";
18
18
  export { useGoState } from "./gostate";
19
+ export { resourceURL } from "./resources";
19
20
  export { useAppInfo, fetchAppInfo } from "./appinfo";
20
21
  export type { AppInfo } from "./appinfo";
22
+ export { useEnv, useMode, useArg, fetchEnv } from "./env";
23
+ export type { AppEnv, AppMode } from "./env";
24
+ export { reportError, addBreadcrumb, useGantryErrors, dismissNotice, clearFatal } from "./errors";
25
+ export type { GantryErrorInfo, GantryCrumb, ErrorHandlingOptions } from "./errors";
26
+ export { ErrorScreen } from "./ErrorScreen";
27
+ export type { ErrorScreenProps } from "./ErrorScreen";
28
+ export { GantryCallError } from "./socket";
29
+ export { Await, Skeleton } from "./Await";
30
+ export type { AwaitProps, SkeletonProps } from "./Await";
21
31
  export { TeaView } from "./tea/Runtime";
22
32
  export type { TeaComponentProps, ComponentRegistry } from "./tea/Runtime";
@@ -0,0 +1,17 @@
1
+ // Embedded app resources. Files placed in the app's resources/ directory
2
+ // are embedded into the binary once and served by the Go side at
3
+ // /resources/<path> (in gantry dev, the Vite plugin serves the same
4
+ // folder live off disk). Reference them by URL:
5
+ //
6
+ // import { resourceURL } from "gantry-web";
7
+ // <img src={resourceURL("img/logo.png")} />
8
+ // const cfg = await fetch(resourceURL("cfg.json")).then((r) => r.json());
9
+ // // fonts: `url(${resourceURL("fonts/inter.woff2")})`
10
+ //
11
+ // The same file is reachable from the paired .go code via
12
+ // gantry.Resource("img/logo.png") / gantry.Resources().
13
+
14
+ /** URL of an embedded resource, e.g. resourceURL("img/logo.png") -> "/resources/img/logo.png". */
15
+ export function resourceURL(name: string): string {
16
+ return "/resources/" + String(name).replace(/^\/+/, "");
17
+ }
package/src/service.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { callGo, connect } from "./socket";
2
+ import { callGo, connect, GantryCallError } from "./socket";
3
3
 
4
4
  export interface Service {
5
5
  /** call awaits a Go function registered on this service (ui.Calls). */
@@ -35,6 +35,8 @@ export function useService(name: string): Service {
35
35
  export interface CallResult<T> {
36
36
  data: T | undefined;
37
37
  error: string | null;
38
+ /** The gerr code of a failed call ("panic.call", "auth.expired", ...). */
39
+ code: string | null;
38
40
  loading: boolean;
39
41
  /** reload re-runs the call. */
40
42
  reload: () => void;
@@ -48,6 +50,7 @@ export interface CallResult<T> {
48
50
  export function useCall<T = unknown>(key: string, name: string, payload?: unknown): CallResult<T> {
49
51
  const [data, setData] = useState<T | undefined>(undefined);
50
52
  const [error, setError] = useState<string | null>(null);
53
+ const [code, setCode] = useState<string | null>(null);
51
54
  const [loading, setLoading] = useState(true);
52
55
  const [tick, setTick] = useState(0);
53
56
  const alive = useRef(true);
@@ -57,6 +60,7 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
57
60
  alive.current = true;
58
61
  setLoading(true);
59
62
  setError(null);
63
+ setCode(null);
60
64
  callGo(key, name, payload)
61
65
  .then((v) => {
62
66
  if (alive.current) {
@@ -67,6 +71,7 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
67
71
  .catch((e: Error) => {
68
72
  if (alive.current) {
69
73
  setError(e.message);
74
+ if (e instanceof GantryCallError && e.code) setCode(e.code);
70
75
  setLoading(false);
71
76
  }
72
77
  });
@@ -78,5 +83,5 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
78
83
  }, [key, name, payloadKey, tick]);
79
84
 
80
85
  const reload = useCallback(() => setTick((t) => t + 1), []);
81
- return { data, error, loading, reload };
86
+ return { data, error, code, loading, reload };
82
87
  }
package/src/socket.ts CHANGED
@@ -3,6 +3,25 @@
3
3
  // backoff (webview reloads, dev server restarts, HMR) and re-announces
4
4
  // the active page on every connect so the server always re-renders.
5
5
 
6
+ /** GantryCallError rejects a failed callGo with the gerr code the Go
7
+ * side attached ("panic.call", or the code of the returned error), so
8
+ * callers can switch on it. */
9
+ export class GantryCallError extends Error {
10
+ code?: string;
11
+ constructor(message: string, code?: string) {
12
+ super(message);
13
+ this.name = "GantryCallError";
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ /** The (single) consumer of server {"t":"error"} frames; errors.ts
19
+ * subscribes. Registered lazily to keep this module UI-free. */
20
+ let errorListener: ((p: unknown) => void) | null = null;
21
+ export function onServerError(fn: (p: unknown) => void): void {
22
+ errorListener = fn;
23
+ }
24
+
6
25
  export type WireNode = {
7
26
  type: string;
8
27
  key?: string;
@@ -63,6 +82,7 @@ function open(): void {
63
82
  id?: string;
64
83
  ok?: boolean;
65
84
  err?: string;
85
+ code?: string;
66
86
  };
67
87
  try {
68
88
  msg = JSON.parse(e.data as string);
@@ -79,11 +99,13 @@ function open(): void {
79
99
  pending.delete(msg.id);
80
100
  clearTimeout(p.timer);
81
101
  if (msg.ok) p.resolve(msg.p);
82
- else p.reject(new Error(msg.err ?? "call failed"));
102
+ else p.reject(new GantryCallError(msg.err ?? "call failed", msg.code));
83
103
  }
84
104
  } else if (msg.t === "state" && msg.key) {
85
105
  stateValues.set(msg.key, msg.p);
86
106
  stateListeners.get(msg.key)?.forEach((fn) => fn());
107
+ } else if (msg.t === "error") {
108
+ errorListener?.(msg.p);
87
109
  }
88
110
  };
89
111
  sock.onclose = () => {
package/src/styles.css CHANGED
@@ -307,3 +307,250 @@
307
307
  min-height: 0;
308
308
  overflow: auto;
309
309
  }
310
+
311
+ /* ---- error UI (ErrorScreen.tsx) ---- */
312
+
313
+ .gantry-error-overlay {
314
+ position: fixed;
315
+ inset: 0;
316
+ z-index: 9998;
317
+ display: flex;
318
+ align-items: center;
319
+ justify-content: center;
320
+ padding: 24px;
321
+ background: color-mix(in srgb, var(--gantry-bg) 82%, black);
322
+ }
323
+
324
+ .gantry-error-card {
325
+ max-width: 720px;
326
+ max-height: 80vh;
327
+ overflow: auto;
328
+ padding: 20px 24px;
329
+ border: 1px solid color-mix(in srgb, var(--gantry-fg) 15%, transparent);
330
+ border-radius: 10px;
331
+ background: var(--gantry-bg);
332
+ color: var(--gantry-fg);
333
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
334
+ }
335
+
336
+ .gantry-error-title {
337
+ font-size: 17px;
338
+ font-weight: 600;
339
+ color: #e5484d;
340
+ margin-bottom: 8px;
341
+ }
342
+
343
+ .gantry-error-message {
344
+ font-size: 14px;
345
+ margin-bottom: 12px;
346
+ word-break: break-word;
347
+ }
348
+
349
+ .gantry-error-meta {
350
+ display: flex;
351
+ flex-wrap: wrap;
352
+ gap: 6px;
353
+ margin-bottom: 10px;
354
+ font-size: 11px;
355
+ }
356
+
357
+ .gantry-error-meta > span {
358
+ padding: 2px 7px;
359
+ border-radius: 99px;
360
+ background: color-mix(in srgb, var(--gantry-fg) 8%, transparent);
361
+ opacity: 0.85;
362
+ }
363
+
364
+ .gantry-error-code {
365
+ font-family: var(--gantry-mono, ui-monospace, monospace);
366
+ }
367
+
368
+ .gantry-error-heading {
369
+ font-size: 11px;
370
+ font-weight: 600;
371
+ text-transform: uppercase;
372
+ letter-spacing: 0.06em;
373
+ opacity: 0.6;
374
+ margin: 12px 0 6px;
375
+ }
376
+
377
+ .gantry-error-stack {
378
+ margin: 0;
379
+ padding: 10px 12px;
380
+ border-radius: 6px;
381
+ background: color-mix(in srgb, var(--gantry-fg) 6%, transparent);
382
+ font-family: var(--gantry-mono, ui-monospace, monospace);
383
+ font-size: 11.5px;
384
+ line-height: 1.5;
385
+ white-space: pre-wrap;
386
+ word-break: break-word;
387
+ max-height: 260px;
388
+ overflow: auto;
389
+ }
390
+
391
+ .gantry-error-trail {
392
+ margin-top: 4px;
393
+ }
394
+
395
+ .gantry-error-crumb {
396
+ display: flex;
397
+ gap: 8px;
398
+ align-items: baseline;
399
+ padding: 2px 0;
400
+ font-size: 12px;
401
+ }
402
+
403
+ .gantry-error-crumb-failed .gantry-error-crumb-detail,
404
+ .gantry-error-crumb-failed .gantry-error-crumb-type {
405
+ color: #e5484d;
406
+ }
407
+
408
+ .gantry-error-crumb-time {
409
+ min-width: 64px;
410
+ text-align: right;
411
+ font-variant-numeric: tabular-nums;
412
+ opacity: 0.5;
413
+ font-size: 11px;
414
+ }
415
+
416
+ .gantry-error-crumb-type {
417
+ min-width: 62px;
418
+ font-size: 11px;
419
+ opacity: 0.65;
420
+ }
421
+
422
+ .gantry-error-crumb-detail {
423
+ font-family: var(--gantry-mono, ui-monospace, monospace);
424
+ word-break: break-word;
425
+ }
426
+
427
+ .gantry-error-actions {
428
+ margin-top: 16px;
429
+ display: flex;
430
+ gap: 8px;
431
+ }
432
+
433
+ .gantry-error-button {
434
+ padding: 6px 16px;
435
+ border: 1px solid color-mix(in srgb, var(--gantry-fg) 20%, transparent);
436
+ border-radius: 6px;
437
+ background: var(--gantry-accent);
438
+ color: white;
439
+ font-size: 13px;
440
+ cursor: pointer;
441
+ }
442
+
443
+ .gantry-error-banner {
444
+ position: fixed;
445
+ right: 16px;
446
+ bottom: 16px;
447
+ z-index: 9999;
448
+ width: min(520px, calc(100vw - 32px));
449
+ max-height: 45vh;
450
+ overflow: auto;
451
+ padding: 12px 14px;
452
+ border: 1px solid #e5484d;
453
+ border-radius: 8px;
454
+ background: var(--gantry-bg);
455
+ color: var(--gantry-fg);
456
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.3);
457
+ }
458
+
459
+ .gantry-error-banner + .gantry-error-banner {
460
+ display: none; /* stacked banners: newest wins, dismiss reveals the next */
461
+ }
462
+
463
+ .gantry-error-banner-head {
464
+ display: flex;
465
+ align-items: flex-start;
466
+ gap: 10px;
467
+ justify-content: space-between;
468
+ }
469
+
470
+ .gantry-error-banner-title {
471
+ font-size: 13px;
472
+ font-weight: 500;
473
+ word-break: break-word;
474
+ }
475
+
476
+ .gantry-error-dismiss {
477
+ border: none;
478
+ background: none;
479
+ color: var(--gantry-fg);
480
+ opacity: 0.6;
481
+ font-size: 16px;
482
+ line-height: 1;
483
+ cursor: pointer;
484
+ padding: 0 2px;
485
+ }
486
+
487
+ .gantry-error-dismiss:hover {
488
+ opacity: 1;
489
+ }
490
+
491
+ /* ---- loading states (Await.tsx) ---- */
492
+
493
+ .gantry-skeleton {
494
+ height: 14px;
495
+ border-radius: 4px;
496
+ background: linear-gradient(
497
+ 90deg,
498
+ color-mix(in srgb, var(--gantry-fg) 7%, transparent) 25%,
499
+ color-mix(in srgb, var(--gantry-fg) 13%, transparent) 50%,
500
+ color-mix(in srgb, var(--gantry-fg) 7%, transparent) 75%
501
+ );
502
+ background-size: 200% 100%;
503
+ animation: gantry-skeleton-shimmer 1.4s ease-in-out infinite;
504
+ }
505
+
506
+ .gantry-skeleton-lines {
507
+ display: flex;
508
+ flex-direction: column;
509
+ gap: 10px;
510
+ }
511
+
512
+ @keyframes gantry-skeleton-shimmer {
513
+ 0% {
514
+ background-position: 200% 0;
515
+ }
516
+ 100% {
517
+ background-position: -200% 0;
518
+ }
519
+ }
520
+
521
+ @media (prefers-reduced-motion: reduce) {
522
+ .gantry-skeleton {
523
+ animation: none;
524
+ }
525
+ }
526
+
527
+ .gantry-await-error {
528
+ display: flex;
529
+ align-items: center;
530
+ gap: 10px;
531
+ padding: 10px 12px;
532
+ border: 1px solid color-mix(in srgb, #e5484d 40%, transparent);
533
+ border-radius: 6px;
534
+ font-size: 13px;
535
+ }
536
+
537
+ .gantry-await-error-code {
538
+ font-family: var(--gantry-mono, ui-monospace, monospace);
539
+ font-size: 11px;
540
+ opacity: 0.6;
541
+ }
542
+
543
+ .gantry-await-retry {
544
+ margin-left: auto;
545
+ padding: 3px 12px;
546
+ border: 1px solid color-mix(in srgb, var(--gantry-fg) 20%, transparent);
547
+ border-radius: 5px;
548
+ background: none;
549
+ color: var(--gantry-fg);
550
+ font-size: 12px;
551
+ cursor: pointer;
552
+ }
553
+
554
+ .gantry-await-retry:hover {
555
+ border-color: var(--gantry-accent);
556
+ }
package/src/vite/index.js CHANGED
@@ -19,6 +19,36 @@ import path from "node:path";
19
19
  const VIRTUAL_ID = "virtual:gantry-app";
20
20
  const RESOLVED_ID = "\0" + VIRTUAL_ID;
21
21
 
22
+ // Content types for the dev /resources/ middleware. Keep in rough sync
23
+ // with the extensions developers drop in resources/ (images, fonts,
24
+ // data). Unknown types fall back to a generic binary stream.
25
+ const RESOURCE_MIME = {
26
+ ".png": "image/png",
27
+ ".jpg": "image/jpeg",
28
+ ".jpeg": "image/jpeg",
29
+ ".gif": "image/gif",
30
+ ".webp": "image/webp",
31
+ ".svg": "image/svg+xml",
32
+ ".ico": "image/x-icon",
33
+ ".avif": "image/avif",
34
+ ".woff": "font/woff",
35
+ ".woff2": "font/woff2",
36
+ ".ttf": "font/ttf",
37
+ ".otf": "font/otf",
38
+ ".json": "application/json",
39
+ ".txt": "text/plain; charset=utf-8",
40
+ ".css": "text/css; charset=utf-8",
41
+ ".wasm": "application/wasm",
42
+ ".mp3": "audio/mpeg",
43
+ ".mp4": "video/mp4",
44
+ ".webm": "video/webm",
45
+ ".pdf": "application/pdf",
46
+ };
47
+
48
+ function resourceMime(file) {
49
+ return RESOURCE_MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
50
+ }
51
+
22
52
  /**
23
53
  * @param {{ appRoot?: string, goPort?: number }} [opts]
24
54
  * @returns {import("vite").Plugin}
@@ -218,6 +248,33 @@ export function gantry(opts = {}) {
218
248
  },
219
249
 
220
250
  configureServer(server) {
251
+ // Serve /resources/<path> live off <appRoot>/resources during dev,
252
+ // the same tree the Go side embeds and serves in production. Kept
253
+ // off the Go server (no proxy) so edits appear without a rebuild.
254
+ const resourcesDir = path.join(appRoot, "resources");
255
+ server.middlewares.use((req, res, next) => {
256
+ const url = (req.url || "").split("?")[0];
257
+ if (!url.startsWith("/resources/")) return next();
258
+ const rel = decodeURIComponent(url.slice("/resources/".length));
259
+ const abs = path.join(resourcesDir, rel);
260
+ // Contain the request inside resources/ (reject ../ traversal).
261
+ const rootPrefix = resourcesDir + path.sep;
262
+ if (abs !== resourcesDir && !abs.startsWith(rootPrefix)) {
263
+ res.statusCode = 403;
264
+ return res.end("Forbidden");
265
+ }
266
+ let stat;
267
+ try {
268
+ stat = fs.statSync(abs);
269
+ } catch {
270
+ return next();
271
+ }
272
+ if (!stat.isFile()) return next();
273
+ res.setHeader("Content-Type", resourceMime(abs));
274
+ res.setHeader("Content-Length", stat.size);
275
+ fs.createReadStream(abs).pipe(res);
276
+ });
277
+
221
278
  server.watcher.add(appRoot);
222
279
  const refresh = (file) => {
223
280
  const f = file.split(path.sep).join("/");