Skip to content

Commit 7cb249f

Browse files
wzrdxaledefra
authored andcommitted
feat: Improve error handling by creating error boundries and improving API error handling
1 parent 02e42d9 commit 7cb249f

File tree

8 files changed

+213
-41
lines changed

8 files changed

+213
-41
lines changed

AGENTS.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
# Repository Guidelines
22

33
## Project Structure & Module Organization
4-
The Vite + React app lives in `src/` with `main.tsx` mounting `App.tsx`. Feature-specific UI is grouped under `src/components` (e.g., `create-project`, `deeploys`), while route screens sit in `src/pages`. Shared hooks/utilities are in `src/lib` and `src/shared`, smart-contract adapters in `src/blockchain`, and schema/types in `src/schemas`, `src/data`, and `src/typedefs`. Static assets live in `public/` and `src/assets/`; builds land in `dist/`.
4+
This is a Next.js App Router project. Routes and layouts live in `app/` (file-based routing via `page.tsx`, `layout.tsx`, `not-found.tsx`, and dynamic segments like `[id]`). The codebase uses route groups for organization, e.g. `app/(public)` and `app/(protected)` (the protected group is gated by `app/(protected)/protected-layout.tsx`).
5+
6+
Most feature UI remains in `src/`: feature-specific components are grouped under `src/components` (e.g., `create-project`, `deeploys`, `tunnels`), shared UI/logic in `src/shared`, hooks/utilities/contexts/providers in `src/lib`, smart-contract adapters in `src/blockchain`, and schema/types in `src/schemas`, `src/data`, and `src/typedefs`. Static assets live in `public/` and `src/assets/`. Next build output is `.next/` (a legacy `dist/` directory may exist from pre-Next builds and should not be treated as the Next build output).
57

68
## Build, Test, and Development Commands
79
Run all commands from the repo root:
8-
- `npm run dev` launches the Vite dev server with HMR.
9-
- `npm run dev:logs` adds verbose Vite diagnostics for debugging config issues.
10-
- `npm run build` type-checks through `tsc -b` then emits a production bundle to `dist/`.
10+
- `npm run dev` launches the Next.js dev server (`next dev --turbo --experimental-https`).
11+
- `npm run dev:logs` enables verbose Next.js diagnostics (`NEXT_DEBUG=1 next dev --turbo --experimental-https`).
12+
- `npm run build` creates a production build (`next build`, outputs to `.next/`).
13+
- `npm run start` serves the production build locally (`next start`).
1114
- `npm run lint` executes ESLint with the project’s React/TypeScript rules.
12-
- `npm run preview` serves the last build for local production smoke tests.
1315

1416
## Deeploy API Integration
15-
Deeploy workflows talk to the [edge_node](https://github.com/Ratio1/edge_node) API through wrappers in `src/lib/api/deeploy.ts`. Configure the base URL by setting `VITE_API_URL` and `VITE_ENVIRONMENT` in your `.env`; `src/lib/config.ts` routes requests across devnet/testnet/mainnet. Local storage must expose `accessToken`/`refreshToken` to satisfy the Axios interceptors. When working against a local edge node, run its server first, then point `VITE_API_URL` to the exposed port (e.g., `http://localhost:5000`). Mock responses for offline work by stubbing the helpers (`createPipeline`, `getApps`, etc.) instead of bypassing the provider.
17+
Deeploy workflows talk to the [edge_node](https://github.com/Ratio1/edge_node) API through wrappers in `src/lib/api/deeploy.ts`. Configure the base URL by setting `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_ENVIRONMENT` (and optionally `NEXT_PUBLIC_DEV_ADDRESS`) in your env file (prefer `.env.local`); `src/lib/config.ts` routes requests across devnet/testnet/mainnet. Local storage must expose `accessToken`/`refreshToken` to satisfy the Axios interceptors. When working against a local edge node, run its server first, then point `NEXT_PUBLIC_API_URL` to the exposed port (e.g., `http://localhost:5000`).
18+
19+
Because this is Next.js, be mindful of client/server boundaries: modules that access `localStorage` (like `src/lib/api/deeploy.ts`) must only run in client components/hooks (files with `'use client'`) and should not be imported/executed from server components.
1620

1721
## Coding Style & Naming Conventions
18-
Prettier (`.prettierrc`) enforces four-space indentation, single quotes, semicolons, and Tailwind class sorting—format before committing. Use PascalCase for components, camelCase for functions and state, and kebab-case for feature folders. Respect path aliases from `tsconfig.app.json` (such as `@components/...`) to avoid brittle relative imports. ESLint relaxes certain hook rules; still supply explicit dependency arrays and delete unused code paths.
22+
Prettier (`.prettierrc`) enforces four-space indentation, single quotes, semicolons, and Tailwind class sorting—format before committing. Use PascalCase for components, camelCase for functions and state, and kebab-case for feature folders. Respect path aliases from `tsconfig.json` (such as `@components/...`) to avoid brittle relative imports. In the `app/` router, add `'use client'` to components that use hooks, browser APIs, or context providers.
1923

2024
## Testing Guidelines
2125
Automated tests are not yet wired into `package.json`. Prefer Vitest plus React Testing Library when adding coverage; place specs alongside source as `*.test.ts(x)` or under `src/__tests__/`. Stub Deeploy API calls and blockchain providers to keep tests deterministic, and document manual QA steps in your PR until the suite matures.

app/(protected)/protected-layout.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import Layout from '@components/layout/Layout';
4+
import { Spinner } from '@heroui/spinner';
45
import { getDevAddress, isUsingDevAddress } from '@lib/config';
56
import { AuthenticationContextType, useAuthenticationContext } from '@lib/contexts/authentication';
67
import { DeploymentContextType, useDeploymentContext } from '@lib/contexts/deployment';
@@ -16,6 +17,7 @@ export function ProtectedLayout({ children }: { children: ReactNode }) {
1617
const { address } = isUsingDevAddress ? getDevAddress() : account;
1718

1819
const isAuthenticated = isSignedIn && address !== undefined && isFetchAppsRequired !== undefined;
20+
const shouldRedirectToLogin = !isAuthenticated;
1921

2022
useEffect(() => {
2123
if (account.status === 'disconnected') {
@@ -25,12 +27,21 @@ export function ProtectedLayout({ children }: { children: ReactNode }) {
2527
}, [account.status, setApps, setFetchAppsRequired]);
2628

2729
useEffect(() => {
28-
if (!isAuthenticated) {
30+
if (shouldRedirectToLogin) {
2931
router.replace('/login');
3032
}
31-
}, [isAuthenticated, router]);
33+
}, [shouldRedirectToLogin, router]);
3234

33-
if (!isAuthenticated) return null;
35+
if (shouldRedirectToLogin) {
36+
return (
37+
<div className="center-all w-full flex-1 py-24">
38+
<div className="col items-center gap-3">
39+
<Spinner />
40+
<div className="text-sm text-slate-500">Redirecting to login…</div>
41+
</div>
42+
</div>
43+
);
44+
}
3445

3546
return <Layout>{children}</Layout>;
3647
}

app/error.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use client';
2+
3+
import { DetailedAlert } from '@shared/DetailedAlert';
4+
import { useEffect } from 'react';
5+
import { RiErrorWarningLine } from 'react-icons/ri';
6+
7+
export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
8+
useEffect(() => {
9+
console.error('[app/error.tsx]', error);
10+
}, [error]);
11+
12+
return (
13+
<div className="flex w-full flex-1 justify-center pt-24">
14+
<DetailedAlert
15+
variant="red"
16+
icon={<RiErrorWarningLine />}
17+
title="Something went wrong"
18+
description={
19+
<div>
20+
An unexpected error occurred. Please try again, or return to the home page.
21+
{process.env.NODE_ENV === 'development' && (
22+
<div className="col gap-1 px-3 py-2 pt-2 font-mono text-sm">
23+
<div>{error.message}</div>
24+
{!!error.digest && <div className="text-slate-500">{error.digest}</div>}
25+
</div>
26+
)}
27+
</div>
28+
}
29+
fullWidth
30+
/>
31+
</div>
32+
);
33+
}

app/global-error.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import '../src/index.css';
5+
6+
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
7+
useEffect(() => {
8+
console.error('[app/global-error.tsx]', error);
9+
}, [error]);
10+
11+
return (
12+
<html lang="en">
13+
<body className="font-mona">
14+
<div className="center-all min-h-screen p-6">
15+
<div className="col w-full max-w-[640px] gap-2 text-center">
16+
<div className="text-lg font-semibold">Something went wrong</div>
17+
<div className="text-sm text-slate-600">
18+
The app hit an unexpected error. Please refresh this page or try again later.
19+
</div>
20+
</div>
21+
</div>
22+
</body>
23+
</html>
24+
);
25+
}

src/lib/api/apiError.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import axios, { type AxiosError } from 'axios';
2+
3+
export class ApiError extends Error {
4+
readonly status?: number;
5+
readonly url?: string;
6+
readonly method?: string;
7+
readonly responseData?: unknown;
8+
readonly isNetworkError: boolean;
9+
10+
constructor(
11+
message: string,
12+
{
13+
status,
14+
url,
15+
method,
16+
responseData,
17+
isNetworkError,
18+
cause,
19+
}: {
20+
status?: number;
21+
url?: string;
22+
method?: string;
23+
responseData?: unknown;
24+
isNetworkError: boolean;
25+
cause?: unknown;
26+
},
27+
) {
28+
super(message);
29+
this.name = 'ApiError';
30+
this.status = status;
31+
this.url = url;
32+
this.method = method;
33+
this.responseData = responseData;
34+
this.isNetworkError = isNetworkError;
35+
if (cause !== undefined) {
36+
Object.defineProperty(this, 'cause', {
37+
value: cause,
38+
enumerable: false,
39+
});
40+
}
41+
}
42+
}
43+
44+
export function toApiError(error: unknown, fallbackMessage: string): ApiError {
45+
if (axios.isAxiosError(error)) {
46+
const axiosError = error as AxiosError;
47+
const status = axiosError.response?.status;
48+
const url = axiosError.config?.url;
49+
const method = axiosError.config?.method?.toUpperCase();
50+
const responseData = axiosError.response?.data;
51+
const isNetworkError = axiosError.response === undefined;
52+
53+
const message =
54+
extractMessageFromResponseData(responseData) ??
55+
axiosError.message ??
56+
(isNetworkError ? 'Network error' : undefined) ??
57+
fallbackMessage;
58+
59+
return new ApiError(message, {
60+
status,
61+
url,
62+
method,
63+
responseData,
64+
isNetworkError,
65+
cause: error,
66+
});
67+
}
68+
69+
const message = error instanceof Error ? error.message : fallbackMessage;
70+
return new ApiError(message, { isNetworkError: false, cause: error });
71+
}
72+
73+
function extractMessageFromResponseData(data: unknown): string | undefined {
74+
if (!isRecord(data)) return;
75+
76+
const error = data.error;
77+
if (typeof error === 'string') return error;
78+
79+
const message = data.message;
80+
if (typeof message === 'string') return message;
81+
82+
const result = data.result;
83+
if (isRecord(result) && typeof result.error === 'string') return result.error;
84+
85+
return undefined;
86+
}
87+
88+
function isRecord(value: unknown): value is Record<string, unknown> {
89+
return value !== null && typeof value === 'object';
90+
}

src/lib/api/backend.tsx

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { config } from '@lib/config';
2+
import { toApiError } from '@lib/api/apiError';
23
import { InvoiceDraft, PublicProfileInfo } from '@typedefs/general';
34
import axios from 'axios';
45
import * as types from '@typedefs/blockchain';
@@ -193,7 +194,7 @@ const axiosDapp = axios.create({
193194

194195
axiosDapp.interceptors.request.use(
195196
async (config) => {
196-
const token = localStorage.getItem('accessToken');
197+
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
197198
if (token) {
198199
config.headers.Authorization = `Bearer ${token}`;
199200
}
@@ -209,24 +210,27 @@ axiosDapp.interceptors.response.use(
209210
return response;
210211
},
211212
async (error) => {
212-
const originalRequest = error.config;
213-
if (error.response.status === 401 && !originalRequest._retry) {
213+
const status: number | undefined = error?.response?.status;
214+
const originalRequest = error?.config;
215+
216+
if (status === 401 && originalRequest && !originalRequest._retry) {
214217
originalRequest._retry = true;
215-
const refreshToken = localStorage.getItem('refreshToken');
218+
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null;
216219
if (refreshToken) {
217-
return axiosDapp
218-
.post('/auth/refresh', {
219-
refreshToken: refreshToken,
220-
})
221-
.then((res) => {
222-
if (res.status === 200) {
223-
localStorage.setItem('accessToken', res.data.accessToken);
224-
return axiosDapp(originalRequest);
225-
}
220+
try {
221+
const refreshClient = axios.create({ baseURL: backendUrl });
222+
const res = await refreshClient.post('/auth/refresh', { refreshToken });
223+
224+
if (res.status === 200 && typeof window !== 'undefined' && res.data?.accessToken) {
225+
localStorage.setItem('accessToken', res.data.accessToken);
226226
return axiosDapp(originalRequest);
227-
});
227+
}
228+
} catch (refreshError) {
229+
return Promise.reject(toApiError(refreshError, 'Session refresh failed.'));
230+
}
228231
}
229232
}
230-
return error.response;
233+
234+
return Promise.reject(toApiError(error, 'Backend request failed.'));
231235
},
232236
);

src/lib/api/deeploy.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { config } from '@lib/config';
2+
import { toApiError } from '@lib/api/apiError';
23
import { EthAddress } from '@typedefs/blockchain';
34
import { DeeployDefaultResponse, GetAppsResponse } from '@typedefs/deeployApi';
45
import axios from 'axios';
@@ -98,7 +99,7 @@ export const axiosDeeploy = axios.create({
9899

99100
axiosDeeploy.interceptors.request.use(
100101
async (config) => {
101-
const token = localStorage.getItem('accessToken');
102+
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
102103
if (token) {
103104
config.headers.Authorization = `Bearer ${token}`;
104105
}
@@ -114,25 +115,28 @@ axiosDeeploy.interceptors.response.use(
114115
return response;
115116
},
116117
async (error) => {
117-
const originalRequest = error.config;
118-
if (error.response.status === 401 && !originalRequest._retry) {
118+
const status: number | undefined = error?.response?.status;
119+
const originalRequest = error?.config;
120+
121+
if (status === 401 && originalRequest && !originalRequest._retry) {
119122
originalRequest._retry = true;
120-
const refreshToken = localStorage.getItem('refreshToken');
123+
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null;
121124
if (refreshToken) {
122-
return axiosDeeploy
123-
.post('/auth/refresh', {
124-
refreshToken: refreshToken,
125-
})
126-
.then((res) => {
127-
if (res.status === 200) {
128-
localStorage.setItem('accessToken', res.data.accessToken);
129-
return axiosDeeploy(originalRequest);
130-
}
125+
try {
126+
const refreshClient = axios.create({ baseURL: config.deeployUrl });
127+
const res = await refreshClient.post('/auth/refresh', { refreshToken });
128+
129+
if (res.status === 200 && typeof window !== 'undefined' && res.data?.accessToken) {
130+
localStorage.setItem('accessToken', res.data.accessToken);
131131
return axiosDeeploy(originalRequest);
132-
});
132+
}
133+
} catch (refreshError) {
134+
return Promise.reject(toApiError(refreshError, 'Session refresh failed.'));
135+
}
133136
}
134137
}
135-
return error.response;
138+
139+
return Promise.reject(toApiError(error, 'Deeploy request failed.'));
136140
},
137141
);
138142

src/lib/api/oracles.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { config } from '@lib/config';
2+
import { toApiError } from '@lib/api/apiError';
23
import axios from 'axios';
34
import * as types from '@typedefs/blockchain';
45

@@ -90,6 +91,6 @@ axiosInstance.interceptors.response.use(
9091
return response;
9192
},
9293
async (error) => {
93-
return error.response;
94+
return Promise.reject(toApiError(error, 'Oracles request failed.'));
9495
},
9596
);

0 commit comments

Comments
 (0)