proteum 2.1.0-2 → 2.1.0-3

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 (37) hide show
  1. package/AGENTS.md +51 -93
  2. package/README.md +44 -1
  3. package/agents/framework/AGENTS.md +155 -788
  4. package/agents/project/AGENTS.md +81 -110
  5. package/agents/project/client/AGENTS.md +22 -93
  6. package/agents/project/client/pages/AGENTS.md +24 -26
  7. package/agents/project/server/routes/AGENTS.md +10 -8
  8. package/agents/project/server/services/AGENTS.md +22 -159
  9. package/agents/project/tests/AGENTS.md +11 -8
  10. package/cli/commands/dev.ts +1 -0
  11. package/cli/commands/trace.ts +210 -0
  12. package/cli/compiler/client/index.ts +30 -8
  13. package/cli/compiler/server/index.ts +28 -6
  14. package/cli/paths.ts +16 -1
  15. package/cli/presentation/commands.ts +23 -1
  16. package/cli/presentation/devSession.ts +5 -0
  17. package/cli/runtime/commands.ts +31 -0
  18. package/common/dev/requestTrace.ts +81 -0
  19. package/docs/request-tracing.md +115 -0
  20. package/package.json +1 -1
  21. package/server/app/container/config.ts +15 -0
  22. package/server/app/container/index.ts +3 -0
  23. package/server/app/container/trace/index.ts +284 -0
  24. package/server/services/prisma/index.ts +61 -5
  25. package/server/services/router/http/index.ts +40 -0
  26. package/server/services/router/index.ts +159 -6
  27. package/server/services/router/response/index.ts +80 -7
  28. package/server/services/router/response/page/document.tsx +16 -0
  29. package/server/services/router/response/page/index.tsx +27 -1
  30. package/Rte.zip +0 -0
  31. package/agents/project/agents.md.zip +0 -0
  32. package/doc/TODO.md +0 -71
  33. package/doc/front/router.md +0 -27
  34. package/doc/workspace/workspace.png +0 -0
  35. package/doc/workspace/workspace2.png +0 -0
  36. package/doc/workspace/workspace_26.01.22.png +0 -0
  37. package/server/services/router/http/session.ts.old +0 -40
@@ -1,9 +1,12 @@
1
- # Codex guidance for writing E2E tests
1
+ # E2E Tests
2
2
 
3
- - Understand the typical user flow and the main feature branches
4
- - Favor as many tests as possible to cover real usage
5
- - Always locate elements via their `data-testid` attribute
6
- - Add `data-testid` where needed
7
- - Keep test files clean, organized and structured
8
- - Test the current controller/page runtime model, not legacy `@Route` or `api.fetch` behavior
9
- - Reuse root catalog files from `/client/catalogs/**`, `/server/catalogs/**`, or `/common/catalogs/**` instead of duplicating catalog constants inside tests
3
+ This file adds test-area local rules on top of the canonical framework contract:
4
+
5
+ - framework repo: `agents/framework/AGENTS.md`
6
+ - installed app: `./node_modules/proteum/agents/framework/AGENTS.md`
7
+
8
+ - Understand the real user flow and the main feature branches before writing tests.
9
+ - Test the current controller/page runtime model, not legacy `@Route` or `api.fetch` behavior.
10
+ - Locate elements with `data-testid`.
11
+ - Add `data-testid` where needed instead of relying on brittle selectors.
12
+ - Reuse root catalog files from `/client/catalogs/**`, `/server/catalogs/**`, or `/common/catalogs/**` instead of duplicating catalog constants in tests.
@@ -155,6 +155,7 @@ async function startApp(app: App) {
155
155
  await renderServerReadyBanner({
156
156
  appName: getDevAppName(app),
157
157
  publicUrl: message.publicUrl,
158
+ routerPort: app.env.router.port,
158
159
  }),
159
160
  );
160
161
  })();
@@ -0,0 +1,210 @@
1
+ import fs from 'fs-extra';
2
+ import got from 'got';
3
+ import path from 'path';
4
+ import yaml from 'yaml';
5
+ import { UsageError } from 'clipanion';
6
+
7
+ import cli from '..';
8
+ import type {
9
+ TRequestTrace,
10
+ TRequestTraceArmResponse,
11
+ TRequestTraceErrorResponse,
12
+ TRequestTraceListItem,
13
+ TRequestTraceListResponse,
14
+ TRequestTraceResponse,
15
+ } from '@common/dev/requestTrace';
16
+
17
+ type TTraceAction = 'latest' | 'show' | 'requests' | 'arm' | 'export';
18
+
19
+ const allowedActions = new Set<TTraceAction>(['latest', 'show', 'requests', 'arm', 'export']);
20
+
21
+ class TraceResponseError extends UsageError {}
22
+
23
+ const getAction = () => {
24
+ const action = typeof cli.args.action === 'string' && cli.args.action ? cli.args.action : 'latest';
25
+ if (!allowedActions.has(action as TTraceAction)) {
26
+ throw new UsageError(`Unsupported trace action "${action}". Expected one of: ${[...allowedActions].join(', ')}.`);
27
+ }
28
+
29
+ return action as TTraceAction;
30
+ };
31
+
32
+ const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '');
33
+
34
+ const getRouterPort = () => {
35
+ const overridePort = typeof cli.args.port === 'string' && cli.args.port ? cli.args.port : '';
36
+ if (overridePort) return overridePort;
37
+
38
+ const envFilepath = path.join(cli.args.workdir as string, 'env.yaml');
39
+ if (!fs.existsSync(envFilepath)) {
40
+ throw new UsageError(`Could not find env.yaml in ${cli.args.workdir as string}. Pass --port or --url explicitly.`);
41
+ }
42
+
43
+ const envFile = yaml.parse(fs.readFileSync(envFilepath, 'utf8')) as { router?: { port?: number } };
44
+ const port = envFile.router?.port;
45
+ if (!port) {
46
+ throw new UsageError(`Could not determine the router port from ${envFilepath}. Pass --port or --url explicitly.`);
47
+ }
48
+
49
+ return String(port);
50
+ };
51
+
52
+ const getRouterBaseUrls = () => {
53
+ const explicitUrl = typeof cli.args.url === 'string' && cli.args.url ? cli.args.url.trim() : '';
54
+ if (explicitUrl) return [normalizeBaseUrl(explicitUrl)];
55
+
56
+ const port = getRouterPort();
57
+ return [...new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`])];
58
+ };
59
+
60
+ const getTraceErrorMessage = (body: TRequestTraceErrorResponse | object | string | undefined, statusCode: number) => {
61
+ if (typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string') {
62
+ return body.error;
63
+ }
64
+
65
+ return `Trace request failed with status ${statusCode}.`;
66
+ };
67
+
68
+ const requestJson = async <TResponse>(pathname: string, options?: { method?: 'GET' | 'POST'; json?: object }) => {
69
+ const attempts: string[] = [];
70
+
71
+ for (const baseUrl of getRouterBaseUrls()) {
72
+ try {
73
+ const response = await got(`${baseUrl}${pathname}`, {
74
+ method: options?.method || 'GET',
75
+ json: options?.json,
76
+ responseType: 'json',
77
+ throwHttpErrors: false,
78
+ retry: { limit: 0 },
79
+ });
80
+
81
+ if (response.statusCode >= 400) {
82
+ throw new TraceResponseError(
83
+ getTraceErrorMessage(response.body as TRequestTraceErrorResponse | object | string | undefined, response.statusCode),
84
+ );
85
+ }
86
+
87
+ return response.body as TResponse;
88
+ } catch (error) {
89
+ if (error instanceof TraceResponseError) throw error;
90
+
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ attempts.push(`${baseUrl}${pathname}: ${message}`);
93
+ }
94
+ }
95
+
96
+ throw new UsageError(
97
+ [
98
+ 'Could not reach the Proteum trace server.',
99
+ ...attempts.map((attempt) => `- ${attempt}`),
100
+ 'Make sure the app is running with `proteum dev`, or pass `--url http://host:port` if it is bound elsewhere.',
101
+ ].join('\n'),
102
+ );
103
+ };
104
+
105
+ const renderTraceSummary = (request: TRequestTraceListItem) =>
106
+ [
107
+ `${request.id} ${request.method} ${request.path}`,
108
+ `status=${request.statusCode ?? 'pending'}`,
109
+ `capture=${request.capture}`,
110
+ `events=${request.eventCount}`,
111
+ request.user ? `user=${request.user}` : '',
112
+ request.errorMessage ? `error=${request.errorMessage}` : '',
113
+ ]
114
+ .filter(Boolean)
115
+ .join(' | ');
116
+
117
+ const renderTrace = (request: TRequestTrace) =>
118
+ [
119
+ `Request ${request.id}`,
120
+ `- ${request.method} ${request.path} status=${request.statusCode ?? 'pending'} capture=${request.capture}`,
121
+ `- started=${request.startedAt} durationMs=${request.durationMs ?? 'pending'} events=${request.events.length} dropped=${request.droppedEvents}`,
122
+ ...(request.user ? [`- user=${request.user}`] : []),
123
+ ...(request.persistedFilepath ? [`- persisted=${request.persistedFilepath}`] : []),
124
+ 'Events',
125
+ ...request.events.map(
126
+ (event) =>
127
+ `- [${event.elapsedMs}ms] ${event.type} ${Object.entries(event.details)
128
+ .map(([key, value]) => `${key}=${JSON.stringify(value)}`)
129
+ .join(' ')}`,
130
+ ),
131
+ ].join('\n');
132
+
133
+ const printJson = (value: object) => {
134
+ console.log(JSON.stringify(value, null, 2));
135
+ };
136
+
137
+ export const run = async () => {
138
+ const action = getAction();
139
+ const requestId = typeof cli.args.id === 'string' ? cli.args.id : '';
140
+ const shouldPrintJson = cli.args.json === true;
141
+
142
+ if (action === 'requests') {
143
+ const response = await requestJson<TRequestTraceListResponse>('/__proteum/trace/requests');
144
+ if (shouldPrintJson) {
145
+ printJson(response);
146
+ return;
147
+ }
148
+
149
+ console.log(['Proteum trace', ...response.requests.map(renderTraceSummary)].join('\n'));
150
+ return;
151
+ }
152
+
153
+ if (action === 'arm') {
154
+ const capture = typeof cli.args.capture === 'string' && cli.args.capture ? cli.args.capture : 'deep';
155
+ const response = await requestJson<TRequestTraceArmResponse>('/__proteum/trace/arm', {
156
+ method: 'POST',
157
+ json: { capture },
158
+ });
159
+
160
+ if (shouldPrintJson) {
161
+ printJson(response);
162
+ return;
163
+ }
164
+
165
+ console.log(`Armed next request trace with capture=${response.capture}.`);
166
+ return;
167
+ }
168
+
169
+ if (action === 'latest') {
170
+ const response = await requestJson<TRequestTraceResponse>('/__proteum/trace/latest');
171
+ if (shouldPrintJson) {
172
+ printJson(response);
173
+ return;
174
+ }
175
+
176
+ console.log(renderTrace(response.request));
177
+ return;
178
+ }
179
+
180
+ if (!requestId) {
181
+ throw new UsageError(`Trace action "${action}" requires a request id.`);
182
+ }
183
+
184
+ const response = await requestJson<TRequestTraceResponse>(`/__proteum/trace/requests/${requestId}`);
185
+
186
+ if (action === 'show') {
187
+ if (shouldPrintJson) {
188
+ printJson(response);
189
+ return;
190
+ }
191
+
192
+ console.log(renderTrace(response.request));
193
+ return;
194
+ }
195
+
196
+ const output =
197
+ typeof cli.args.output === 'string' && cli.args.output
198
+ ? cli.args.output
199
+ : path.join(cli.args.workdir as string, 'var', 'traces', 'exports', `${response.request.id}.json`);
200
+
201
+ fs.ensureDirSync(path.dirname(output));
202
+ fs.writeJSONSync(output, response.request, { spaces: 2 });
203
+
204
+ if (shouldPrintJson) {
205
+ printJson({ output, request: response.request });
206
+ return;
207
+ }
208
+
209
+ console.log(`Exported trace ${response.request.id} to ${output}`);
210
+ };
@@ -27,6 +27,24 @@ const hmrClientEntry = path.join(cli.paths.core.root, 'client', 'dev', 'hmr.ts')
27
27
  const normalizeModulePath = (value?: string) => (value || '').replace(/\\/g, '/');
28
28
  const resolveFromAppOrCore = (app: App, request: string) =>
29
29
  require.resolve(request, { paths: [app.paths.root, cli.paths.core.root] });
30
+ const rewriteFrameworkAliasTargets = (app: App, aliases: Record<string, string | string[]>) => {
31
+ const installedCoreRoot = normalizeModulePath(path.join(app.paths.root, 'node_modules', 'proteum'));
32
+ const activeCoreRoot = normalizeModulePath(cli.paths.core.root);
33
+
34
+ if (installedCoreRoot === activeCoreRoot) return aliases;
35
+
36
+ const rewriteCandidate = (candidate: string) =>
37
+ normalizeModulePath(candidate).startsWith(installedCoreRoot + '/')
38
+ ? activeCoreRoot + normalizeModulePath(candidate).substring(installedCoreRoot.length)
39
+ : candidate;
40
+
41
+ return Object.fromEntries(
42
+ Object.entries(aliases).map(([alias, value]) => [
43
+ alias,
44
+ Array.isArray(value) ? value.map(rewriteCandidate) : rewriteCandidate(value),
45
+ ]),
46
+ );
47
+ };
30
48
 
31
49
  const getModulePath = (module: Module) => {
32
50
  const resource = typeof module.nameForCondition === 'function' ? module.nameForCondition() : undefined;
@@ -58,6 +76,8 @@ export default function createCompiler(
58
76
  logVerbose(`Creating compiler for client (${mode}).`);
59
77
  const dev = mode === 'dev';
60
78
  const outputPath = app.outputPath(outputTarget);
79
+ const installedCoreRoot = path.join(app.paths.root, 'node_modules', 'proteum');
80
+ const frameworkRoots = [cli.paths.core.root, installedCoreRoot];
61
81
 
62
82
  const commonConfig = createCommonConfig(app, 'client', mode, outputTarget);
63
83
 
@@ -73,11 +93,12 @@ export default function createCompiler(
73
93
 
74
94
  // Convert tsconfig paths into bundler aliases.
75
95
  const { aliases } = app.aliases.client.forWebpack({ modulesPath: app.paths.root + '/node_modules' });
96
+ const resolvedAliases = rewriteFrameworkAliasTargets(app, aliases);
76
97
 
77
98
  // We're not supposed in any case to import server libs from client
78
- delete aliases['@server'];
79
- delete aliases['@/server'];
80
- const rspackAliases = toRspackAliases(aliases);
99
+ delete resolvedAliases['@server'];
100
+ delete resolvedAliases['@/server'];
101
+ const rspackAliases = toRspackAliases(resolvedAliases);
81
102
  rspackAliases['@/client/router$'] = cli.paths.core.root + '/client/router.ts';
82
103
  rspackAliases['preact/jsx-runtime$'] = resolveFromAppOrCore(app, 'preact/jsx-runtime');
83
104
  rspackAliases['react/jsx-runtime$'] = resolveFromAppOrCore(app, 'preact/jsx-runtime');
@@ -133,16 +154,16 @@ export default function createCompiler(
133
154
  test: ssrScriptPattern,
134
155
  include: [
135
156
  app.paths.root + '/client',
136
- cli.paths.core.root + '/client',
137
157
  app.paths.client.generated,
138
158
 
139
159
  app.paths.root + '/common',
140
- cli.paths.core.root + '/common',
141
160
  app.paths.common.generated,
142
161
 
143
162
  app.paths.root + '/server',
144
- cli.paths.core.root + '/server',
145
163
  app.paths.server.generated,
164
+ ...frameworkRoots.map((rootPath) => rootPath + '/client'),
165
+ ...frameworkRoots.map((rootPath) => rootPath + '/common'),
166
+ ...frameworkRoots.map((rootPath) => rootPath + '/server'),
146
167
  ],
147
168
  loader: path.join(
148
169
  cli.paths.core.root,
@@ -157,17 +178,18 @@ export default function createCompiler(
157
178
  test: regex.scripts,
158
179
  include: [
159
180
  app.paths.root + '/client',
160
- cli.paths.core.root + '/client',
161
181
  app.paths.client.generated,
162
182
 
163
183
  app.paths.root + '/common',
164
- cli.paths.core.root + '/common',
165
184
  app.paths.common.generated,
166
185
 
167
186
  // Prisma 7 generates browser-safe TypeScript entrypoints under var/prisma.
168
187
  app.paths.root + '/var/prisma',
169
188
 
170
189
  app.paths.server.generated + '/models.ts',
190
+ ...frameworkRoots.map((rootPath) => rootPath + '/client'),
191
+ ...frameworkRoots.map((rootPath) => rootPath + '/common'),
192
+ ...frameworkRoots.map((rootPath) => rootPath + '/server'),
171
193
  ],
172
194
  rules: require('../common/scripts')({ app, side: 'client', dev }),
173
195
  },
@@ -49,6 +49,25 @@ const getDevGeneratedRuntimeEntries = (app: App) => ({
49
49
  __proteum_dev_routes: [app.paths.server.generated + '/routes.ts'],
50
50
  __proteum_dev_controllers: [app.paths.server.generated + '/controllers.ts'],
51
51
  });
52
+ const normalizeModulePath = (value?: string) => (value || '').replace(/\\/g, '/');
53
+ const rewriteFrameworkAliasTargets = (app: App, aliases: Record<string, string | string[]>) => {
54
+ const installedCoreRoot = normalizeModulePath(app.paths.root + '/node_modules/proteum');
55
+ const activeCoreRoot = normalizeModulePath(cli.paths.core.root);
56
+
57
+ if (installedCoreRoot === activeCoreRoot) return aliases;
58
+
59
+ const rewriteCandidate = (candidate: string) =>
60
+ normalizeModulePath(candidate).startsWith(installedCoreRoot + '/')
61
+ ? activeCoreRoot + normalizeModulePath(candidate).substring(installedCoreRoot.length)
62
+ : candidate;
63
+
64
+ return Object.fromEntries(
65
+ Object.entries(aliases).map(([alias, value]) => [
66
+ alias,
67
+ Array.isArray(value) ? value.map(rewriteCandidate) : rewriteCandidate(value),
68
+ ]),
69
+ );
70
+ };
52
71
 
53
72
  /*----------------------------------
54
73
  - CONFIG
@@ -61,14 +80,17 @@ export default function createCompiler(
61
80
  debug && console.info(`Creating compiler for server (${mode}).`);
62
81
  const dev = mode === 'dev';
63
82
  const outputPath = app.outputPath(outputTarget);
83
+ const installedCoreRoot = app.paths.root + '/node_modules/proteum';
84
+ const frameworkRoots = [cli.paths.core.root, installedCoreRoot];
64
85
 
65
86
  const commonConfig = createCommonConfig(app, 'server', mode, outputTarget);
66
87
  const { aliases } = app.aliases.server.forWebpack({ modulesPath: app.paths.root + '/node_modules' });
88
+ const resolvedAliases = rewriteFrameworkAliasTargets(app, aliases);
67
89
 
68
90
  // We're not supposed in any case to import client services from server
69
- delete aliases['@client/services'];
70
- delete aliases['@/client/services'];
71
- const rspackAliases = toRspackAliases(aliases);
91
+ delete resolvedAliases['@client/services'];
92
+ delete resolvedAliases['@/client/services'];
93
+ const rspackAliases = toRspackAliases(resolvedAliases);
72
94
  rspackAliases['@/client/router$'] = cli.paths.core.root + '/client/router.ts';
73
95
 
74
96
  debug &&
@@ -154,11 +176,9 @@ export default function createCompiler(
154
176
  test: regex.scripts,
155
177
  include: [
156
178
  app.paths.root + '/client',
157
- cli.paths.core.root + '/client',
158
179
  app.paths.client.generated,
159
180
 
160
181
  app.paths.root + '/common',
161
- cli.paths.core.root + '/common',
162
182
  app.paths.common.generated,
163
183
 
164
184
  // Prisma 7 generates TypeScript entrypoints under var/prisma.
@@ -166,8 +186,10 @@ export default function createCompiler(
166
186
 
167
187
  // Dossiers server uniquement pour le bundle server
168
188
  app.paths.root + '/server',
169
- cli.paths.core.root + '/server',
170
189
  app.paths.server.generated,
190
+ ...frameworkRoots.map((rootPath) => rootPath + '/client'),
191
+ ...frameworkRoots.map((rootPath) => rootPath + '/common'),
192
+ ...frameworkRoots.map((rootPath) => rootPath + '/server'),
171
193
 
172
194
  // Complle 5HTP modules so they can refer to the framework instance and aliases
173
195
  // Temp disabled because compile issue on vercel
package/cli/paths.ts CHANGED
@@ -43,13 +43,28 @@ export const staticAssetName = /*isDebug ? '[name].[ext].[hash:8]' :*/ '[hash:8]
43
43
 
44
44
  const pathInfosDefaultOpts = { shortenExtensions: ['ts', 'js', 'tsx', 'jsx'], trimIndex: true };
45
45
 
46
+ const safeRealpath = (filepath: string) => {
47
+ try {
48
+ return fs.realpathSync(filepath);
49
+ } catch {
50
+ return path.resolve(filepath);
51
+ }
52
+ };
53
+
46
54
  const resolveCoreRoot = (appRoot: string) => {
55
+ const currentPackageRoot = path.resolve(__dirname, '..');
56
+ const currentBin = path.join(currentPackageRoot, 'cli', 'bin.js');
57
+ const invokedScript = process.argv[1] ? safeRealpath(process.argv[1]) : '';
58
+ const invokedCurrentPackage = invokedScript === safeRealpath(currentBin);
59
+
60
+ if (invokedCurrentPackage) return currentPackageRoot;
61
+
47
62
  const localInstall = path.join(appRoot, 'node_modules', 'proteum');
48
63
  if (fs.existsSync(localInstall)) return localInstall;
49
64
 
50
65
  // When running `npx`/global installs, there may be no local `node_modules/proteum` yet.
51
66
  // Fall back to the installed package root (this file lives in `<root>/cli`).
52
- return path.resolve(__dirname, '..');
67
+ return currentPackageRoot;
53
68
  };
54
69
 
55
70
  const normalizeImportPath = (value: string) => value.replace(/\\/g, '/');
@@ -13,6 +13,7 @@ export const proteumCommandNames = [
13
13
  'check',
14
14
  'doctor',
15
15
  'explain',
16
+ 'trace',
16
17
  ] as const;
17
18
 
18
19
  export type TProteumCommandName = (typeof proteumCommandNames)[number];
@@ -45,7 +46,7 @@ export const proteumRecommendedFlow: TRow[] = [
45
46
  export const proteumCommandGroups: Array<{ title: string; names: TProteumCommandName[] }> = [
46
47
  { title: 'Daily workflow', names: ['dev', 'refresh', 'build'] },
47
48
  { title: 'Quality gates', names: ['typecheck', 'lint', 'check'] },
48
- { title: 'Manifest and contracts', names: ['doctor', 'explain'] },
49
+ { title: 'Manifest and contracts', names: ['doctor', 'explain', 'trace'] },
49
50
  { title: 'Project scaffolding', names: ['init'] },
50
51
  ];
51
52
 
@@ -184,6 +185,27 @@ export const proteumCommands: Record<TProteumCommandName, TProteumCommandDoc> =
184
185
  notes: ['Legacy positional section selection remains supported, for example `proteum explain routes services`.'],
185
186
  status: 'stable',
186
187
  },
188
+ trace: {
189
+ name: 'trace',
190
+ category: 'Manifest and contracts',
191
+ summary: 'Inspect live in-memory request traces from a running Proteum dev server.',
192
+ usage: 'proteum trace [latest|show <requestId>|requests|arm|export <requestId>] [--port <port>|--url <baseUrl>] [--json]',
193
+ bestFor:
194
+ 'Debugging route resolution, context creation, SSR payloads, renders, and runtime errors without attaching a debugger.',
195
+ examples: [
196
+ { description: 'Show the latest request trace', command: 'proteum trace latest' },
197
+ { description: 'List recent trace summaries', command: 'proteum trace requests' },
198
+ { description: 'Arm the next request for deep capture', command: 'proteum trace arm --capture deep' },
199
+ { description: 'Export a request trace to disk', command: 'proteum trace export <requestId>' },
200
+ { description: 'Target a custom dev base URL directly', command: 'proteum trace latest --url http://127.0.0.1:3010' },
201
+ ],
202
+ notes: [
203
+ 'This command talks to the running app over the dev-only `__proteum/trace` HTTP endpoints.',
204
+ 'Traces are stored in a bounded in-memory buffer with payload summarization and sensitive-field redaction.',
205
+ 'Use `--port` when the app is not running on the router port declared in `env.yaml`, or `--url` when the host itself is non-standard.',
206
+ ],
207
+ status: 'experimental',
208
+ },
187
209
  };
188
210
 
189
211
  export const isLikelyProteumAppRoot = (workdir: string) =>
@@ -43,6 +43,8 @@ export const renderDevSession = async ({
43
43
  { label: 'root', value: appRoot },
44
44
  { label: 'router', value: `http://localhost:${routerPort}` },
45
45
  { label: 'hmr', value: `http://localhost:${devEventPort}/__proteum_hmr` },
46
+ { label: 'trace', value: `proteum trace latest --port ${routerPort}` },
47
+ { label: 'trace deep', value: `proteum trace arm --capture deep --port ${routerPort}` },
46
48
  { label: 'hotkeys', value: 'Ctrl+R reload, Ctrl+C stop' },
47
49
  ],
48
50
  { minLabelWidth: 12, maxLabelWidth: 12 },
@@ -52,9 +54,11 @@ export const renderDevSession = async ({
52
54
  export const renderServerReadyBanner = async ({
53
55
  appName,
54
56
  publicUrl,
57
+ routerPort,
55
58
  }: {
56
59
  appName: string;
57
60
  publicUrl: string;
61
+ routerPort: number;
58
62
  }) =>
59
63
  renderInk(({ Box, Text }) => {
60
64
  const createElement = React.createElement;
@@ -66,5 +70,6 @@ export const renderServerReadyBanner = async ({
66
70
  createElement(Text, { bold: true, color: 'green' }, appName),
67
71
  createElement(Text, { bold: true }, publicUrl),
68
72
  createElement(Text, { dimColor: true }, 'SSR server is listening for requests and hot reloads.'),
73
+ createElement(Text, { dimColor: true }, `Trace latest: proteum trace latest --port ${routerPort}`),
69
74
  );
70
75
  });
@@ -185,6 +185,35 @@ class ExplainCommand extends ProteumCommand {
185
185
  }
186
186
  }
187
187
 
188
+ class TraceCommand extends ProteumCommand {
189
+ public static paths = [['trace']];
190
+
191
+ public static usage = buildUsage('trace');
192
+
193
+ public port = Option.String('--port', { description: 'Override the router port used to query the running dev server.' });
194
+ public url = Option.String('--url', { description: 'Override the full base URL used to query the running dev server.' });
195
+ public json = Option.Boolean('--json', false, { description: 'Print JSON output.' });
196
+ public capture = Option.String('--capture', { description: 'Capture mode used by `proteum trace arm`.' });
197
+ public output = Option.String('--output', { description: 'Output filepath used by `proteum trace export`.' });
198
+ public args = Option.Rest();
199
+
200
+ public async execute() {
201
+ const [action = 'latest', id = ''] = this.args;
202
+
203
+ this.setCliArgs({
204
+ action,
205
+ id,
206
+ port: this.port ?? '',
207
+ url: this.url ?? '',
208
+ json: this.json,
209
+ capture: this.capture ?? '',
210
+ output: this.output ?? '',
211
+ });
212
+
213
+ await runCommandModule(() => import('../commands/trace'));
214
+ }
215
+ }
216
+
188
217
  export const registeredCommands = {
189
218
  init: InitCommand,
190
219
  dev: DevCommand,
@@ -195,6 +224,7 @@ export const registeredCommands = {
195
224
  check: CheckCommand,
196
225
  doctor: DoctorCommand,
197
226
  explain: ExplainCommand,
227
+ trace: TraceCommand,
198
228
  } as const;
199
229
 
200
230
  export const createCli = (version: string) => {
@@ -216,6 +246,7 @@ export const createCli = (version: string) => {
216
246
  clipanion.register(CheckCommand);
217
247
  clipanion.register(DoctorCommand);
218
248
  clipanion.register(ExplainCommand);
249
+ clipanion.register(TraceCommand);
219
250
 
220
251
  return clipanion;
221
252
  };
@@ -0,0 +1,81 @@
1
+ export const traceCaptureModes = ['summary', 'resolve', 'deep'] as const;
2
+
3
+ export type TTraceCaptureMode = (typeof traceCaptureModes)[number];
4
+
5
+ export const traceEventTypes = [
6
+ 'request.start',
7
+ 'request.user',
8
+ 'resolve.start',
9
+ 'resolve.controller-route',
10
+ 'resolve.routes-evaluated',
11
+ 'resolve.route-skip',
12
+ 'resolve.route-match',
13
+ 'resolve.not-found',
14
+ 'controller.start',
15
+ 'controller.result',
16
+ 'setup.options',
17
+ 'context.create',
18
+ 'page.data',
19
+ 'ssr.payload',
20
+ 'render.start',
21
+ 'render.end',
22
+ 'response.send',
23
+ 'request.finish',
24
+ 'error',
25
+ ] as const;
26
+
27
+ export type TTraceEventType = (typeof traceEventTypes)[number];
28
+
29
+ export type TTraceSummaryValue =
30
+ | PrimitiveValue
31
+ | null
32
+ | { kind: 'undefined' }
33
+ | { kind: 'redacted'; reason: string }
34
+ | { kind: 'bigint'; value: string }
35
+ | { kind: 'symbol'; value: string }
36
+ | { kind: 'function'; name: string }
37
+ | { kind: 'date'; value: string }
38
+ | { kind: 'error'; name: string; message: string; stack?: string }
39
+ | { kind: 'buffer'; byteLength: number }
40
+ | { kind: 'array'; length: number; items: TTraceSummaryValue[]; truncated: boolean }
41
+ | {
42
+ kind: 'object';
43
+ constructorName: string;
44
+ keys: string[];
45
+ entries: { [key: string]: TTraceSummaryValue };
46
+ truncated: boolean;
47
+ }
48
+ | { kind: 'map'; size: number }
49
+ | { kind: 'set'; size: number };
50
+
51
+ export type TTraceEvent = {
52
+ index: number;
53
+ at: string;
54
+ elapsedMs: number;
55
+ type: TTraceEventType;
56
+ details: { [key: string]: TTraceSummaryValue };
57
+ };
58
+
59
+ export type TRequestTrace = {
60
+ id: string;
61
+ method: string;
62
+ path: string;
63
+ url: string;
64
+ capture: TTraceCaptureMode;
65
+ startedAt: string;
66
+ finishedAt?: string;
67
+ durationMs?: number;
68
+ statusCode?: number;
69
+ user?: string;
70
+ droppedEvents: number;
71
+ persistedFilepath?: string;
72
+ errorMessage?: string;
73
+ events: TTraceEvent[];
74
+ };
75
+
76
+ export type TRequestTraceListItem = Omit<TRequestTrace, 'events'> & { eventCount: number };
77
+
78
+ export type TRequestTraceListResponse = { requests: TRequestTraceListItem[] };
79
+ export type TRequestTraceResponse = { request: TRequestTrace };
80
+ export type TRequestTraceArmResponse = { armed: true; capture: TTraceCaptureMode };
81
+ export type TRequestTraceErrorResponse = { error: string };