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 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 concrete `Application` subclass.
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
- export default defineApplication({
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
- - `doc/`: focused design notes and internal documentation
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 includes rethrowing the error, passing it to `useContext().app.handleError(error)` or `context.app.handleError(error)`, logging/reporting it with the original error, or surfacing original detail such as `error.message` in user-visible feedback.
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: (app) => ({
176
- Router: new Router(
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
 
@@ -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
- const error = new Error(e.reason); // How to get stacktrace ?
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 abstract handleError(error: CoreError | Error): void;
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: 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 as Error);
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 preservingCallNames = new Set([
125
- 'captureError',
126
- 'captureException',
127
- 'consoleError',
128
- 'handleError',
129
- 'logError',
130
- 'onError',
131
- 'reject',
132
- 'reportError',
133
- 'setError',
134
- 'setErrorMessage',
135
- ]);
136
-
137
- const preservingMemberNames = new Set(['captureException', 'error', 'handleError', 'reject', 'warn']);
138
-
139
- const isPreservingCall = (callExpression, names) => {
140
- const propertyName = getCalleePropertyName(callExpression.callee);
141
- if (!propertyName) return false;
142
- if (callExpression.callee.type === 'MemberExpression' && callExpression.callee.object?.name === 'console')
143
- return false;
144
-
145
- const isKnownPreserver =
146
- preservingCallNames.has(propertyName) ||
147
- (callExpression.callee.type === 'MemberExpression' && preservingMemberNames.has(propertyName));
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 handlerPreservesCaughtError = (node, names) => {
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
- if (name && directPromiseCatchHandlers.has(name)) return true;
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 be preserved, reported, rethrown, or surfaced with original detail.',
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 preserved. Use `catch (error)` and rethrow, report, route, or surface original details.',
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, report it, route it to app error handling, or surface its original details.',
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 preserved. Rethrow it, report it, route it, or surface original error details.',
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.0",
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",
@@ -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: any, _promise: any) => {
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.runHook('error', error);
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.runHook('error', typedError, request);
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.runHook('error', error, request);
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.runHook('error.' + code, error, request);
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: (app) => ({
381
- Router: new Router(
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: 'client/example.tsx',
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 allows surfacing original error details', () => {
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.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
118
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
119
119
  });
120
120
 
121
- test('proteum lint allows surfacing a message derived from the caught error', () => {
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.filter((message) => message.ruleId === swallowedErrorRuleId).length, 0);
133
+ assert.equal(messages.some((message) => message.ruleId === swallowedErrorRuleId), true);
134
134
  });
135
135
 
136
- test('proteum lint allows routing promise failures to app error handling', () => {
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
+ });