proteum 2.5.0 → 2.5.1
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/README.md +39 -15
- package/agents/project/client/AGENTS.md +5 -1
- package/agents/project/server/services/AGENTS.md +4 -0
- package/cli/scaffold/templates.ts +3 -3
- package/client/app/index.ts +22 -5
- package/client/services/router/request/api.ts +2 -2
- package/docs/migration-2.5.md +226 -0
- package/eslint.js +56 -44
- package/package.json +1 -1
- package/server/app/index.ts +28 -2
- package/server/services/router/index.ts +3 -3
- package/tests/client-app-error-handling.test.cjs +100 -0
- package/tests/dev-transpile-watch.test.cjs +3 -6
- package/tests/eslint-rules.test.cjs +146 -7
- package/tests/scaffold-templates.test.cjs +18 -0
- package/tests/server-app-report-error.test.cjs +135 -0
package/README.md
CHANGED
|
@@ -140,7 +140,7 @@ Use this for linked or workspace-local TypeScript packages that ship source file
|
|
|
140
140
|
|
|
141
141
|
## Example: Server Bootstrap
|
|
142
142
|
|
|
143
|
-
Proteum app services are declared explicitly through typed config exports plus a
|
|
143
|
+
Proteum app services and router plugins are declared explicitly through typed config exports plus a default-exported `defineApplication(...)` definition object.
|
|
144
144
|
|
|
145
145
|
```ts
|
|
146
146
|
// server/config/user.ts
|
|
@@ -167,27 +167,45 @@ export const routerBaseConfig = {
|
|
|
167
167
|
|
|
168
168
|
```ts
|
|
169
169
|
// server/index.ts
|
|
170
|
-
import { defineApplication } from '@server/app';
|
|
170
|
+
import { defineApplication, type Application } from '@server/app';
|
|
171
171
|
import Router from '@server/services/router';
|
|
172
172
|
import SchemaRouter from '@server/services/schema/router';
|
|
173
173
|
import Users from '@/server/services/Users';
|
|
174
174
|
import * as userConfig from '@/server/config/user';
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
type MyAppServices = {
|
|
177
|
+
Users: Users;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
type MyRouterPlugins = {
|
|
181
|
+
schema: SchemaRouter;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export type MyRouter = Router<MyApp, MyRouterPlugins>;
|
|
185
|
+
export interface MyApp extends Application, MyAppServices {
|
|
186
|
+
Router: MyRouter;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const createRouter = (app: MyApp): MyRouter =>
|
|
190
|
+
new Router<MyApp, MyRouterPlugins>(
|
|
191
|
+
app,
|
|
192
|
+
{
|
|
193
|
+
...userConfig.routerBaseConfig,
|
|
194
|
+
plugins: {
|
|
195
|
+
schema: new SchemaRouter({}, app),
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
app
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const MyApplication = defineApplication<MyAppServices, MyRouter>({
|
|
177
202
|
services: (app) => ({
|
|
178
203
|
Users: new Users(app, userConfig.usersConfig, app),
|
|
179
|
-
Router: new Router(
|
|
180
|
-
app,
|
|
181
|
-
{
|
|
182
|
-
...userConfig.routerBaseConfig,
|
|
183
|
-
plugins: {
|
|
184
|
-
schema: new SchemaRouter({}, app),
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
app
|
|
188
|
-
),
|
|
189
204
|
}),
|
|
205
|
+
router: createRouter,
|
|
190
206
|
});
|
|
207
|
+
|
|
208
|
+
export default MyApplication;
|
|
191
209
|
```
|
|
192
210
|
|
|
193
211
|
Proteum reads `server/index.ts` as the source of truth for installed root services and router plugins, and reads `server/config/*.ts` `Services.config(...)` exports for typed config such as service priority overrides.
|
|
@@ -233,7 +251,7 @@ Default public asset validators depend on the environment: dev disables `ETag` a
|
|
|
233
251
|
Proteum pages are explicit SSR entrypoints.
|
|
234
252
|
|
|
235
253
|
```tsx
|
|
236
|
-
import { definePageRoute } from '@common/router';
|
|
254
|
+
import { definePageRoute } from '@common/router/definitions';
|
|
237
255
|
|
|
238
256
|
export default definePageRoute({
|
|
239
257
|
path: '/',
|
|
@@ -653,6 +671,12 @@ npx proteum check
|
|
|
653
671
|
npx proteum build --prod
|
|
654
672
|
```
|
|
655
673
|
|
|
674
|
+
## Migrating To 2.5
|
|
675
|
+
|
|
676
|
+
Proteum 2.5 removes the old contextual route/controller magic. Apps migrate by replacing ambient `@app` imports, top-level `Router.*(...)` route calls, controller classes, and `Application` subclasses with explicit definition objects and typed runtime callback parameters.
|
|
677
|
+
|
|
678
|
+
Use [the 2.5 migration guide](docs/migration-2.5.md) for the full checklist.
|
|
679
|
+
|
|
656
680
|
## Repository Structure
|
|
657
681
|
|
|
658
682
|
This repository is organized around the same explicit framework surface it exposes:
|
|
@@ -661,7 +685,7 @@ This repository is organized around the same explicit framework surface it expos
|
|
|
661
685
|
- `client/`: client runtime, page registration, islands, and router behavior
|
|
662
686
|
- `server/`: controller base classes, services, runtime, and SSR server behavior
|
|
663
687
|
- `common/`: shared router contracts, models, request/response types, and utilities
|
|
664
|
-
- `
|
|
688
|
+
- `docs/`: focused design notes and internal documentation
|
|
665
689
|
- `agents/`: agent-specific conventions and scaffolding used in Proteum-based projects
|
|
666
690
|
|
|
667
691
|
## Status
|
|
@@ -25,7 +25,11 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
|
|
|
25
25
|
- Never depend on legacy `@app` imports on the client.
|
|
26
26
|
- Errors from controller calls should never be silently swallowed. Rethrow or surface them clearly.
|
|
27
27
|
- Caught frontend errors must always preserve the original failure. Never write `catch {}`, `.catch(() => ...)`, or a catch handler that only shows a generic toast/state without using the caught error.
|
|
28
|
-
- Valid frontend error handling
|
|
28
|
+
- Valid terminal frontend error handling is `throw error`, `useContext().app.handleError(error)`, or `context.app.handleError(error)`.
|
|
29
|
+
- Do not normalize caught values in app code before calling `handleError`; the app handles `unknown` values and returns a displayable message.
|
|
30
|
+
- If the app customizes `handleError`, keep the signature `handleError(error: unknown, fallbackMessage?: string): string`.
|
|
31
|
+
- Toasts and form errors are local feedback only; use `setError(context.app.handleError(error, fallbackMessage))` or rethrow the caught error.
|
|
32
|
+
- `console.*(error)` is not error handling and must not be the last stop for a caught error.
|
|
29
33
|
|
|
30
34
|
## Design
|
|
31
35
|
|
|
@@ -38,3 +38,7 @@ Diagnostics source of truth: root-level `diagnostics.md`.
|
|
|
38
38
|
|
|
39
39
|
- Never silence caught errors.
|
|
40
40
|
- If you need to wrap a failure, preserve enough detail and the original error.
|
|
41
|
+
- Prefer `throw error` when the current request or job should fail.
|
|
42
|
+
- For catch-and-continue server work, detached promises, custom Express responses, or background jobs, call `await this.app.reportError(error, request)` when a request is available, or `await this.app.reportError(error)` without one.
|
|
43
|
+
- Do not call `app.runHook('error', ...)` directly from app code; route caught errors through `app.reportError(...)` so HTTP-specific error hooks stay centralized.
|
|
44
|
+
- `console.*(error)` is not error handling and must not be the last stop for a caught error.
|
|
@@ -172,8 +172,9 @@ import SchemaRouter from '@server/services/schema/router';
|
|
|
172
172
|
import * as appConfig from '@/server/config/app';
|
|
173
173
|
|
|
174
174
|
export default defineApplication({
|
|
175
|
-
services: (
|
|
176
|
-
|
|
175
|
+
services: () => ({}),
|
|
176
|
+
router: (app) =>
|
|
177
|
+
new Router(
|
|
177
178
|
app,
|
|
178
179
|
{
|
|
179
180
|
...appConfig.routerBaseConfig,
|
|
@@ -183,7 +184,6 @@ export default defineApplication({
|
|
|
183
184
|
},
|
|
184
185
|
app,
|
|
185
186
|
),
|
|
186
|
-
}),
|
|
187
187
|
});
|
|
188
188
|
`;
|
|
189
189
|
|
package/client/app/index.ts
CHANGED
|
@@ -7,8 +7,6 @@ if (typeof window === 'undefined') throw new Error(`This file shouldn't be loade
|
|
|
7
7
|
|
|
8
8
|
window.dev && require('preact/debug');
|
|
9
9
|
|
|
10
|
-
// Core
|
|
11
|
-
import { CoreError } from '@common/errors';
|
|
12
10
|
import type { Layout } from '@common/router';
|
|
13
11
|
import { createDialog } from '@client/components/Dialog/Manager';
|
|
14
12
|
|
|
@@ -53,6 +51,21 @@ const isIgnorableBrowserErrorMessage = (message: string) =>
|
|
|
53
51
|
message === 'ResizeObserver loop completed with undelivered notifications.' ||
|
|
54
52
|
message === 'ResizeObserver loop limit exceeded';
|
|
55
53
|
|
|
54
|
+
export const getClientErrorMessage = (error: unknown, fallbackMessage = 'Unknown client error'): string => {
|
|
55
|
+
if (error instanceof Error && error.message) return error.message;
|
|
56
|
+
if (typeof error === 'string' && error.trim()) return error;
|
|
57
|
+
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' && error.message)
|
|
58
|
+
return error.message;
|
|
59
|
+
|
|
60
|
+
return fallbackMessage;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const normalizeClientError = (error: unknown, fallbackMessage?: string): Error => {
|
|
64
|
+
if (error instanceof Error) return error;
|
|
65
|
+
|
|
66
|
+
return new Error(getClientErrorMessage(error, fallbackMessage));
|
|
67
|
+
};
|
|
68
|
+
|
|
56
69
|
/*----------------------------------
|
|
57
70
|
- CLASS
|
|
58
71
|
----------------------------------*/
|
|
@@ -88,8 +101,7 @@ export default abstract class Application {
|
|
|
88
101
|
public bindErrorHandlers() {
|
|
89
102
|
// Impossible de recup le stacktrace ...
|
|
90
103
|
window.addEventListener('unhandledrejection', (e) => {
|
|
91
|
-
|
|
92
|
-
this.handleError(error);
|
|
104
|
+
this.handleError(e.reason);
|
|
93
105
|
});
|
|
94
106
|
|
|
95
107
|
window.onerror = (message, file, line, col, stacktrace) => {
|
|
@@ -109,7 +121,12 @@ export default abstract class Application {
|
|
|
109
121
|
};
|
|
110
122
|
}
|
|
111
123
|
|
|
112
|
-
public
|
|
124
|
+
public handleError(error: unknown, fallbackMessage?: string): string {
|
|
125
|
+
const normalizedError = normalizeClientError(error, fallbackMessage);
|
|
126
|
+
console.error(normalizedError);
|
|
127
|
+
|
|
128
|
+
return getClientErrorMessage(error, fallbackMessage);
|
|
129
|
+
}
|
|
113
130
|
|
|
114
131
|
public abstract handleUpdate(): void;
|
|
115
132
|
|
|
@@ -109,7 +109,7 @@ export default class ApiClient implements ApiClientService {
|
|
|
109
109
|
.then((data: TObjetDonnees) => {
|
|
110
110
|
this.set(data);
|
|
111
111
|
})
|
|
112
|
-
.catch((error:
|
|
112
|
+
.catch((error: unknown) => {
|
|
113
113
|
this.app.handleError(error);
|
|
114
114
|
});
|
|
115
115
|
}
|
|
@@ -270,7 +270,7 @@ export default class ApiClient implements ApiClientService {
|
|
|
270
270
|
);
|
|
271
271
|
|
|
272
272
|
// API Error hook
|
|
273
|
-
this.app.handleError(e
|
|
273
|
+
this.app.handleError(e);
|
|
274
274
|
|
|
275
275
|
throw e;
|
|
276
276
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Migrating To Proteum 2.5
|
|
2
|
+
|
|
3
|
+
Proteum 2.5 is a breaking cleanup release. It removes contextual app/router magic from user source and makes routes, controllers, pages, services, and app bootstrap machine-readable through explicit definition objects.
|
|
4
|
+
|
|
5
|
+
## What Changed
|
|
6
|
+
|
|
7
|
+
- App roots default-export `defineApplication({ services, router, models, commands })`.
|
|
8
|
+
- Page files default-export `definePageRoute({ path, options, data, render })` or `defineErrorRoute({ code, options, render })`.
|
|
9
|
+
- Manual HTTP route files default-export `defineServerRoute({ method, path, options, handler })` or `defineServerRoutes(...)`.
|
|
10
|
+
- Raw Express handlers are wrapped with `expressHandler(...)`.
|
|
11
|
+
- Controller files default-export `defineController({ path, actions })`; actions use `defineAction({ input, handler })`.
|
|
12
|
+
- Runtime app, service, router, request, response, auth, and router-plugin access comes from typed callback context.
|
|
13
|
+
- `@app` imports, top-level `Router.page(...)`, top-level server `Router.*(...)`, controller classes, and `this.input(...)` are no longer supported.
|
|
14
|
+
|
|
15
|
+
## 1. Install The Published Package
|
|
16
|
+
|
|
17
|
+
Update every Proteum app package in the repo to `proteum@^2.5.0`, then reinstall from npm.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install
|
|
21
|
+
npm ls proteum
|
|
22
|
+
node -p "require('./node_modules/proteum/package.json').version + ' ' + require.resolve('proteum/package.json')"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The resolved path must point inside the app repo `node_modules/proteum`, not to a local framework checkout. If a project was using `npm link`, the reinstall should replace the symlink.
|
|
26
|
+
|
|
27
|
+
## 2. Refresh Agent Instructions
|
|
28
|
+
|
|
29
|
+
Regenerate project instructions so LLMs receive the explicit 2.5 contracts.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx proteum configure agents
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Generated instructions should mention `defineApplication`, `definePageRoute`, `defineServerRoute`, `defineController`, and the ban on `@app` imports in route, page, and controller files.
|
|
36
|
+
|
|
37
|
+
## 3. Migrate `server/index.ts`
|
|
38
|
+
|
|
39
|
+
Move from an `Application` subclass or service-returned `Router` to an explicit app definition. Keep `server/index.ts` as the canonical type root for services, router plugins, models, and request context.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { defineApplication, type Application } from '@server/app';
|
|
43
|
+
import Router from '@server/services/router';
|
|
44
|
+
import SchemaRouter from '@server/services/schema/router';
|
|
45
|
+
import BillingService from '@/server/services/Billing';
|
|
46
|
+
|
|
47
|
+
import * as appConfig from '@/server/config/app';
|
|
48
|
+
|
|
49
|
+
type ProjectServices = {
|
|
50
|
+
Billing: BillingService;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type ProjectRouterPlugins = {
|
|
54
|
+
schema: SchemaRouter;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type ProjectRouter = Router<ProjectApp, ProjectRouterPlugins>;
|
|
58
|
+
export interface ProjectApp extends Application, ProjectServices {
|
|
59
|
+
Router: ProjectRouter;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const createProjectRouter = (app: ProjectApp): ProjectRouter =>
|
|
63
|
+
new Router<ProjectApp, ProjectRouterPlugins>(
|
|
64
|
+
app,
|
|
65
|
+
{
|
|
66
|
+
...appConfig.routerBaseConfig,
|
|
67
|
+
plugins: {
|
|
68
|
+
schema: new SchemaRouter({}, app),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
app,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const ProjectApplication = defineApplication<ProjectServices, ProjectRouter>({
|
|
75
|
+
services: (app) => ({
|
|
76
|
+
Billing: new BillingService(app, {}, app),
|
|
77
|
+
}),
|
|
78
|
+
router: createProjectRouter,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export default ProjectApplication;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 4. Migrate Pages
|
|
85
|
+
|
|
86
|
+
Replace legacy page registration with a default-exported page definition.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { definePageRoute } from '@common/router/definitions';
|
|
90
|
+
|
|
91
|
+
export default definePageRoute({
|
|
92
|
+
path: '/dashboard',
|
|
93
|
+
options: { auth: true },
|
|
94
|
+
data: ({ AccountController }) => ({
|
|
95
|
+
account: AccountController.accountPage(),
|
|
96
|
+
}),
|
|
97
|
+
render: ({ account }) => <Dashboard account={account} />,
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Rules:
|
|
102
|
+
|
|
103
|
+
- `path`, `options`, and error `code` must be static and compiler-readable.
|
|
104
|
+
- Route behavior belongs in `options`, not in data.
|
|
105
|
+
- Use `data: null` when no SSR data is needed.
|
|
106
|
+
- Runtime references are allowed only inside `data` and `render`.
|
|
107
|
+
|
|
108
|
+
## 5. Migrate Manual Server Routes
|
|
109
|
+
|
|
110
|
+
Replace top-level `Router.get(...)`, `Router.post(...)`, `Router.express(...)`, and similar calls with definition exports.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { defineServerRoute, defineServerRoutes, expressHandler } from '@common/router/definitions';
|
|
114
|
+
import type { ProjectApp } from '@/server/index';
|
|
115
|
+
|
|
116
|
+
export default defineServerRoutes((app: ProjectApp) => [
|
|
117
|
+
defineServerRoute({
|
|
118
|
+
method: 'GET',
|
|
119
|
+
path: '/health',
|
|
120
|
+
options: {},
|
|
121
|
+
handler: ({ response }) => response.json({ ok: true }),
|
|
122
|
+
}),
|
|
123
|
+
defineServerRoute({
|
|
124
|
+
method: 'POST',
|
|
125
|
+
path: '/webhook',
|
|
126
|
+
options: {},
|
|
127
|
+
handler: expressHandler((request, response) => {
|
|
128
|
+
app.Billing.recordWebhook(request.body);
|
|
129
|
+
response.status(204).send('');
|
|
130
|
+
}),
|
|
131
|
+
}),
|
|
132
|
+
]);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Use `defineServerRoutes((app) => [...])` only when the route definitions need app services at registration time. Otherwise export one `defineServerRoute(...)`.
|
|
136
|
+
|
|
137
|
+
## 6. Migrate Controllers
|
|
138
|
+
|
|
139
|
+
Replace controller classes and `this.input(schema)` with explicit actions.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { defineAction, defineController, schema } from '@server/app/controller';
|
|
143
|
+
|
|
144
|
+
export default defineController({
|
|
145
|
+
path: 'Billing',
|
|
146
|
+
actions: {
|
|
147
|
+
read: defineAction({
|
|
148
|
+
input: schema.object({ accountId: schema.string() }),
|
|
149
|
+
handler: ({ input, services }) => services.Billing.read(input.accountId),
|
|
150
|
+
}),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Rules:
|
|
156
|
+
|
|
157
|
+
- `input` is parsed before the handler runs.
|
|
158
|
+
- Read parsed input from `context.input`.
|
|
159
|
+
- Read request state from `request`, `response`, `api`, `auth`, and router-plugin context.
|
|
160
|
+
- Call business logic through `services`, `models`, or `app`.
|
|
161
|
+
|
|
162
|
+
## 7. Remove Legacy Magic
|
|
163
|
+
|
|
164
|
+
Search user source for old contracts and remove every match.
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
rg -n "from ['\"]@app['\"]|Router\\.(page|error|get|post|put|patch|delete|express)\\(|this\\.input\\(" client server common commands
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Expected result: no user-source matches.
|
|
171
|
+
|
|
172
|
+
Allowed replacements:
|
|
173
|
+
|
|
174
|
+
- `ctx.app`, `ctx.services`, `ctx.Router`, `ctx.request`, `ctx.response`, `ctx.auth`, and custom router-plugin context inside handlers.
|
|
175
|
+
- `this.app`, `this.services`, and `this.models` inside typed services.
|
|
176
|
+
- `defineServerRoutes((app) => [...])` when server route definitions need app services.
|
|
177
|
+
|
|
178
|
+
## 8. Standardize Caught Error Handling
|
|
179
|
+
|
|
180
|
+
Every caught error must end at the same framework error surface. Local UI feedback or protocol responses can still happen, but they are not the terminal error handling step by themselves.
|
|
181
|
+
|
|
182
|
+
Server rules:
|
|
183
|
+
|
|
184
|
+
- Use `throw error` when the request/router/controller should fail and let Proteum render the HTTP error response.
|
|
185
|
+
- Use `await app.reportError(error, request)` when a Proteum request is available, or `await app.reportError(error)` for detached/custom Express paths, catch-and-continue server work, and jobs that intentionally keep running.
|
|
186
|
+
- Do not use raw `app.runHook('error', error, request)` in app code. `app.reportError(...)` keeps the `error` versus `error.<code>` routing centralized.
|
|
187
|
+
|
|
188
|
+
Client rules:
|
|
189
|
+
|
|
190
|
+
- Use `throw error` when the action should fail and reach the app-level unhandled rejection path.
|
|
191
|
+
- Use `useContext().app.handleError(error)` or `context.app.handleError(error)` when the UI catches and continues.
|
|
192
|
+
- `handleError` accepts unknown caught values and returns a displayable message. Prefer `setError(context.app.handleError(error, 'Unable to finish this action.'))` over local `instanceof Error` filtering.
|
|
193
|
+
- If an app overrides `handleError`, update it to `handleError(error: unknown, fallbackMessage?: string): string` and return the display message.
|
|
194
|
+
- Toasts, form errors, or `setError(...)` are local feedback only. Route the original caught value through `app.handleError(error)` or `throw error`.
|
|
195
|
+
|
|
196
|
+
Do not treat `console.error(error)`, `console.warn(error)`, or any other `console.*(error)` call as error handling. Console calls can be temporary diagnostics, but they must not be the last stop for a caught error.
|
|
197
|
+
|
|
198
|
+
## 9. Refresh Generated Artifacts
|
|
199
|
+
|
|
200
|
+
Do not edit `.proteum/**` manually. Regenerate it from source.
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
npx proteum refresh
|
|
204
|
+
npx proteum typecheck
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
If connected local projects are used through `file:` sources, start or validate producer apps before validating the consumer.
|
|
208
|
+
|
|
209
|
+
## 10. Validate Runtime Behavior
|
|
210
|
+
|
|
211
|
+
Run the smallest trustworthy checks first, then broaden when the touched surface requires it.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
npx proteum diagnose /
|
|
215
|
+
npx proteum build --prod
|
|
216
|
+
npx proteum e2e
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
For protected flows, prefer Proteum session helpers over automating login unless login is the feature under test.
|
|
220
|
+
|
|
221
|
+
## Common Fixes
|
|
222
|
+
|
|
223
|
+
- Production route-generation errors where top-level `Router.express(...)` was lifted outside registration are fixed by moving the route into `defineServerRoute({ handler: expressHandler(...) })`.
|
|
224
|
+
- `@app` import errors are fixed by moving runtime access into `data`, `render`, route handlers, controller action handlers, or typed services.
|
|
225
|
+
- Missing `this.app.Router` typings are fixed by exporting the concrete app and router types from `server/index.ts`.
|
|
226
|
+
- Static metadata errors are fixed by moving runtime-dependent values out of `path`, `method`, `options`, and error `code`.
|
package/eslint.js
CHANGED
|
@@ -121,77 +121,89 @@ const getCalleePropertyName = (callee) => {
|
|
|
121
121
|
return null;
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
-
const
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
128
|
-
|
|
129
|
-
'
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
'
|
|
134
|
-
'
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return isKnownPreserver && nodeReferencesName(callExpression, names);
|
|
124
|
+
const getErrorHandlingSide = (filename) => {
|
|
125
|
+
const normalized = filename.replace(/\\/g, '/');
|
|
126
|
+
if (/(^|\/)client\//.test(normalized)) return 'client';
|
|
127
|
+
if (/(^|\/)(server|commands)\//.test(normalized)) return 'server';
|
|
128
|
+
|
|
129
|
+
return 'shared';
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const getMemberPropertyName = (node) => {
|
|
133
|
+
if (node?.type !== 'MemberExpression') return null;
|
|
134
|
+
if (node.property.type === 'Identifier') return node.property.name;
|
|
135
|
+
if (node.property.type === 'Literal') return String(node.property.value);
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const isConsoleMember = (node) =>
|
|
141
|
+
node?.type === 'MemberExpression' && node.object?.type === 'Identifier' && node.object.name === 'console';
|
|
142
|
+
|
|
143
|
+
const isAppReceiver = (node) => {
|
|
144
|
+
if (!node) return false;
|
|
145
|
+
if (node.type === 'Identifier' && node.name === 'app') return true;
|
|
146
|
+
if (node.type === 'MemberExpression' && getMemberPropertyName(node) === 'app') return true;
|
|
147
|
+
|
|
148
|
+
return false;
|
|
150
149
|
};
|
|
151
150
|
|
|
152
|
-
const
|
|
151
|
+
const isClientErrorHandlerCall = (callExpression) =>
|
|
152
|
+
callExpression.callee.type === 'MemberExpression' &&
|
|
153
|
+
getMemberPropertyName(callExpression.callee) === 'handleError' &&
|
|
154
|
+
isAppReceiver(callExpression.callee.object);
|
|
155
|
+
|
|
156
|
+
const isServerErrorReporterCall = (callExpression) =>
|
|
157
|
+
callExpression.callee.type === 'MemberExpression' &&
|
|
158
|
+
getMemberPropertyName(callExpression.callee) === 'reportError' &&
|
|
159
|
+
isAppReceiver(callExpression.callee.object);
|
|
160
|
+
|
|
161
|
+
const isPromiseRejectCall = (callExpression) => getCalleePropertyName(callExpression.callee) === 'reject';
|
|
162
|
+
|
|
163
|
+
const isPreservingCall = (callExpression, names, side) => {
|
|
164
|
+
if (!nodeReferencesName(callExpression, names)) return false;
|
|
165
|
+
if (isConsoleMember(callExpression.callee)) return false;
|
|
166
|
+
if (isPromiseRejectCall(callExpression)) return true;
|
|
167
|
+
if (side === 'client') return isClientErrorHandlerCall(callExpression);
|
|
168
|
+
if (side === 'server') return isServerErrorReporterCall(callExpression);
|
|
169
|
+
|
|
170
|
+
return isClientErrorHandlerCall(callExpression) || isServerErrorReporterCall(callExpression);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const handlerPreservesCaughtError = (node, names, side) => {
|
|
153
174
|
let preserves = false;
|
|
154
175
|
|
|
155
176
|
traverseNode(node, (child) => {
|
|
156
177
|
if (child.type === 'ThrowStatement' && nodeReferencesName(child.argument, names)) preserves = true;
|
|
157
|
-
if (child.type === 'CallExpression' && isPreservingCall(child, names)) preserves = true;
|
|
178
|
+
if (child.type === 'CallExpression' && isPreservingCall(child, names, side)) preserves = true;
|
|
158
179
|
});
|
|
159
180
|
|
|
160
181
|
return preserves;
|
|
161
182
|
};
|
|
162
183
|
|
|
163
|
-
const directPromiseCatchHandlers = new Set([
|
|
164
|
-
'captureError',
|
|
165
|
-
'captureException',
|
|
166
|
-
'consoleError',
|
|
167
|
-
'handleError',
|
|
168
|
-
'logError',
|
|
169
|
-
'reportError',
|
|
170
|
-
]);
|
|
171
|
-
|
|
172
184
|
const isDirectPromiseCatchHandler = (node) => {
|
|
173
185
|
const name = getCalleePropertyName(node);
|
|
174
|
-
|
|
175
|
-
return false;
|
|
186
|
+
return name === 'reject';
|
|
176
187
|
};
|
|
177
188
|
|
|
178
189
|
const createSwallowedErrorRule = () => ({
|
|
179
190
|
meta: {
|
|
180
191
|
type: 'problem',
|
|
181
192
|
docs: {
|
|
182
|
-
description: 'Require caught errors to
|
|
193
|
+
description: 'Require caught errors to reach the standard app error path or be rethrown.',
|
|
183
194
|
},
|
|
184
195
|
messages: {
|
|
185
196
|
missingParam:
|
|
186
|
-
'Caught errors must be bound and
|
|
197
|
+
'Caught errors must be bound and routed through the standard error path. Use `catch (error)` and rethrow, call app.reportError on the server, or call app.handleError on the client.',
|
|
187
198
|
unusedParam:
|
|
188
|
-
'Caught error `{{name}}` is discarded. Rethrow it,
|
|
199
|
+
'Caught error `{{name}}` is discarded. Rethrow it, call app.reportError on the server, or call app.handleError on the client.',
|
|
189
200
|
unpreserved:
|
|
190
|
-
'Caught error `{{name}}` is used but not
|
|
201
|
+
'Caught error `{{name}}` is used but not routed through the standard error path. Rethrow it, call app.reportError on the server, or call app.handleError on the client.',
|
|
191
202
|
},
|
|
192
203
|
schema: [],
|
|
193
204
|
},
|
|
194
205
|
create(context) {
|
|
206
|
+
const side = getErrorHandlingSide(context.filename || context.getFilename?.() || '');
|
|
195
207
|
const reportHandler = (node, params, body) => {
|
|
196
208
|
const names = params.flatMap((param) => collectPatternNames(param));
|
|
197
209
|
if (names.length === 0) {
|
|
@@ -205,7 +217,7 @@ const createSwallowedErrorRule = () => ({
|
|
|
205
217
|
return;
|
|
206
218
|
}
|
|
207
219
|
|
|
208
|
-
if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names))) {
|
|
220
|
+
if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names), side)) {
|
|
209
221
|
context.report({ node, messageId: 'unpreserved', data: { name: referencedName } });
|
|
210
222
|
}
|
|
211
223
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proteum",
|
|
3
3
|
"description": "LLM-first Opinionated Typescript Framework for web applications.",
|
|
4
|
-
"version": "2.5.
|
|
4
|
+
"version": "2.5.1",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/proteum.git",
|
|
7
7
|
"license": "MIT",
|
package/server/app/index.ts
CHANGED
|
@@ -92,6 +92,24 @@ const createCommandsManager = (app: Application) => new CommandsManager(app, { d
|
|
|
92
92
|
const createDevCommandsRegistry = (app: Application) => new DevCommandsRegistry(app);
|
|
93
93
|
const createDevDiagnosticsRegistry = (app: Application) => new DevDiagnosticsRegistry(app);
|
|
94
94
|
|
|
95
|
+
const normalizeReportedError = (rejection: unknown, fallbackMessage: string): Error => {
|
|
96
|
+
if (rejection instanceof Error) return rejection;
|
|
97
|
+
if (typeof rejection === 'string') return new Error(rejection);
|
|
98
|
+
|
|
99
|
+
return new Error(fallbackMessage);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const getNumericErrorProperty = (error: Error, property: 'http' | 'status' | 'statusCode') => {
|
|
103
|
+
const value = (error as Error & Partial<Record<typeof property, unknown>>)[property];
|
|
104
|
+
return typeof value === 'number' ? value : undefined;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const getErrorHttpCode = (error: Error) =>
|
|
108
|
+
getNumericErrorProperty(error, 'http') ??
|
|
109
|
+
getNumericErrorProperty(error, 'status') ??
|
|
110
|
+
getNumericErrorProperty(error, 'statusCode') ??
|
|
111
|
+
500;
|
|
112
|
+
|
|
95
113
|
/*----------------------------------
|
|
96
114
|
- FUNCTIONS
|
|
97
115
|
----------------------------------*/
|
|
@@ -138,12 +156,12 @@ export abstract class Application<
|
|
|
138
156
|
// Handle unhandled crash
|
|
139
157
|
this.on('error', (e, request) => this.container.handleBug(e, 'An error occured in the application', request));
|
|
140
158
|
|
|
141
|
-
process.on('unhandledRejection', (error:
|
|
159
|
+
process.on('unhandledRejection', (error: unknown) => {
|
|
142
160
|
// Log so we know it's coming from unhandledRejection
|
|
143
161
|
console.error('unhandledRejection', error);
|
|
144
162
|
|
|
145
163
|
// We don't log the error here because it's the role of the app to decidehiw to log errors
|
|
146
|
-
this.
|
|
164
|
+
void this.reportError(error);
|
|
147
165
|
});
|
|
148
166
|
|
|
149
167
|
// We can't pass this in super so we assign here
|
|
@@ -155,6 +173,14 @@ export abstract class Application<
|
|
|
155
173
|
return this.container.Console.createBugReport(new Anomaly(...anomalyArgs));
|
|
156
174
|
}
|
|
157
175
|
|
|
176
|
+
public async reportError(rejection: unknown, request?: ServerRequest<TServerRouter>) {
|
|
177
|
+
const error = normalizeReportedError(rejection, 'Unknown application error');
|
|
178
|
+
const code = getErrorHttpCode(error);
|
|
179
|
+
|
|
180
|
+
if (code === 500) await this.runHook('error', error, request);
|
|
181
|
+
else await this.runHook('error.' + code, error, request);
|
|
182
|
+
}
|
|
183
|
+
|
|
158
184
|
/*----------------------------------
|
|
159
185
|
- COMMANDS
|
|
160
186
|
----------------------------------*/
|
|
@@ -637,7 +637,7 @@ export default class ServerRouter<
|
|
|
637
637
|
error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown request.finished hook error');
|
|
638
638
|
|
|
639
639
|
try {
|
|
640
|
-
await this.app.
|
|
640
|
+
await this.app.reportError(typedError, request);
|
|
641
641
|
} catch (hookError) {
|
|
642
642
|
console.error('request.finished hook error', typedError, 'Error hook failure', hookError);
|
|
643
643
|
}
|
|
@@ -1111,7 +1111,7 @@ export default class ServerRouter<
|
|
|
1111
1111
|
console.log(LogPrefix, 'Error catched from the router:', error);
|
|
1112
1112
|
|
|
1113
1113
|
// Report error
|
|
1114
|
-
await this.app.
|
|
1114
|
+
await this.app.reportError(error, request);
|
|
1115
1115
|
|
|
1116
1116
|
// Don't exose technical errors to users
|
|
1117
1117
|
if (this.app.env.profile === 'prod')
|
|
@@ -1123,7 +1123,7 @@ export default class ServerRouter<
|
|
|
1123
1123
|
/*if (this.app.env.profile === "dev")
|
|
1124
1124
|
console.warn(e);*/
|
|
1125
1125
|
|
|
1126
|
-
await this.app.
|
|
1126
|
+
await this.app.reportError(error, request);
|
|
1127
1127
|
}
|
|
1128
1128
|
|
|
1129
1129
|
// Return error based on the request format
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const Module = require('node:module');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const coreRoot = path.join(__dirname, '..');
|
|
6
|
+
require('module-alias').addAliases({
|
|
7
|
+
'@client': path.join(coreRoot, 'client'),
|
|
8
|
+
'@common': path.join(coreRoot, 'common'),
|
|
9
|
+
'@server': path.join(coreRoot, 'server'),
|
|
10
|
+
});
|
|
11
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
12
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
13
|
+
require('ts-node/register/transpile-only');
|
|
14
|
+
const previousLessExtension = require.extensions['.less'];
|
|
15
|
+
require.extensions['.less'] = () => {};
|
|
16
|
+
|
|
17
|
+
const clientContextStub = () => ({ side: 'client' });
|
|
18
|
+
clientContextStub.default = clientContextStub;
|
|
19
|
+
const originalLoad = Module._load;
|
|
20
|
+
Module._load = function load(request, parent, isMain) {
|
|
21
|
+
if (request === '@/client/context') return clientContextStub;
|
|
22
|
+
|
|
23
|
+
return originalLoad.call(this, request, parent, isMain);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const previousWindow = global.window;
|
|
27
|
+
const previousDocument = global.document;
|
|
28
|
+
global.window = {
|
|
29
|
+
dev: false,
|
|
30
|
+
addEventListener: () => {},
|
|
31
|
+
removeEventListener: () => {},
|
|
32
|
+
history: {
|
|
33
|
+
state: {},
|
|
34
|
+
pushState: () => {},
|
|
35
|
+
replaceState: () => {},
|
|
36
|
+
},
|
|
37
|
+
location: {
|
|
38
|
+
hash: '',
|
|
39
|
+
pathname: '/',
|
|
40
|
+
search: '',
|
|
41
|
+
assign: () => {},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
global.document = { defaultView: global.window };
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
default: Application,
|
|
48
|
+
getClientErrorMessage,
|
|
49
|
+
normalizeClientError,
|
|
50
|
+
} = require('../client/app/index.ts');
|
|
51
|
+
|
|
52
|
+
class TestApplication extends Application {
|
|
53
|
+
boot() {}
|
|
54
|
+
handleUpdate() {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('client error message normalization accepts unknown caught values', () => {
|
|
58
|
+
assert.equal(getClientErrorMessage(new Error('boom'), 'fallback'), 'boom');
|
|
59
|
+
assert.equal(getClientErrorMessage('string failure', 'fallback'), 'string failure');
|
|
60
|
+
assert.equal(getClientErrorMessage({ message: 'object failure' }, 'fallback'), 'object failure');
|
|
61
|
+
assert.equal(getClientErrorMessage({ code: 'UNKNOWN' }, 'fallback'), 'fallback');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('client error normalization returns an Error instance', () => {
|
|
65
|
+
const existing = new Error('existing');
|
|
66
|
+
|
|
67
|
+
assert.equal(normalizeClientError(existing), existing);
|
|
68
|
+
assert.equal(normalizeClientError('string failure').message, 'string failure');
|
|
69
|
+
assert.equal(normalizeClientError({ code: 'UNKNOWN' }, 'fallback').message, 'fallback');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('client app handleError logs and returns displayable message', () => {
|
|
73
|
+
const app = new TestApplication();
|
|
74
|
+
const logged = [];
|
|
75
|
+
const originalConsoleError = console.error;
|
|
76
|
+
console.error = (error) => {
|
|
77
|
+
logged.push(error);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const message = app.handleError({ code: 'UNKNOWN' }, 'Unable to finish action.');
|
|
82
|
+
|
|
83
|
+
assert.equal(message, 'Unable to finish action.');
|
|
84
|
+
assert.equal(logged.length, 1);
|
|
85
|
+
assert.equal(logged[0] instanceof Error, true);
|
|
86
|
+
assert.equal(logged[0].message, 'Unable to finish action.');
|
|
87
|
+
} finally {
|
|
88
|
+
console.error = originalConsoleError;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterAll(() => {
|
|
93
|
+
Module._load = originalLoad;
|
|
94
|
+
if (previousLessExtension === undefined) delete require.extensions['.less'];
|
|
95
|
+
else require.extensions['.less'] = previousLessExtension;
|
|
96
|
+
if (previousWindow === undefined) delete global.window;
|
|
97
|
+
else global.window = previousWindow;
|
|
98
|
+
if (previousDocument === undefined) delete global.document;
|
|
99
|
+
else global.document = previousDocument;
|
|
100
|
+
});
|
|
@@ -377,8 +377,9 @@ import SchemaRouter from '@server/services/schema/router';
|
|
|
377
377
|
import * as appConfig from '@/server/config/app';
|
|
378
378
|
|
|
379
379
|
export default defineApplication({
|
|
380
|
-
services: (
|
|
381
|
-
|
|
380
|
+
services: () => ({}),
|
|
381
|
+
router: (app) =>
|
|
382
|
+
new Router(
|
|
382
383
|
app,
|
|
383
384
|
{
|
|
384
385
|
...appConfig.routerBaseConfig,
|
|
@@ -388,7 +389,6 @@ export default defineApplication({
|
|
|
388
389
|
},
|
|
389
390
|
app,
|
|
390
391
|
),
|
|
391
|
-
}),
|
|
392
392
|
});
|
|
393
393
|
`,
|
|
394
394
|
);
|
|
@@ -405,9 +405,6 @@ export default class TranspileWatchClient extends ClientApplication {
|
|
|
405
405
|
|
|
406
406
|
public boot() {}
|
|
407
407
|
public handleUpdate() {}
|
|
408
|
-
public handleError(error: Error) {
|
|
409
|
-
throw error;
|
|
410
|
-
}
|
|
411
408
|
}
|
|
412
409
|
`,
|
|
413
410
|
);
|
|
@@ -3,10 +3,10 @@ const { Linter } = require('eslint');
|
|
|
3
3
|
|
|
4
4
|
const { createProteumEslintConfig } = require('../eslint.js');
|
|
5
5
|
|
|
6
|
-
const lint = (code) => {
|
|
6
|
+
const lint = (code, filename = 'client/example.tsx') => {
|
|
7
7
|
const linter = new Linter({ configType: 'flat' });
|
|
8
8
|
return linter.verify(code, createProteumEslintConfig(), {
|
|
9
|
-
filename
|
|
9
|
+
filename,
|
|
10
10
|
});
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -102,7 +102,7 @@ test('proteum lint allows rethrowing the caught error', () => {
|
|
|
102
102
|
assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
test('proteum lint
|
|
105
|
+
test('proteum lint rejects user feedback that does not route the caught error', () => {
|
|
106
106
|
const messages = lint(`
|
|
107
107
|
export const run = async () => {
|
|
108
108
|
try {
|
|
@@ -115,10 +115,10 @@ test('proteum lint allows surfacing original error details', () => {
|
|
|
115
115
|
};
|
|
116
116
|
`);
|
|
117
117
|
|
|
118
|
-
assert.equal(messages.
|
|
118
|
+
assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
test('proteum lint
|
|
121
|
+
test('proteum lint rejects derived message state that does not route the caught error', () => {
|
|
122
122
|
const messages = lint(`
|
|
123
123
|
export const run = async () => {
|
|
124
124
|
try {
|
|
@@ -130,10 +130,10 @@ test('proteum lint allows surfacing a message derived from the caught error', ()
|
|
|
130
130
|
};
|
|
131
131
|
`);
|
|
132
132
|
|
|
133
|
-
assert.equal(messages.
|
|
133
|
+
assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
test('proteum lint allows
|
|
136
|
+
test('proteum lint allows client catches routed to context app error handling', () => {
|
|
137
137
|
const messages = lint(`
|
|
138
138
|
export const run = () => {
|
|
139
139
|
const context = useContext();
|
|
@@ -145,3 +145,142 @@ test('proteum lint allows routing promise failures to app error handling', () =>
|
|
|
145
145
|
|
|
146
146
|
assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
test('proteum lint allows client catches using app error messages for UI feedback', () => {
|
|
150
|
+
const messages = lint(`
|
|
151
|
+
export const run = async () => {
|
|
152
|
+
const context = useContext();
|
|
153
|
+
try {
|
|
154
|
+
await Investor.api.ensureApiKey();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
setError(context.app.handleError(error, 'Unable to finish this action.'));
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('proteum lint allows client catches routed to local app error handling', () => {
|
|
165
|
+
const messages = lint(`
|
|
166
|
+
export const run = async () => {
|
|
167
|
+
const app = useContext();
|
|
168
|
+
try {
|
|
169
|
+
await Investor.api.ensureApiKey();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
app.handleError(error);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
`);
|
|
175
|
+
|
|
176
|
+
assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('proteum lint allows client catches routed to useContext app error handling', () => {
|
|
180
|
+
const messages = lint(`
|
|
181
|
+
export const run = async () => {
|
|
182
|
+
try {
|
|
183
|
+
await Investor.api.ensureApiKey();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
useContext().app.handleError(error);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
`);
|
|
189
|
+
|
|
190
|
+
assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('proteum lint rejects bare client error handlers', () => {
|
|
194
|
+
const messages = lint(`
|
|
195
|
+
export const run = async () => {
|
|
196
|
+
try {
|
|
197
|
+
await Investor.api.ensureApiKey();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
handleError(error);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
`);
|
|
203
|
+
|
|
204
|
+
assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('proteum lint allows server catches routed to app error reporting', () => {
|
|
208
|
+
const messages = lint(
|
|
209
|
+
`
|
|
210
|
+
export const run = async (context) => {
|
|
211
|
+
try {
|
|
212
|
+
await context.services.Worker.run();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
await context.app.reportError(error, context.request);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
`,
|
|
218
|
+
'server/example.ts',
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('proteum lint rejects server catches routed to client app error handling', () => {
|
|
225
|
+
const messages = lint(
|
|
226
|
+
`
|
|
227
|
+
export const run = async (context) => {
|
|
228
|
+
try {
|
|
229
|
+
await context.services.Worker.run();
|
|
230
|
+
} catch (error) {
|
|
231
|
+
context.app.handleError(error);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
`,
|
|
235
|
+
'server/example.ts',
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('proteum lint rejects raw server error hooks as caught error handling', () => {
|
|
242
|
+
const messages = lint(
|
|
243
|
+
`
|
|
244
|
+
export const run = async (context) => {
|
|
245
|
+
try {
|
|
246
|
+
await context.services.Worker.run();
|
|
247
|
+
} catch (error) {
|
|
248
|
+
await context.app.runHook('error', error, context.request);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
`,
|
|
252
|
+
'server/example.ts',
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('proteum lint allows manual promise rejection', () => {
|
|
259
|
+
const messages = lint(
|
|
260
|
+
`
|
|
261
|
+
export const run = (input) =>
|
|
262
|
+
new Promise((resolve, reject) => {
|
|
263
|
+
input.load().catch((error) => {
|
|
264
|
+
reject(error);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
`,
|
|
268
|
+
'common/example.ts',
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('proteum lint allows direct reject promise catch handlers', () => {
|
|
275
|
+
const messages = lint(
|
|
276
|
+
`
|
|
277
|
+
export const run = (input) =>
|
|
278
|
+
new Promise((resolve, reject) => {
|
|
279
|
+
input.load().catch(reject);
|
|
280
|
+
});
|
|
281
|
+
`,
|
|
282
|
+
'common/example.ts',
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
assert.equal(messages.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
|
|
286
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const coreRoot = path.resolve(__dirname, '..');
|
|
5
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
6
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
7
|
+
require('ts-node/register/transpile-only');
|
|
8
|
+
|
|
9
|
+
const { createServerIndexTemplate } = require('../cli/scaffold/templates.ts');
|
|
10
|
+
|
|
11
|
+
test('server index scaffold uses explicit defineApplication router property', () => {
|
|
12
|
+
const content = createServerIndexTemplate({ appIdentifier: 'ExampleApp' });
|
|
13
|
+
|
|
14
|
+
assert.match(content, /export default defineApplication\(\{/);
|
|
15
|
+
assert.match(content, /services: \(\) => \(\{\}\),/);
|
|
16
|
+
assert.match(content, /router: \(app\) =>\s+new Router\(/);
|
|
17
|
+
assert.doesNotMatch(content, /services: \(app\) => \(\{\s+Router: new Router\(/);
|
|
18
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const Module = require('node:module');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const coreRoot = path.join(__dirname, '..');
|
|
6
|
+
require('module-alias').addAliases({
|
|
7
|
+
'@client': path.join(coreRoot, 'client'),
|
|
8
|
+
'@common': path.join(coreRoot, 'common'),
|
|
9
|
+
'@server': path.join(coreRoot, 'server'),
|
|
10
|
+
});
|
|
11
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
12
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
13
|
+
require('ts-node/register/transpile-only');
|
|
14
|
+
|
|
15
|
+
const appContainerStub = {
|
|
16
|
+
Environment: {
|
|
17
|
+
profile: 'test',
|
|
18
|
+
connectedProjects: [],
|
|
19
|
+
},
|
|
20
|
+
Identity: {},
|
|
21
|
+
Setup: {},
|
|
22
|
+
Console: {
|
|
23
|
+
createBugReport: () => {},
|
|
24
|
+
},
|
|
25
|
+
Trace: {
|
|
26
|
+
finishRequest: () => {},
|
|
27
|
+
record: () => {},
|
|
28
|
+
releaseRequest: () => {},
|
|
29
|
+
},
|
|
30
|
+
handleBug: () => {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const applicationModulePath = path.join(coreRoot, 'server/app/index.ts');
|
|
34
|
+
const originalLoad = Module._load;
|
|
35
|
+
Module._load = function patchedLoad(request, parent, isMain) {
|
|
36
|
+
if (parent?.filename === applicationModulePath && request === './container') {
|
|
37
|
+
return { __esModule: true, default: appContainerStub };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return originalLoad.call(this, request, parent, isMain);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let Application;
|
|
44
|
+
try {
|
|
45
|
+
Application = require('../server/app/index.ts').Application;
|
|
46
|
+
} finally {
|
|
47
|
+
Module._load = originalLoad;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class TestApplication extends Application {}
|
|
51
|
+
|
|
52
|
+
const createAppWithHooks = (hooks) => {
|
|
53
|
+
const app = new TestApplication();
|
|
54
|
+
app.hooks = hooks;
|
|
55
|
+
|
|
56
|
+
return app;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
test('server application reports plain errors through the default error hook', async () => {
|
|
60
|
+
const events = [];
|
|
61
|
+
const request = { id: 'request-1' };
|
|
62
|
+
const app = createAppWithHooks({
|
|
63
|
+
error: [
|
|
64
|
+
async (error, hookRequest) => {
|
|
65
|
+
events.push({
|
|
66
|
+
hook: 'error',
|
|
67
|
+
message: error.message,
|
|
68
|
+
requestId: hookRequest.id,
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await app.reportError(new Error('boom'), request);
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(events, [{ hook: 'error', message: 'boom', requestId: 'request-1' }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('server application reports HTTP errors through code-specific hooks', async () => {
|
|
80
|
+
const events = [];
|
|
81
|
+
const error = new Error('missing');
|
|
82
|
+
error.http = 404;
|
|
83
|
+
const request = { id: 'request-404' };
|
|
84
|
+
const app = createAppWithHooks({
|
|
85
|
+
'error.404': [
|
|
86
|
+
async (hookError, hookRequest) => {
|
|
87
|
+
events.push({
|
|
88
|
+
hook: 'error.404',
|
|
89
|
+
message: hookError.message,
|
|
90
|
+
requestId: hookRequest.id,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await app.reportError(error, request);
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(events, [{ hook: 'error.404', message: 'missing', requestId: 'request-404' }]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('server application reports status errors through code-specific hooks', async () => {
|
|
102
|
+
const events = [];
|
|
103
|
+
const error = new Error('forbidden');
|
|
104
|
+
error.status = 403;
|
|
105
|
+
const app = createAppWithHooks({
|
|
106
|
+
'error.403': [
|
|
107
|
+
async (hookError) => {
|
|
108
|
+
events.push({
|
|
109
|
+
hook: 'error.403',
|
|
110
|
+
message: hookError.message,
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await app.reportError(error);
|
|
117
|
+
|
|
118
|
+
assert.deepEqual(events, [{ hook: 'error.403', message: 'forbidden' }]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('server application normalizes non-error rejections before reporting', async () => {
|
|
122
|
+
const messages = [];
|
|
123
|
+
const app = createAppWithHooks({
|
|
124
|
+
error: [
|
|
125
|
+
async (error) => {
|
|
126
|
+
messages.push(error.message);
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await app.reportError('string failure');
|
|
132
|
+
await app.reportError({ reason: 'unknown failure' });
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(messages, ['string failure', 'Unknown application error']);
|
|
135
|
+
});
|