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 +1 -1
- package/src/Await.tsx +85 -0
- package/src/ErrorScreen.tsx +98 -0
- package/src/app.tsx +78 -3
- package/src/env.ts +71 -0
- package/src/errors.ts +214 -0
- package/src/index.ts +10 -0
- package/src/resources.ts +17 -0
- package/src/service.ts +7 -2
- package/src/socket.ts +23 -1
- package/src/styles.css +247 -0
- package/src/vite/index.js +57 -0
package/package.json
CHANGED
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";
|
package/src/resources.ts
ADDED
|
@@ -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
|
|
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("/");
|