proteum 2.4.4 → 2.5.0

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.
Files changed (73) hide show
  1. package/README.md +60 -55
  2. package/agents/project/AGENTS.md +112 -31
  3. package/agents/project/CODING_STYLE.md +2 -2
  4. package/agents/project/app-root/AGENTS.md +1 -3
  5. package/agents/project/client/AGENTS.md +1 -1
  6. package/agents/project/client/pages/AGENTS.md +21 -9
  7. package/agents/project/diagnostics.md +2 -2
  8. package/agents/project/optimizations.md +1 -1
  9. package/agents/project/root/AGENTS.md +105 -22
  10. package/agents/project/server/routes/AGENTS.md +30 -1
  11. package/agents/project/tests/AGENTS.md +1 -1
  12. package/cli/commands/doctor.ts +54 -3
  13. package/cli/commands/runtime.ts +6 -0
  14. package/cli/commands/worktree.ts +116 -0
  15. package/cli/compiler/artifacts/controllers.ts +16 -15
  16. package/cli/compiler/artifacts/discovery.ts +129 -17
  17. package/cli/compiler/artifacts/routing.ts +0 -5
  18. package/cli/compiler/artifacts/services.ts +253 -76
  19. package/cli/compiler/common/controllers.ts +159 -57
  20. package/cli/compiler/common/generatedRouteModules.ts +457 -363
  21. package/cli/mcp/router.ts +47 -3
  22. package/cli/presentation/commands.ts +25 -15
  23. package/cli/runtime/commands.ts +39 -12
  24. package/cli/runtime/worktreeBootstrap.ts +608 -0
  25. package/cli/scaffold/index.ts +28 -18
  26. package/cli/scaffold/templates.ts +44 -33
  27. package/cli/utils/agents.ts +14 -1
  28. package/client/services/router/index.tsx +23 -3
  29. package/client/services/router/request/api.ts +14 -4
  30. package/common/dev/contractsDoctor.ts +1 -1
  31. package/common/dev/mcpPayloads.ts +8 -1
  32. package/common/env/proteumEnv.ts +14 -2
  33. package/common/router/contracts.ts +1 -1
  34. package/common/router/definitions.ts +177 -0
  35. package/common/router/index.ts +23 -12
  36. package/common/router/pageData.ts +5 -5
  37. package/common/router/register.ts +2 -2
  38. package/common/router/request/api.ts +12 -2
  39. package/docs/agent-routing.md +5 -2
  40. package/docs/diagnostics.md +2 -0
  41. package/docs/mcp.md +6 -3
  42. package/eslint.js +36 -1
  43. package/package.json +1 -1
  44. package/server/app/commands.ts +5 -1
  45. package/server/app/container/console/index.ts +1 -1
  46. package/server/app/controller/index.ts +98 -40
  47. package/server/app/index.ts +92 -1
  48. package/server/app/service/index.ts +5 -1
  49. package/server/index.ts +6 -2
  50. package/server/services/router/index.ts +47 -38
  51. package/server/services/router/response/index.ts +2 -2
  52. package/tests/agents-utils.test.cjs +14 -1
  53. package/tests/cli-mcp-command.test.cjs +84 -0
  54. package/tests/definition-contracts.test.cjs +453 -0
  55. package/tests/dev-transpile-watch.test.cjs +37 -28
  56. package/tests/eslint-rules.test.cjs +39 -1
  57. package/tests/mcp.test.cjs +90 -0
  58. package/tests/worktree-bootstrap.test.cjs +206 -0
  59. package/types/aliases.d.ts +0 -5
  60. package/types/controller-input.test.ts +23 -17
  61. package/types/controller-request-context.test.ts +10 -11
  62. package/cli/commands/migrate.ts +0 -51
  63. package/cli/migrate/pageContract.ts +0 -516
  64. package/docs/migrate-from-2.1.3.md +0 -396
  65. package/scripts/cleanup-generated-controllers.ts +0 -62
  66. package/scripts/fix-reference-app-typing.ts +0 -490
  67. package/scripts/format-router-registrations.ts +0 -119
  68. package/scripts/migrate-explicit-controllers-and-request.ts +0 -423
  69. package/scripts/refactor-client-app-imports.ts +0 -244
  70. package/scripts/refactor-client-pages.ts +0 -587
  71. package/scripts/refactor-server-controllers.ts +0 -471
  72. package/scripts/refactor-server-runtime-aliases.ts +0 -360
  73. package/scripts/restore-client-app-import-files.ts +0 -41
@@ -68,7 +68,7 @@ proteum mcp
68
68
 
69
69
  The machine router discovers live `proteum dev` sessions and offline Proteum app roots under a cwd. `proteum dev` ensures one managed machine MCP daemon is running; terminal `proteum mcp` starts or reuses that daemon and prints a compact central MCP banner with the HTTP client URL, while MCP clients can use stdio. Agents should call MCP `workflow_start` with `cwd` or a known `projectId`, use `project_resolve { cwd }` when routing is ambiguous or offline, and pass the returned live `projectId` to every follow-up app-bound MCP tool. Offline candidates include port-inspected next actions, so agents should follow those instead of guessing the manifest default port. The router forwards to the selected dev-hosted `/__proteum/mcp` endpoint and strips routing fields before the app sees the call.
70
70
 
71
- If machine MCP routing returns offline candidates, choose the intended app root and follow that candidate's next action from the app root, not from the monorepo wrapper. If machine MCP routing fails, run `proteum mcp status` and `proteum runtime status` from the intended app root; if no live session exists, use the exact Start Dev next action from runtime status so occupied router/HMR ports are avoided. If the same app already responds on the configured port without live tracking, use or repair that runtime instead of starting another server. Do not `curl` normal page routes to identify which app owns a port; use runtime status or Proteum dev-only endpoints. If a live session exists but runtime/MCP is unreachable, stop the listed session file first, then start dev again. Do not run diagnose, trace, or perf reads while runtime health is unreachable. Do not start a second dev server in the same worktree, and do not start a second managed MCP daemon. Then retry MCP `workflow_start`.
71
+ If machine MCP routing returns offline candidates, choose the intended app root and follow that candidate's next action from the app root, not from the monorepo wrapper. In `/.codex/worktrees/`, if `workflow_start` returns a worktree bootstrap block, run `npx proteum worktree init --source <source-app-root>` or the returned `--refresh` command before any runtime read. If machine MCP routing fails, run `proteum mcp status` and `proteum runtime status` from the intended app root; if no live session exists, use the exact Start Dev next action from runtime status so occupied router/HMR ports are avoided. If the same app already responds on the configured port without live tracking, use or repair that runtime instead of starting another server. Do not `curl` normal page routes to identify which app owns a port; use runtime status or Proteum dev-only endpoints. If a live session exists but runtime/MCP is unreachable, stop the listed session file first, then start dev again. Do not run diagnose, trace, or perf reads while runtime health is unreachable. Do not start a second dev server in the same worktree, and do not start a second managed MCP daemon. Then retry MCP `workflow_start`.
72
72
 
73
73
  Prefer CLI over MCP when the result must be reproducible as a shell command, part of verification, or copied into CI/debug instructions.
74
74
 
@@ -93,9 +93,12 @@ The router standard is trigger -> canonical instruction file, not trigger -> cop
93
93
 
94
94
  Standard triggered reads:
95
95
 
96
+ - Worktree Preflight (`cwd` inside `/.codex/worktrees/`, newly created Proteum worktree, or before editing in a Codex worktree): root contract fallback, then `npx proteum worktree init --source <source-app-root>` or the returned `--refresh` command, `npx proteum runtime status`, and tracked `npx proteum dev` for runtime-visible work.
96
97
  - Git lifecycle (`commit`, `and commit`, `stage`, `push`, `PR`, pull request): root contract fallback.
97
- - Before finishing production code changes: root contract fallback, `CODING_STYLE.md`, and touched area `AGENTS.md`.
98
+ - Before git writes after a bug fix, behavior change, decision change, or docs-relevant production change: `DOCUMENTATION.md`.
99
+ - Before finishing production code changes: root contract fallback, `DOCUMENTATION.md`, `CODING_STYLE.md`, and touched area `AGENTS.md`.
98
100
  - Runtime-visible, request-time, router, SSR, browser, or controller behavior: root contract fallback plus `diagnostics.md`.
101
+ - Bug fixes, regressions, incidents, broken public routes, auth/OAuth failures, integration failures, or production behavior fixes: `DOCUMENTATION.md`.
99
102
  - Non-trivial feature, product, business-rule, UX, copy, or docs changes: `DOCUMENTATION.md`.
100
103
  - Implementation edits: `CODING_STYLE.md` plus the matching area file from the routing table.
101
104
 
@@ -112,6 +112,8 @@ Default compact command output follows this shape:
112
112
 
113
113
  `proteum runtime status` emits the current app manifest summary, tracked dev sessions, selected live session, MCP URL, health status, configured router/HMR port inspection, and a suggested next command. Use it before starting another dev server, and use its Start Dev command instead of probing page bodies when the default port is occupied. If it reports that the same app already responds on the configured port without a live tracked session, use or repair that runtime instead of starting a second server.
114
114
 
115
+ Inside `/.codex/worktrees/`, `proteum dev`, `proteum refresh`, `proteum runtime status`, `proteum verify`, and MCP `workflow_start` require a fresh `.proteum/worktree-bootstrap.json`. If the marker is missing, run `npx proteum worktree init --source <source-app-root>`. If hashes, `.env`, `.proteum/manifest.json`, `node_modules`, or the Proteum version are stale, run the returned `npx proteum worktree init --source <source-app-root> --refresh` command. `PROTEUM_ALLOW_UNBOOTSTRAPPED_WORKTREE=1` bypasses the block but remains visible in runtime status, doctor diagnostics, and MCP output.
116
+
115
117
  During `proteum dev`, `/__proteum/mcp` exposes compact `workflow_start`, `runtime_status`, `orient`, `instructions_resolve`, `route_candidates`, `explain_summary`, `doctor`, `diagnose`, `trace_*`, `perf_*`, and `logs_tail` tools without spawning CLI commands for each repeated read. `proteum dev` also ensures one managed machine `proteum mcp` daemon is running. Through the machine router, call `workflow_start` with `cwd` or a known `projectId`; if routing is ambiguous or returns offline app candidates, use `project_resolve { cwd }`, follow the selected app root's port-inspected next action when needed, then pass the selected live `projectId` to follow-up app-bound tools.
116
118
 
117
119
  MCP tool/resource output follows compact single-line `proteum-mcp-v1` JSON:
package/docs/mcp.md CHANGED
@@ -49,6 +49,8 @@ Example tool calls:
49
49
 
50
50
  `workflow_start` is the only app-bound bootstrap tool that may resolve from `cwd` when `projectId` is not known. It may return offline app candidates when no matching dev server is running yet. Other app-bound tools require a live `projectId`; if they omit it, the router returns a compact error that tells the agent to call `projects_list` or `project_resolve`. There is no single-project fallback, because wrong-project reads are worse than an explicit routing retry.
51
51
 
52
+ When the selected app root is inside `/.codex/worktrees/`, `workflow_start` first checks `.proteum/worktree-bootstrap.json`. If the marker is missing or stale, it returns `ok: false` with a single next action such as `npx proteum worktree init --source <source-app-root>` or the same command with `--refresh`. The router does not forward to the app MCP endpoint until bootstrap is complete, unless `PROTEUM_ALLOW_UNBOOTSTRAPPED_WORKTREE=1` is set; bypasses remain visible in MCP, `runtime status`, and `doctor`.
53
+
52
54
  ## Dev Runtime Endpoint
53
55
 
54
56
  During `proteum dev`, the app exposes the same app-level MCP contract through the official streamable HTTP transport:
@@ -76,9 +78,10 @@ If machine MCP routing fails:
76
78
 
77
79
  1. Run `proteum mcp status`.
78
80
  2. Run `proteum runtime status` from the intended app root. If you are in a monorepo wrapper, use the returned app candidates and exact next action instead of starting dev from the wrapper.
79
- 3. If no live app session exists, use the exact Start Dev next action returned by runtime status. It checks the configured router/HMR ports and suggests an alternate free port when the manifest default is occupied.
80
- 4. If a live session exists but runtime/MCP is unreachable, stop the listed session file with `proteum dev stop --session-file <path>`, then start dev again.
81
- 5. Retry MCP `workflow_start` and use the returned `projectId`.
81
+ 3. If the app root is inside `/.codex/worktrees/` and runtime status or workflow start reports missing/stale bootstrap, run `proteum worktree init --source <source-app-root>` or the returned `--refresh` command first.
82
+ 4. If no live app session exists, use the exact Start Dev next action returned by runtime status. It checks the configured router/HMR ports and suggests an alternate free port when the manifest default is occupied.
83
+ 5. If a live session exists but runtime/MCP is unreachable, stop the listed session file with `proteum dev stop --session-file <path>`, then start dev again.
84
+ 6. Retry MCP `workflow_start` and use the returned `projectId`.
82
85
 
83
86
  Offline `project_resolve` and `workflow_start` candidates also inspect configured router/HMR ports before returning `nextAction`. If the configured port already serves the same app but no live machine project is registered, the next action is runtime tracking repair, not starting a second dev server.
84
87
 
package/eslint.js CHANGED
@@ -139,6 +139,8 @@ const preservingMemberNames = new Set(['captureException', 'error', 'handleError
139
139
  const isPreservingCall = (callExpression, names) => {
140
140
  const propertyName = getCalleePropertyName(callExpression.callee);
141
141
  if (!propertyName) return false;
142
+ if (callExpression.callee.type === 'MemberExpression' && callExpression.callee.object?.name === 'console')
143
+ return false;
142
144
 
143
145
  const isKnownPreserver =
144
146
  preservingCallNames.has(propertyName) ||
@@ -170,7 +172,7 @@ const directPromiseCatchHandlers = new Set([
170
172
  const isDirectPromiseCatchHandler = (node) => {
171
173
  const name = getCalleePropertyName(node);
172
174
  if (name && directPromiseCatchHandlers.has(name)) return true;
173
- return node?.type === 'MemberExpression' && node.object?.type === 'Identifier' && node.object.name === 'console';
175
+ return false;
174
176
  };
175
177
 
176
178
  const createSwallowedErrorRule = () => ({
@@ -228,6 +230,37 @@ const createSwallowedErrorRule = () => ({
228
230
  },
229
231
  });
230
232
 
233
+ const createNoAppImportRule = () => ({
234
+ meta: {
235
+ type: 'problem',
236
+ docs: {
237
+ description: 'Disallow Proteum contextual @app imports in user code.',
238
+ },
239
+ messages: {
240
+ noAppImport:
241
+ '`@app` is not a real runtime module. Receive app services through typed route/controller callback context instead.',
242
+ },
243
+ schema: [],
244
+ },
245
+ create(context) {
246
+ return {
247
+ ImportDeclaration(node) {
248
+ if (node.source?.value === '@app') context.report({ node, messageId: 'noAppImport' });
249
+ },
250
+ CallExpression(node) {
251
+ if (
252
+ node.callee?.type === 'Identifier' &&
253
+ node.callee.name === 'require' &&
254
+ node.arguments?.[0]?.type === 'Literal' &&
255
+ node.arguments[0].value === '@app'
256
+ ) {
257
+ context.report({ node, messageId: 'noAppImport' });
258
+ }
259
+ },
260
+ };
261
+ },
262
+ });
263
+
231
264
  const createProteumEslintConfig = ({ ignores = [] } = {}) => [
232
265
  {
233
266
  ignores: [...defaultIgnores, ...ignores],
@@ -253,6 +286,7 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
253
286
  '@typescript-eslint': tseslint.plugin,
254
287
  proteum: {
255
288
  rules: {
289
+ 'no-app-import': createNoAppImportRule(),
256
290
  'no-swallowed-caught-error': createSwallowedErrorRule(),
257
291
  },
258
292
  },
@@ -262,6 +296,7 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
262
296
  },
263
297
  rules: {
264
298
  '@typescript-eslint/no-explicit-any': 'error',
299
+ 'proteum/no-app-import': 'error',
265
300
  'proteum/no-swallowed-caught-error': 'error',
266
301
  'no-restricted-syntax': [
267
302
  'error',
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.4.4",
4
+ "version": "2.5.0",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -44,7 +44,11 @@ export abstract class Commands<TApplication extends TCommandApplication = TComma
44
44
  }
45
45
 
46
46
  public get models(): object {
47
- const models = this.app.models?.client ?? this.app.Models?.client;
47
+ const appRecord = this.app as unknown as Record<string, unknown>;
48
+ const explicitModels = Object.prototype.hasOwnProperty.call(appRecord, 'models')
49
+ ? (appRecord.models as { client?: object } | undefined)
50
+ : undefined;
51
+ const models = explicitModels?.client ?? this.app.Models?.client;
48
52
 
49
53
  if (!models)
50
54
  throw new Error(`${this.constructor.name} tried to access models but no Models service is registered.`);
@@ -539,7 +539,7 @@ Logs: ${
539
539
  public jsonToHTML(json: unknown): string {
540
540
  if (!json) return 'No data';
541
541
 
542
- const coloredJson = highlight(stringify(json, null, 4), { language: 'json', ignoreIllegals: true });
542
+ const coloredJson = highlight(stringify(json, undefined, 4), { language: 'json', ignoreIllegals: true });
543
543
 
544
544
  const html = ansi2Html.toHtml(coloredJson);
545
545
 
@@ -5,8 +5,6 @@
5
5
  // Npm
6
6
  import zod from 'zod';
7
7
 
8
- // Core
9
- import context from '@server/context';
10
8
  import {
11
9
  toValidationSchema,
12
10
  type TValidationSchema,
@@ -63,11 +61,6 @@ export type TControllerRequestContext<
63
61
  } & (TRouter extends TAnyRouter ? TRouterContextServices<TControllerRouter<TRouter>> : {}) &
64
62
  TRequestServices;
65
63
 
66
- type TControllerBaseContext<TApplication extends object> = {
67
- app: TApplication;
68
- request: { data: TObjetDonnees };
69
- };
70
-
71
64
  type TControllerDefaultContext<TApplication extends object, TRequestServices extends object> = {
72
65
  app: TApplication;
73
66
  context: object;
@@ -80,43 +73,108 @@ type TControllerDefaultContext<TApplication extends object, TRequestServices ext
80
73
  } & TRouterContextServices<TControllerApplicationRouter<TApplication>> &
81
74
  TRequestServices;
82
75
 
83
- /*----------------------------------
84
- - CLASS
85
- ----------------------------------*/
86
-
87
- export default abstract class Controller<
76
+ export type TControllerActionContext<
77
+ TInput = undefined,
88
78
  TApplication extends object = object,
89
- TRouter extends object = object,
90
79
  TRequestServices extends object = {},
91
- TContext extends TControllerBaseContext<TApplication> = TControllerDefaultContext<TApplication, TRequestServices>,
92
- > {
93
- public constructor(public request: TContext) {}
94
-
95
- public get app(): TApplication {
96
- return this.request.app as TApplication;
97
- }
98
-
99
- public get services(): TApplication {
100
- return this.app;
101
- }
102
-
103
- public get models(): TControllerModelsClient<TApplication> {
104
- const app = this.app as {
105
- models?: { client?: TControllerModelsClient<TApplication> };
106
- Models?: { client?: TControllerModelsClient<TApplication> };
107
- };
108
- return (app.models?.client ?? app.Models?.client) as TControllerModelsClient<TApplication>;
109
- }
80
+ > = TControllerDefaultContext<TApplication, TRequestServices> & {
81
+ input: TInput;
82
+ models: TControllerModelsClient<TApplication>;
83
+ services: TApplication;
84
+ };
110
85
 
111
- public input<TSchema extends TValidationSchema>(schema: TSchema): zod.output<TSchema>;
112
- public input<TShape extends TValidationShape>(schema: TShape): zod.output<zod.ZodObject<TShape>>;
113
- public input(schema: TValidationSchema | TValidationShape) {
114
- const store = context.getStore() as { inputSchemaUsed?: boolean } | undefined;
86
+ export type TControllerActionDefinition<
87
+ TInput = undefined,
88
+ TResult = unknown,
89
+ TApplication extends object = object,
90
+ TRequestServices extends object = {},
91
+ > = {
92
+ input?: TValidationSchema | TValidationShape;
93
+ handler: (context: TControllerActionContext<TInput, TApplication, TRequestServices>) => TResult;
94
+ };
115
95
 
116
- if (store?.inputSchemaUsed) throw new Error('Controller.input() can only be called once per request handler.');
96
+ export type TControllerDefinition<
97
+ TActions extends Record<string, TControllerActionDefinition<any, any, any, any>>,
98
+ > = {
99
+ kind: 'controller';
100
+ path?: string;
101
+ actions: TActions;
102
+ };
117
103
 
118
- if (store) store.inputSchemaUsed = true;
104
+ export type TControllerActionInput<TController, TMethod extends keyof any> = TController extends { actions: infer TActions }
105
+ ? TMethod extends keyof TActions
106
+ ? TActions[TMethod] extends TControllerActionDefinition<infer TInput, any, any, any>
107
+ ? TInput
108
+ : never
109
+ : never
110
+ : never;
119
111
 
120
- return toValidationSchema(schema).parse(this.request.request.data);
121
- }
112
+ export type TControllerActionResult<TController, TMethod extends keyof any> = TController extends {
113
+ actions: infer TActions;
114
+ }
115
+ ? TMethod extends keyof TActions
116
+ ? TActions[TMethod] extends TControllerActionDefinition<any, infer TResult, any, any>
117
+ ? Awaited<TResult>
118
+ : never
119
+ : never
120
+ : never;
121
+
122
+ export function defineAction<TSchema extends TValidationSchema, TResult>(
123
+ definition: {
124
+ input: TSchema;
125
+ handler: (context: TControllerActionContext<zod.output<TSchema>>) => TResult;
126
+ },
127
+ ): TControllerActionDefinition<zod.output<TSchema>, TResult>;
128
+ export function defineAction<TShape extends TValidationShape, TResult>(
129
+ definition: {
130
+ input: TShape;
131
+ handler: (context: TControllerActionContext<zod.output<zod.ZodObject<TShape>>>) => TResult;
132
+ },
133
+ ): TControllerActionDefinition<zod.output<zod.ZodObject<TShape>>, TResult>;
134
+ export function defineAction<TResult>(
135
+ definition: {
136
+ handler: (context: TControllerActionContext<undefined>) => TResult;
137
+ },
138
+ ): TControllerActionDefinition<undefined, TResult>;
139
+ export function defineAction(definition: {
140
+ input?: TValidationSchema | TValidationShape;
141
+ handler: (context: TControllerActionContext<any>) => unknown;
142
+ }) {
143
+ return definition;
122
144
  }
145
+
146
+ export const defineController = <
147
+ TActions extends Record<string, TControllerActionDefinition<any, any, any, any>>,
148
+ >({
149
+ path,
150
+ actions,
151
+ }: {
152
+ path?: string;
153
+ actions: TActions;
154
+ }): TControllerDefinition<TActions> => ({
155
+ kind: 'controller',
156
+ path,
157
+ actions,
158
+ });
159
+
160
+ export const runControllerAction = (
161
+ action: TControllerActionDefinition<any, any, any, any>,
162
+ requestContext: TControllerDefaultContext<any, any>,
163
+ ) => {
164
+ const input = action.input === undefined ? undefined : toValidationSchema(action.input).parse(requestContext.request.data);
165
+ const app = requestContext.app as {
166
+ models?: { client?: object };
167
+ Models?: { client?: object };
168
+ };
169
+ const appRecord = app as Record<string, unknown>;
170
+ const explicitModels = Object.prototype.hasOwnProperty.call(appRecord, 'models')
171
+ ? (appRecord.models as { client?: object } | undefined)
172
+ : undefined;
173
+
174
+ return action.handler({
175
+ ...requestContext,
176
+ input,
177
+ models: explicitModels?.client ?? app.Models?.client ?? {},
178
+ services: requestContext.app,
179
+ });
180
+ };
@@ -40,6 +40,33 @@ export type TApplicationStartOptions = {
40
40
 
41
41
  export const Service = ServicesContainer;
42
42
 
43
+ type TApplicationDefinitionValue<TValue, TApplication extends object> =
44
+ | TValue
45
+ | ((app: TApplication) => TValue);
46
+
47
+ type TDefinedApplicationContext<
48
+ TServices extends Record<string, unknown>,
49
+ TRouter,
50
+ TModels = unknown,
51
+ > = Application &
52
+ TServices & {
53
+ Router: TRouter;
54
+ models?: TModels;
55
+ Models?: TModels;
56
+ };
57
+
58
+ export type TApplicationDefinition<
59
+ TServices extends Record<string, unknown> = {},
60
+ TRouter = unknown,
61
+ TModels = unknown,
62
+ TCommands = unknown,
63
+ > = {
64
+ services?: TApplicationDefinitionValue<TServices, TDefinedApplicationContext<TServices, TRouter>>;
65
+ router?: TApplicationDefinitionValue<TRouter, TDefinedApplicationContext<TServices, TRouter>>;
66
+ models?: TApplicationDefinitionValue<TModels, TDefinedApplicationContext<TServices, TRouter>>;
67
+ commands?: TApplicationDefinitionValue<TCommands, TDefinedApplicationContext<TServices, TRouter, TModels>>;
68
+ };
69
+
43
70
  // Without prettify, we don't get a clear list of the class properties
44
71
  type Prettify<T> = { [K in keyof T]: T[K] } & {};
45
72
 
@@ -78,7 +105,7 @@ export abstract class Application<
78
105
  public app!: this;
79
106
  public servicesContainer!: TServicesContainer;
80
107
  public userType!: TUser;
81
- public declare Router: object;
108
+ public declare Router: unknown;
82
109
 
83
110
  /*----------------------------------
84
111
  - PROPERTIES
@@ -268,4 +295,68 @@ export abstract class Application<
268
295
  }
269
296
  }
270
297
 
298
+ const resolveApplicationDefinitionValue = <TValue, TApplication extends object>(
299
+ value: TApplicationDefinitionValue<TValue, TApplication> | undefined,
300
+ app: TApplication,
301
+ ): TValue | undefined => (typeof value === 'function' ? (value as (app: TApplication) => TValue)(app) : value);
302
+
303
+ const assignApplicationRecord = (target: object, value: unknown) => {
304
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return;
305
+
306
+ Object.assign(target, value);
307
+ };
308
+
309
+ export const defineApplication = <
310
+ TServices extends Record<string, unknown> = {},
311
+ TRouter = unknown,
312
+ TModels = unknown,
313
+ TCommands = unknown,
314
+ >(
315
+ definition: TApplicationDefinition<TServices, TRouter, TModels, TCommands>,
316
+ ) => {
317
+ type TDefinedApplication = Application &
318
+ TServices & {
319
+ Router: TRouter;
320
+ models?: TModels;
321
+ Models?: TModels;
322
+ commands?: TCommands;
323
+ };
324
+
325
+ class DefinedApplication extends Application {
326
+ public constructor() {
327
+ super();
328
+
329
+ const self = this as unknown as TDefinedApplication & Record<string, unknown>;
330
+ const services = resolveApplicationDefinitionValue(
331
+ definition.services,
332
+ self as TDefinedApplicationContext<TServices, TRouter>,
333
+ );
334
+ assignApplicationRecord(self, services);
335
+
336
+ const router = resolveApplicationDefinitionValue(
337
+ definition.router,
338
+ self as TDefinedApplicationContext<TServices, TRouter>,
339
+ );
340
+ if (router !== undefined) (self as Record<string, unknown>).Router = router;
341
+
342
+ const models = resolveApplicationDefinitionValue(
343
+ definition.models,
344
+ self as TDefinedApplicationContext<TServices, TRouter>,
345
+ );
346
+ if (models !== undefined) {
347
+ (self as Record<string, unknown>).models = models;
348
+ (self as Record<string, unknown>).Models = models;
349
+ }
350
+
351
+ const commands = resolveApplicationDefinitionValue(
352
+ definition.commands,
353
+ self as TDefinedApplicationContext<TServices, TRouter, TModels>,
354
+ );
355
+ if (commands !== undefined) (self as Record<string, unknown>).commands = commands;
356
+ }
357
+ }
358
+
359
+ return DefinedApplication as unknown as new () => TDefinedApplication;
360
+ };
361
+
271
362
  export default Application;
@@ -145,7 +145,11 @@ export default abstract class Service<
145
145
  models?: { client?: TServiceModelsClient<TApplication> };
146
146
  Models?: { client?: TServiceModelsClient<TApplication> };
147
147
  };
148
- const models = app.models?.client ?? app.Models?.client;
148
+ const appRecord = app as Record<string, unknown>;
149
+ const explicitModels = Object.prototype.hasOwnProperty.call(appRecord, 'models')
150
+ ? (appRecord.models as { client?: TServiceModelsClient<TApplication> } | undefined)
151
+ : undefined;
152
+ const models = explicitModels?.client ?? app.Models?.client;
149
153
 
150
154
  if (!models)
151
155
  throw new Error(`${this.constructor.name} tried to access models but no Models service is registered.`);
package/server/index.ts CHANGED
@@ -3,6 +3,10 @@ import Application from '@/server/index';
3
3
  import { isServerHotReloadRequest, serverHotReloadMessageType } from '@common/dev/serverHotReload';
4
4
 
5
5
  const application = AppContainer.start(Application);
6
+ const router = application.Router as {
7
+ started?: Promise<unknown>;
8
+ reloadGeneratedDefinitions?: (changedFiles: string[]) => Promise<unknown>;
9
+ };
6
10
  let shutdownPromise: Promise<void> | undefined;
7
11
 
8
12
  const shutdownApplication = async (reason: string) => {
@@ -36,8 +40,8 @@ if (__DEV__ && typeof process.send === 'function') {
36
40
 
37
41
  void (async () => {
38
42
  try {
39
- await application.Router?.started;
40
- await application.Router.reloadGeneratedDefinitions(message.changedFiles);
43
+ await router.started;
44
+ await router.reloadGeneratedDefinitions?.(message.changedFiles);
41
45
 
42
46
  process.send?.({ type: serverHotReloadMessageType.succeeded, changedFiles: message.changedFiles });
43
47
  } catch (error) {
@@ -14,12 +14,12 @@ const { v4: uuid } = require('uuid') as { v4: () => string };
14
14
  import got from 'got';
15
15
  import hInterval from 'human-interval';
16
16
  import type express from 'express';
17
- import type { Request, Response, NextFunction } from 'express';
18
17
  import zod, { ZodError } from 'zod';
19
18
  export { default as schema } from 'zod';
20
19
 
21
20
  // Core
22
21
  import type { Application } from '@server/app/index';
22
+ import { runControllerAction, type TControllerActionDefinition } from '@server/app/controller';
23
23
  import Service, { TServiceArgs } from '@server/app/service';
24
24
  import context from '@server/context';
25
25
  import type DisksManager from '@server/services/disks';
@@ -30,9 +30,12 @@ import BaseRouter, {
30
30
  TErrorRoute,
31
31
  TRouteModule,
32
32
  TRouteOptions,
33
+ type TRouteDefinition,
34
+ type TRouteMetadata,
33
35
  defaultOptions,
34
36
  matchRoute,
35
37
  buildUrl,
38
+ withRouteMetadata,
36
39
  } from '@common/router';
37
40
  import type { TSsrUnresolvedRoute, TRegisterPageArgs } from '@common/router/contracts';
38
41
  import { buildRegex, getRegisterPageArgs } from '@common/router/register';
@@ -76,7 +79,7 @@ type TGeneratedControllerDefinition = {
76
79
  path: string;
77
80
  filepath: string;
78
81
  sourceLocation: { line: number; column: number };
79
- Controller: new (request: TRouterContext<TServerRouter>) => { [method: string]: () => any };
82
+ action: TControllerActionDefinition<any, any, any, any>;
80
83
  method: string;
81
84
  };
82
85
 
@@ -385,7 +388,7 @@ export default class ServerRouter<
385
388
  }
386
389
  }
387
390
 
388
- this.afterRegister();
391
+ this.refreshRouteRegistration();
389
392
  }
390
393
 
391
394
  private registerControllers(definitions: TGeneratedControllerDefinition[]) {
@@ -393,10 +396,8 @@ export default class ServerRouter<
393
396
  const route: TRoute<TRouterContext<this>> = {
394
397
  method: 'POST',
395
398
  path: definition.path,
396
- controller: (requestContext: TRouterContext<this>) => {
397
- const controller = new definition.Controller(requestContext);
398
- return controller[definition.method]();
399
- },
399
+ controller: (requestContext: TRouterContext<this>) =>
400
+ runControllerAction(definition.action, requestContext as any),
400
401
  options: { ...defaultOptions, filepath: definition.filepath, sourceLocation: definition.sourceLocation },
401
402
  };
402
403
 
@@ -419,7 +420,31 @@ export default class ServerRouter<
419
420
  - REGISTER
420
421
  ----------------------------------*/
421
422
 
422
- public page(...args: TRegisterPageArgs<any, TRouteOptions>) {
423
+ public registerRouteDefinition(definition: TRouteDefinition, metadata: TRouteMetadata = {}) {
424
+ if (definition.kind === 'page') {
425
+ return this.page(
426
+ definition.path,
427
+ withRouteMetadata(definition.options, metadata),
428
+ definition.data,
429
+ definition.render,
430
+ );
431
+ }
432
+
433
+ if (definition.kind === 'error') {
434
+ return this.error(definition.code, withRouteMetadata(definition.options, metadata), definition.render);
435
+ }
436
+
437
+ const method = definition.method === '*' ? '*' : (definition.method.toUpperCase() as TRouteHttpMethod);
438
+
439
+ return this.registerApi(
440
+ method,
441
+ definition.path,
442
+ withRouteMetadata(definition.options, metadata),
443
+ definition.handler as TServerController<this>,
444
+ );
445
+ }
446
+
447
+ protected page(...args: TRegisterPageArgs<any, TRouteOptions>) {
423
448
  const { path, options, data, renderer, layout } = getRegisterPageArgs(...args);
424
449
 
425
450
  const { regex, keys } = buildRegex(path);
@@ -442,7 +467,7 @@ export default class ServerRouter<
442
467
  return this;
443
468
  }
444
469
 
445
- public error(
470
+ protected error(
446
471
  code: number,
447
472
  options: Partial<TRouteOptions>,
448
473
  renderer: TFrontRenderer<{}, { message: string }>,
@@ -461,34 +486,13 @@ export default class ServerRouter<
461
486
  this.errors[code] = route;
462
487
  }
463
488
 
464
- public all = (...args: TApiRegisterArgs<this>) => this.registerApi('*', ...args);
465
- public options = (...args: TApiRegisterArgs<this>) => this.registerApi('OPTIONS', ...args);
466
- public get = (...args: TApiRegisterArgs<this>) => this.registerApi('GET', ...args);
467
- public post = (...args: TApiRegisterArgs<this>) => this.registerApi('POST', ...args);
468
- public put = (...args: TApiRegisterArgs<this>) => this.registerApi('PUT', ...args);
469
- public patch = (...args: TApiRegisterArgs<this>) => this.registerApi('PATCH', ...args);
470
- public delete = (...args: TApiRegisterArgs<this>) => this.registerApi('DELETE', ...args);
471
-
472
- public express(
473
- middleware: (req: Request, res: Response, next: NextFunction, requestContext: TRouterContext<this>) => void,
474
- ) {
475
- return (context: TRouterContext<this>) =>
476
- new Promise((resolve) => {
477
- context.request.res.on('finish', function () {
478
- //console.log('the response has been sent', request.res.statusCode);
479
- resolve(true);
480
- });
481
-
482
- middleware(
483
- context.request.req,
484
- context.request.res,
485
- () => {
486
- resolve(true);
487
- },
488
- context,
489
- );
490
- });
491
- }
489
+ protected all = (...args: TApiRegisterArgs<this>) => this.registerApi('*', ...args);
490
+ protected options = (...args: TApiRegisterArgs<this>) => this.registerApi('OPTIONS', ...args);
491
+ protected get = (...args: TApiRegisterArgs<this>) => this.registerApi('GET', ...args);
492
+ protected post = (...args: TApiRegisterArgs<this>) => this.registerApi('POST', ...args);
493
+ protected put = (...args: TApiRegisterArgs<this>) => this.registerApi('PUT', ...args);
494
+ protected patch = (...args: TApiRegisterArgs<this>) => this.registerApi('PATCH', ...args);
495
+ protected delete = (...args: TApiRegisterArgs<this>) => this.registerApi('DELETE', ...args);
492
496
 
493
497
  protected registerApi(method: TRouteHttpMethod, ...args: TApiRegisterArgs<this>): this {
494
498
  let path: string;
@@ -565,7 +569,11 @@ export default class ServerRouter<
565
569
  );
566
570
  }
567
571
 
568
- private async afterRegister() {
572
+ public refreshRouteRegistration() {
573
+ this.afterRegister();
574
+ }
575
+
576
+ private afterRegister() {
569
577
  // Ordonne par ordre de priorité
570
578
  this.config.debug && console.info('Loading routes ...');
571
579
  this.routes.sort((r1, r2) => {
@@ -579,6 +587,7 @@ export default class ServerRouter<
579
587
  return 0;
580
588
  });
581
589
  // - Génère les définitions de route pour le client
590
+ this.ssrRoutes = [];
582
591
  this.config.debug && console.info(`Registered routes:`);
583
592
  for (const route of this.routes) {
584
593
  const chunkId = route.options.id;