proteum 2.4.3 → 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 (74) 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/http-client-error-context.test.cjs +10 -1
  46. package/server/app/container/console/index.ts +2 -1
  47. package/server/app/controller/index.ts +98 -40
  48. package/server/app/index.ts +92 -1
  49. package/server/app/service/index.ts +5 -1
  50. package/server/index.ts +6 -2
  51. package/server/services/router/index.ts +47 -38
  52. package/server/services/router/response/index.ts +2 -2
  53. package/tests/agents-utils.test.cjs +14 -1
  54. package/tests/cli-mcp-command.test.cjs +84 -0
  55. package/tests/definition-contracts.test.cjs +453 -0
  56. package/tests/dev-transpile-watch.test.cjs +37 -28
  57. package/tests/eslint-rules.test.cjs +39 -1
  58. package/tests/mcp.test.cjs +90 -0
  59. package/tests/worktree-bootstrap.test.cjs +206 -0
  60. package/types/aliases.d.ts +0 -5
  61. package/types/controller-input.test.ts +23 -17
  62. package/types/controller-request-context.test.ts +10 -11
  63. package/cli/commands/migrate.ts +0 -51
  64. package/cli/migrate/pageContract.ts +0 -516
  65. package/docs/migrate-from-2.1.3.md +0 -396
  66. package/scripts/cleanup-generated-controllers.ts +0 -62
  67. package/scripts/fix-reference-app-typing.ts +0 -490
  68. package/scripts/format-router-registrations.ts +0 -119
  69. package/scripts/migrate-explicit-controllers-and-request.ts +0 -423
  70. package/scripts/refactor-client-app-imports.ts +0 -244
  71. package/scripts/refactor-client-pages.ts +0 -587
  72. package/scripts/refactor-server-controllers.ts +0 -471
  73. package/scripts/refactor-server-runtime-aliases.ts +0 -360
  74. 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.3",
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.`);
@@ -13,7 +13,7 @@ moduleAlias.addAliases({
13
13
  '@server': path.join(coreRoot, 'server'),
14
14
  });
15
15
 
16
- const { getHttpClientErrorContext, normalizeBugReportError } = require('./index.ts');
16
+ const { default: Console, getHttpClientErrorContext, normalizeBugReportError } = require('./index.ts');
17
17
 
18
18
  test('wraps got HTTP errors as anomalies with original error context', () => {
19
19
  const error = new Error('Response code 422 (Unprocessable Entity)');
@@ -85,3 +85,12 @@ test('wraps got HTTP errors as anomalies with original error context', () => {
85
85
  test('ignores normal application errors', () => {
86
86
  assert.equal(getHttpClientErrorContext(new Error('Something else failed')), null);
87
87
  });
88
+
89
+ test('renders circular JSON contexts in bug report HTML', () => {
90
+ const context = { name: 'request' };
91
+ context.self = context;
92
+
93
+ const html = Console.prototype.jsonToHTML.call({ printHtml: (value) => value }, context);
94
+
95
+ assert.match(html, /Circular/);
96
+ });
@@ -7,6 +7,7 @@ import { serialize } from 'v8';
7
7
  import { formatWithOptions } from 'util';
8
8
  import md5 from 'md5';
9
9
  import dayjs from 'dayjs';
10
+ import stringify from 'fast-safe-stringify';
10
11
 
11
12
  // Npm
12
13
  import { Logger, IMeta, ILogObj, ISettings } from 'tslog';
@@ -538,7 +539,7 @@ Logs: ${
538
539
  public jsonToHTML(json: unknown): string {
539
540
  if (!json) return 'No data';
540
541
 
541
- const coloredJson = highlight(JSON.stringify(json, null, 4), { language: 'json', ignoreIllegals: true });
542
+ const coloredJson = highlight(stringify(json, undefined, 4), { language: 'json', ignoreIllegals: true });
542
543
 
543
544
  const html = ansi2Html.toHtml(coloredJson);
544
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) {