proteum 2.2.9 → 2.3.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 (45) hide show
  1. package/AGENTS.md +3 -2
  2. package/README.md +49 -11
  3. package/agents/project/AGENTS.md +43 -5
  4. package/agents/project/diagnostics.md +6 -2
  5. package/agents/project/optimizations.md +1 -0
  6. package/agents/project/root/AGENTS.md +14 -5
  7. package/agents/project/tests/AGENTS.md +6 -0
  8. package/agents/project/tests/e2e/AGENTS.md +13 -0
  9. package/agents/project/tests/e2e/REAL_WORLD_JOURNEY_TESTS.md +192 -0
  10. package/cli/commands/connect.ts +40 -4
  11. package/cli/commands/diagnose.ts +136 -5
  12. package/cli/commands/doctor.ts +24 -4
  13. package/cli/commands/explain.ts +105 -6
  14. package/cli/commands/mcp.ts +16 -0
  15. package/cli/commands/orient.ts +66 -3
  16. package/cli/commands/perf.ts +118 -13
  17. package/cli/commands/runtime.ts +151 -0
  18. package/cli/commands/trace.ts +116 -21
  19. package/cli/mcp/provider.ts +365 -0
  20. package/cli/mcp/stdio.ts +16 -0
  21. package/cli/presentation/commands.ts +77 -20
  22. package/cli/presentation/devSession.ts +2 -0
  23. package/cli/runtime/commands.ts +95 -12
  24. package/cli/utils/agentOutput.ts +46 -0
  25. package/cli/utils/agents.ts +116 -49
  26. package/common/dev/inspection.ts +14 -6
  27. package/common/dev/mcpPayloads.ts +736 -0
  28. package/common/dev/mcpServer.ts +254 -0
  29. package/docs/agent-routing.md +126 -0
  30. package/docs/dev-commands.md +2 -0
  31. package/docs/dev-sessions.md +2 -1
  32. package/docs/diagnostics.md +68 -23
  33. package/docs/mcp.md +149 -0
  34. package/docs/migrate-from-2.1.3.md +15 -5
  35. package/docs/request-tracing.md +12 -6
  36. package/package.json +2 -1
  37. package/server/app/devMcp.ts +159 -0
  38. package/server/services/router/http/cache.ts +116 -0
  39. package/server/services/router/http/index.ts +94 -35
  40. package/server/services/router/index.ts +8 -11
  41. package/tests/agents-utils.test.cjs +36 -13
  42. package/tests/dev-transpile-watch.test.cjs +117 -8
  43. package/tests/inspection.test.cjs +67 -0
  44. package/tests/mcp.test.cjs +127 -0
  45. package/tests/router-cache-config.test.cjs +74 -0
@@ -8,7 +8,10 @@ import express from 'express';
8
8
  import http from 'http';
9
9
  import https from 'https';
10
10
  import path from 'path';
11
+ import { randomUUID } from 'crypto';
11
12
  import cors, { CorsOptions } from 'cors';
13
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
14
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
12
15
 
13
16
  // Middlewares (npm)
14
17
  import morgan from 'morgan';
@@ -37,9 +40,16 @@ import {
37
40
  parseConnectedProjectProxyPath,
38
41
  } from '@common/connectedProjects';
39
42
  import { profilerTraceRequestIdHeader } from '@common/dev/profiler';
43
+ import { createProteumMcpServer } from '@common/dev/mcpServer';
44
+ import { createRuntimeProteumMcpProvider } from '@server/app/devMcp';
40
45
 
41
46
  // Middlewaees (core)
42
47
  import { isMutipart, MiddlewareFormData } from './multipart';
48
+ import {
49
+ resolveHttpCacheConfig,
50
+ resolvePublicAssetCacheControl,
51
+ type THttpCacheConfig,
52
+ } from './cache';
43
53
 
44
54
  /*----------------------------------
45
55
  - CONFIG
@@ -58,12 +68,15 @@ export type Config = {
58
68
  maxSize: string; // Expression package bytes
59
69
  };
60
70
  csp: { default?: string[]; styles?: string[]; images?: string[]; scripts: string[] };
71
+ cache?: THttpCacheConfig;
61
72
  cors?: CorsOptions;
62
73
  helmet?: Parameters<typeof helmet>[0];
63
74
  };
64
75
 
65
76
  export type Hooks = {};
66
77
 
78
+ export { resolveHttpCacheConfig, type THttpCacheConfig } from './cache';
79
+
67
80
  type TContentSecurityPolicyOptions = NonNullable<Parameters<typeof helmet.contentSecurityPolicy>[0]>;
68
81
  type TContentSecurityPolicyDirectives = NonNullable<TContentSecurityPolicyOptions['directives']>;
69
82
  type TDevSessionAuthService = {
@@ -94,37 +107,9 @@ const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPol
94
107
  };
95
108
  };
96
109
 
97
- const immutablePublicAssetCacheControl = 'public, max-age=31536000, immutable';
98
- const devPublicAssetCacheControl = 'no-store';
99
- const revalidatedPublicAssetCacheControl = 'public, max-age=0, must-revalidate';
100
- const hashedPublicAssetPattern = /(^|[-_.])[a-f0-9]{6,}(?=(\.[^.]+)+$)/i;
101
110
  const connectedProjectBootRetryCount = 10;
102
111
  const connectedProjectBootRetryDelayMs = 5_000;
103
112
 
104
- const isVersionedPublicAssetRequest = (res: undefined | express.Response | http.ServerResponse, filePath: string) => {
105
- const request =
106
- res && typeof res === 'object' && 'req' in res
107
- ? ((res as express.Response | (http.ServerResponse & { req?: express.Request })).req ?? undefined)
108
- : undefined;
109
- const requestUrl = request?.originalUrl || request?.url || '';
110
- const searchParams = new URL(requestUrl, 'http://proteum.local').searchParams;
111
- if (searchParams.has('v')) return true;
112
-
113
- return hashedPublicAssetPattern.test(path.basename(filePath));
114
- };
115
-
116
- const resolvePublicAssetCacheControl = ({
117
- res,
118
- filePath,
119
- profile,
120
- }: {
121
- res: undefined | express.Response | http.ServerResponse;
122
- filePath: string;
123
- profile: string;
124
- }) => {
125
- if (profile === 'dev') return devPublicAssetCacheControl;
126
- return isVersionedPublicAssetRequest(res, filePath) ? immutablePublicAssetCacheControl : revalidatedPublicAssetCacheControl;
127
- };
128
113
  const wait = async (durationMs: number) =>
129
114
  await new Promise<void>((resolve) => {
130
115
  setTimeout(resolve, durationMs);
@@ -406,20 +391,22 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
406
391
  // Normalement, seulement utile pour le mode production,
407
392
  // Quand mode debug, les ressources client semblent servies par le dev middlewae
408
393
  // Sauf que les ressources serveur ne semblent pas trouvées par le dev-middleware
409
- const disablePublicAssetCaching = this.app.env.profile === 'dev';
394
+ const publicAssetCache = resolveHttpCacheConfig(this.config.cache).publicAssets;
395
+ const defaultPublicAssetValidators = this.app.env.profile !== 'dev';
396
+ const publicAssetEtag = publicAssetCache.etag ?? defaultPublicAssetValidators;
397
+ const publicAssetLastModified = publicAssetCache.lastModified ?? defaultPublicAssetValidators;
398
+
410
399
  routes.use(compression());
411
400
  routes.use('/public', cors());
412
401
  routes.use(
413
402
  '/public',
414
403
  express.static(path.join(Container.path.root, APP_OUTPUT_DIR, 'public'), {
415
404
  dotfiles: 'deny',
416
- etag: !disablePublicAssetCaching,
417
- lastModified: !disablePublicAssetCaching,
405
+ etag: publicAssetEtag,
406
+ lastModified: publicAssetLastModified,
418
407
  setHeaders: (res, filePath) => {
419
- if (disablePublicAssetCaching) {
420
- res.removeHeader('ETag');
421
- res.removeHeader('Last-Modified');
422
- }
408
+ if (!publicAssetEtag) res.removeHeader('ETag');
409
+ if (!publicAssetLastModified) res.removeHeader('Last-Modified');
423
410
 
424
411
  res.setHeader(
425
412
  'Cache-Control',
@@ -427,6 +414,7 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
427
414
  res,
428
415
  filePath,
429
416
  profile: this.app.env.profile,
417
+ cache: publicAssetCache,
430
418
  }),
431
419
  );
432
420
  },
@@ -510,6 +498,8 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
510
498
  private registerDevTraceRoutes(routes: express.Express) {
511
499
  if (!__DEV__ || this.app.env.profile !== 'dev') return;
512
500
 
501
+ this.registerDevMcpRoute(routes);
502
+
513
503
  if (this.app.container.Trace.isDevTraceEnabled()) {
514
504
  routes.get('/__proteum/trace/requests', (req, res) => {
515
505
  const rawLimit = Array.isArray(req.query.limit) ? req.query.limit[0] : req.query.limit;
@@ -836,4 +826,73 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
836
826
  private getCronManager() {
837
827
  return (this.app as typeof this.app & { Cron?: CronManager }).Cron;
838
828
  }
829
+
830
+ private registerDevMcpRoute(routes: express.Express) {
831
+ type TMcpTransportEntry = {
832
+ server: ReturnType<typeof createProteumMcpServer>;
833
+ transport: StreamableHTTPServerTransport;
834
+ };
835
+ const transports = new Map<string, TMcpTransportEntry>();
836
+ const readSessionId = (req: express.Request) => {
837
+ const value = req.headers['mcp-session-id'];
838
+ if (Array.isArray(value)) return value[0];
839
+ return typeof value === 'string' && value.trim() ? value : undefined;
840
+ };
841
+ const writeJsonRpcError = (res: express.Response, statusCode: number, message: string) => {
842
+ res.status(statusCode).json({
843
+ jsonrpc: '2.0',
844
+ error: {
845
+ code: -32000,
846
+ message,
847
+ },
848
+ id: null,
849
+ });
850
+ };
851
+
852
+ routes.all('/__proteum/mcp', async (req, res) => {
853
+ const sessionId = readSessionId(req);
854
+ let entry = sessionId ? transports.get(sessionId) : undefined;
855
+
856
+ try {
857
+ if (!entry && !sessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
858
+ const provider = createRuntimeProteumMcpProvider({
859
+ app: this.app,
860
+ publicUrl: this.publicUrl,
861
+ routerPort: this.config.port,
862
+ });
863
+ const server = createProteumMcpServer({
864
+ provider,
865
+ version: String(process.env.npm_package_version || 'runtime'),
866
+ });
867
+ const transport = new StreamableHTTPServerTransport({
868
+ sessionIdGenerator: () => randomUUID(),
869
+ onsessioninitialized: (initializedSessionId) => {
870
+ transports.set(initializedSessionId, { server, transport });
871
+ },
872
+ });
873
+
874
+ transport.onclose = () => {
875
+ const transportSessionId = transport.sessionId;
876
+ if (transportSessionId) transports.delete(transportSessionId);
877
+ void server.close().catch(() => undefined);
878
+ };
879
+
880
+ await server.connect(transport);
881
+ entry = { server, transport };
882
+ }
883
+
884
+ if (!entry) {
885
+ writeJsonRpcError(res, 400, 'Bad Request: initialize the Proteum MCP session before sending tool or resource requests.');
886
+ return;
887
+ }
888
+
889
+ await entry.transport.handleRequest(req, res, req.body);
890
+ } catch (error) {
891
+ console.error('Error handling Proteum MCP request:', error);
892
+ if (!res.headersSent) {
893
+ writeJsonRpcError(res, 500, 'Internal Proteum MCP server error.');
894
+ }
895
+ }
896
+ });
897
+ }
839
898
  }
@@ -52,7 +52,7 @@ import { AnyRouterService } from './service';
52
52
  import ServerRequest from './request';
53
53
  import ServerResponse, { TRouterContext, TRouterContextServices } from './response';
54
54
  import Page from './response/page';
55
- import HTTP, { Config as HttpServiceConfig } from './http';
55
+ import HTTP, { Config as HttpServiceConfig, resolveHttpCacheConfig } from './http';
56
56
  import DocumentRenderer from './response/page/document';
57
57
  import { loadGeneratedRuntimeBundle } from './generatedRuntime';
58
58
 
@@ -97,9 +97,6 @@ export type TApiResponseData = { data: any; triggers?: { [cle: string]: any } };
97
97
 
98
98
  export type HttpHeaders = { [cle: string]: string };
99
99
 
100
- const dynamicHtmlCacheControl = 'no-store, no-cache, must-revalidate, proxy-revalidate';
101
- const staticHtmlCacheControl = 'public, max-age=0, must-revalidate';
102
-
103
100
  /*----------------------------------
104
101
  - SERVICE CONFIG
105
102
  ----------------------------------*/
@@ -1136,15 +1133,15 @@ export default class ServerRouter<
1136
1133
  }
1137
1134
 
1138
1135
  private applyHtmlCacheHeaders(res: express.Response, isStaticHtml: boolean) {
1139
- if (isStaticHtml) {
1136
+ const cache = resolveHttpCacheConfig(this.config.http.cache);
1137
+ const htmlCache = isStaticHtml ? cache.html.static : cache.html.dynamic;
1138
+
1139
+ if (htmlCache.surrogateControl === false) {
1140
1140
  res.removeHeader('Surrogate-Control');
1141
- res.setHeader('Cache-Control', staticHtmlCacheControl);
1142
- return;
1141
+ } else {
1142
+ res.setHeader('Surrogate-Control', htmlCache.surrogateControl);
1143
1143
  }
1144
1144
 
1145
- // Don't cache dynamic HTML, because updated releases can change asset hashes.
1146
- // https://github.com/helmetjs/nocache/blob/main/index.ts
1147
- res.setHeader('Surrogate-Control', 'no-store');
1148
- res.setHeader('Cache-Control', dynamicHtmlCacheControl);
1145
+ res.setHeader('Cache-Control', htmlCache.cacheControl);
1149
1146
  }
1150
1147
  }
@@ -24,7 +24,17 @@ const createCoreFixture = () => {
24
24
 
25
25
  writeFile(path.join(agentsRoot, 'AGENTS.md'), '# Root Contract\n\n- Root rule\n');
26
26
  writeFile(path.join(agentsRoot, 'CODING_STYLE.md'), '# Coding Style\n\n- Style rule\n');
27
+ writeFile(path.join(agentsRoot, 'diagnostics.md'), '# Diagnostics\n\n- Diagnostics rule\n');
28
+ writeFile(path.join(agentsRoot, 'optimizations.md'), '# Optimizations\n\n- Optimization rule\n');
27
29
  writeFile(path.join(agentsRoot, 'client', 'AGENTS.md'), '# Client Rules\n\n- Client rule\n');
30
+ writeFile(path.join(agentsRoot, 'client', 'pages', 'AGENTS.md'), '# Page Rules\n\n- Page rule\n');
31
+ writeFile(path.join(agentsRoot, 'server', 'routes', 'AGENTS.md'), '# Route Rules\n\n- Route rule\n');
32
+ writeFile(path.join(agentsRoot, 'server', 'services', 'AGENTS.md'), '# Service Rules\n\n- Service rule\n');
33
+ writeFile(path.join(agentsRoot, 'tests', 'e2e', 'AGENTS.md'), '# E2E Rules\n\n- E2E rule\n');
34
+ writeFile(
35
+ path.join(agentsRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'),
36
+ '# Real World Journey Tests\n\n- Journey rule\n',
37
+ );
28
38
 
29
39
  return root;
30
40
  };
@@ -52,7 +62,7 @@ const createAppFixture = () => {
52
62
  return appRoot;
53
63
  };
54
64
 
55
- test('standalone configure creates tracked instruction files with embedded corpus', () => {
65
+ test('standalone configure creates tracked instruction files with routing contract and split docs', () => {
56
66
  const coreRoot = createCoreFixture();
57
67
  const appRoot = createAppFixture();
58
68
  const result = configureProjectAgentInstructions({ appRoot, coreRoot });
@@ -63,10 +73,16 @@ test('standalone configure creates tracked instruction files with embedded corpu
63
73
  assert.equal(result.blocked.length, 0);
64
74
  assert.match(agentsContent, /^# Proteum Instructions/m);
65
75
  assert.match(agentsContent, /<!-- proteum-instructions:start -->/);
66
- assert.match(agentsContent, /## Source: AGENTS\.md/);
67
- assert.match(agentsContent, /## Root Contract/);
68
- assert.match(agentsContent, /## Source: CODING_STYLE\.md/);
69
- assert.match(codingStyleContent, /## Source: client\/AGENTS\.md/);
76
+ assert.match(agentsContent, /## Agent Routing Contract/);
77
+ assert.match(agentsContent, /npx proteum orient <query>/);
78
+ assert.doesNotMatch(agentsContent, /## Source: CODING_STYLE\.md/);
79
+ assert.match(codingStyleContent, /## Source: CODING_STYLE\.md/);
80
+ assert.match(codingStyleContent, /## Coding Style/);
81
+ assert.doesNotMatch(codingStyleContent, /## Source: client\/AGENTS\.md/);
82
+ assert.equal(fs.existsSync(path.join(appRoot, 'tests', 'e2e', 'AGENTS.md')), true);
83
+ assert.equal(fs.existsSync(path.join(appRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md')), true);
84
+ assert.match(fs.readFileSync(path.join(appRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'), 'utf8'), /Journey rule/);
85
+ assert.doesNotMatch(fs.readFileSync(path.join(appRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'), 'utf8'), /## Source: CODING_STYLE\.md/);
70
86
  assert.doesNotMatch(agentsContent, /Before reading or applying instructions from this file/);
71
87
  assert.doesNotMatch(gitignoreContent, /Proteum-managed instruction files/);
72
88
  assert.doesNotMatch(gitignoreContent, /^\/AGENTS\.md$/m);
@@ -102,7 +118,8 @@ test('configure preserves project content outside the managed section', () => {
102
118
  const content = fs.readFileSync(path.join(appRoot, 'AGENTS.md'), 'utf8');
103
119
  assert.match(content, /# Product Notes/);
104
120
  assert.match(content, /Keep this product note\./);
105
- assert.match(content, /## Source: CODING_STYLE\.md/);
121
+ assert.match(content, /## Agent Routing Contract/);
122
+ assert.doesNotMatch(content, /## Source: CODING_STYLE\.md/);
106
123
  assert.doesNotMatch(content, /Old managed content/);
107
124
  assert.match(content, /# Local Footer/);
108
125
  assert.match(content, /Keep this footer\./);
@@ -144,7 +161,8 @@ test('configure preserves project content around legacy managed stubs', () => {
144
161
  assert.match(content, /## Product Bootstrap/);
145
162
  assert.match(content, /Keep these local bootstrap notes\./);
146
163
  assert.match(content, /# Proteum Instructions/);
147
- assert.match(content, /## Source: CODING_STYLE\.md/);
164
+ assert.match(content, /## Agent Routing Contract/);
165
+ assert.doesNotMatch(content, /## Source: CODING_STYLE\.md/);
148
166
  assert.doesNotMatch(content, /# Proteum Managed Instructions/);
149
167
  assert.doesNotMatch(content, /Before reading or applying instructions from this file/);
150
168
  assert.match(content, /## Local Footer/);
@@ -165,11 +183,16 @@ test('monorepo configure writes root and app instruction files', () => {
165
183
 
166
184
  assert.equal(result.mode, 'monorepo');
167
185
  assert.equal(resolveProjectAgentMonorepoRoot(appRoot), fs.realpathSync(monorepoRoot));
168
- assert.match(fs.readFileSync(path.join(monorepoRoot, 'AGENTS.md'), 'utf8'), /## Source: AGENTS\.md/);
186
+ assert.match(fs.readFileSync(path.join(monorepoRoot, 'AGENTS.md'), 'utf8'), /## Agent Routing Contract/);
169
187
  assert.match(fs.readFileSync(path.join(monorepoRoot, 'CODING_STYLE.md'), 'utf8'), /## Source: CODING_STYLE\.md/);
170
- assert.match(fs.readFileSync(path.join(monorepoRoot, 'diagnostics.md'), 'utf8'), /## Source: AGENTS\.md/);
171
- assert.match(fs.readFileSync(path.join(monorepoRoot, 'optimizations.md'), 'utf8'), /## Source: AGENTS\.md/);
172
- assert.match(fs.readFileSync(path.join(appRoot, 'AGENTS.md'), 'utf8'), /## Source: client\/AGENTS\.md/);
188
+ assert.match(fs.readFileSync(path.join(monorepoRoot, 'diagnostics.md'), 'utf8'), /## Source: diagnostics\.md/);
189
+ assert.match(fs.readFileSync(path.join(monorepoRoot, 'optimizations.md'), 'utf8'), /## Source: optimizations\.md/);
190
+ assert.match(fs.readFileSync(path.join(monorepoRoot, 'tests', 'e2e', 'AGENTS.md'), 'utf8'), /## Source: tests\/e2e\/AGENTS\.md/);
191
+ assert.match(fs.readFileSync(path.join(monorepoRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'), 'utf8'), /## Source: tests\/e2e\/REAL_WORLD_JOURNEY_TESTS\.md/);
192
+ assert.doesNotMatch(fs.readFileSync(path.join(monorepoRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'), 'utf8'), /## Source: CODING_STYLE\.md/);
193
+ assert.equal(fs.existsSync(path.join(appRoot, 'tests', 'e2e', 'AGENTS.md')), false);
194
+ assert.match(fs.readFileSync(path.join(appRoot, 'AGENTS.md'), 'utf8'), /## Agent Routing Contract/);
195
+ assert.match(fs.readFileSync(path.join(appRoot, 'client', 'AGENTS.md'), 'utf8'), /## Source: client\/AGENTS\.md/);
173
196
  assert.equal(fs.existsSync(path.join(appRoot, 'CODING_STYLE.md')), false);
174
197
  assert.equal(fs.existsSync(path.join(appRoot, 'diagnostics.md')), false);
175
198
  assert.equal(fs.existsSync(path.join(appRoot, 'optimizations.md')), false);
@@ -222,7 +245,7 @@ test('monorepo configure strips retired managed sections from local app-root doc
222
245
  assert.equal(result.updated.some((entry) => entry.endsWith('/apps/product/CODING_STYLE.md')), true);
223
246
  });
224
247
 
225
- test('configure migrates legacy managed symlinks to embedded files', () => {
248
+ test('configure migrates legacy managed symlinks to tracked files', () => {
226
249
  const coreRoot = createCoreFixture();
227
250
  const appRoot = createAppFixture();
228
251
  const installedCoreRoot = createCoreFixture();
@@ -258,5 +281,5 @@ test('configure reports blocked paths unless overwrite is allowed', () => {
258
281
 
259
282
  assert.equal(result.overwritten.some((entry) => entry.endsWith('/CODING_STYLE.md')), true);
260
283
  assert.equal(fs.lstatSync(blockedPath).isFile(), true);
261
- assert.match(fs.readFileSync(blockedPath, 'utf8'), /## Source: AGENTS\.md/);
284
+ assert.match(fs.readFileSync(blockedPath, 'utf8'), /## Source: CODING_STYLE\.md/);
262
285
  });
@@ -65,6 +65,33 @@ const findAssetContaining = (appRoot, extension, marker) => {
65
65
  return candidates.find((filepath) => fs.readFileSync(filepath, 'utf8').includes(marker));
66
66
  };
67
67
 
68
+ const toPublicAssetUrl = (appRoot, filepath) => {
69
+ const publicRoot = path.join(appRoot, 'dev', 'public');
70
+ const relativePath = path.relative(publicRoot, filepath).split(path.sep).join('/');
71
+
72
+ return `/public/${relativePath}`;
73
+ };
74
+
75
+ const request = async (port, urlPath, headers = {}) => {
76
+ const response = await fetch(`http://127.0.0.1:${port}${urlPath}`, { headers });
77
+ const body = await response.text();
78
+
79
+ return { response, body };
80
+ };
81
+
82
+ const waitForHeader = async (port, urlPath, headerName, expectedValue, timeoutMs = 60000) => {
83
+ const deadline = Date.now() + timeoutMs;
84
+
85
+ while (Date.now() < deadline) {
86
+ const { response } = await request(port, urlPath, { Accept: 'text/html' });
87
+ if (response.headers.get(headerName) === expectedValue) return response;
88
+
89
+ await sleep(250);
90
+ }
91
+
92
+ throw new Error(`Timed out waiting for ${urlPath} header ${headerName}=${expectedValue}.`);
93
+ };
94
+
68
95
  const waitForAssetContaining = async (appRoot, extension, marker, timeoutMs = 60000) => {
69
96
  const deadline = Date.now() + timeoutMs;
70
97
 
@@ -172,9 +199,10 @@ const createSharedStyleSource = (marker) => `.shared-style-marker {
172
199
  }
173
200
  `;
174
201
 
175
- const createFixture = (root, port) => {
202
+ const createFixture = (root, port, options = {}) => {
176
203
  const appRoot = path.join(root, 'app');
177
204
  const sharedRoot = path.join(root, 'shared');
205
+ const cacheConfigSource = options.routerCache ? ` cache: ${options.routerCache},\n` : '';
178
206
 
179
207
  fs.mkdirSync(path.join(appRoot, 'public'), { recursive: true });
180
208
  fs.mkdirSync(path.join(appRoot, 'client', 'assets', 'identity'), { recursive: true });
@@ -332,6 +360,7 @@ export const routerBaseConfig = {
332
360
  upload: {
333
361
  maxSize: '10mb',
334
362
  },
363
+ ${cacheConfigSource}
335
364
  csp: {
336
365
  scripts: [],
337
366
  },
@@ -403,6 +432,31 @@ Router.page(
403
432
  );
404
433
  `,
405
434
  );
435
+ if (options.staticPage) {
436
+ writeFile(
437
+ path.join(appRoot, 'client', 'pages', 'static-cache.tsx'),
438
+ `import Router from '@/client/router';
439
+ import { SharedMarker } from '@test/shared';
440
+
441
+ Router.page(
442
+ '/static-cache',
443
+ {
444
+ auth: false,
445
+ layout: false,
446
+ static: { urls: ['/static-cache'] },
447
+ },
448
+ null,
449
+ () => {
450
+ return (
451
+ <main>
452
+ <SharedMarker />
453
+ </main>
454
+ );
455
+ },
456
+ );
457
+ `,
458
+ );
459
+ }
406
460
 
407
461
  writeFile(
408
462
  path.join(sharedRoot, 'package.json'),
@@ -448,13 +502,8 @@ const stopDevServer = async (child) => {
448
502
  });
449
503
  };
450
504
 
451
- test('proteum dev invalidates client assets and reloads for transpiled package scripts and styles', { timeout: 180000 }, async () => {
452
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-transpile-watch-'));
453
- const port = await resolvePortPair();
454
- const { appRoot, sharedRoot } = createFixture(root, port);
455
- const sessionFile = path.join(appRoot, 'var', 'run', 'proteum', 'dev', 'transpile-watch-test.json');
505
+ const startDevServer = (appRoot, port, sessionFile) => {
456
506
  let output = '';
457
-
458
507
  const child = spawn(
459
508
  process.execPath,
460
509
  [cliBin, 'dev', '--cwd', appRoot, '--port', String(port), '--session-file', sessionFile, '--no-cache', '--verbose'],
@@ -476,8 +525,21 @@ test('proteum dev invalidates client assets and reloads for transpiled package s
476
525
  output += chunk.toString();
477
526
  });
478
527
 
528
+ return {
529
+ child,
530
+ getOutput: () => output,
531
+ };
532
+ };
533
+
534
+ test('proteum dev invalidates client assets and reloads for transpiled package scripts and styles', { timeout: 180000 }, async () => {
535
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-transpile-watch-'));
536
+ const port = await resolvePortPair();
537
+ const { appRoot, sharedRoot } = createFixture(root, port);
538
+ const sessionFile = path.join(appRoot, 'var', 'run', 'proteum', 'dev', 'transpile-watch-test.json');
539
+ const { child, getOutput } = startDevServer(appRoot, port, sessionFile);
540
+
479
541
  try {
480
- await waitForSessionReady(sessionFile, child, () => output);
542
+ await waitForSessionReady(sessionFile, child, getOutput);
481
543
 
482
544
  const initialScriptAsset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL');
483
545
  const initialScriptContent = fs.readFileSync(initialScriptAsset, 'utf8');
@@ -511,3 +573,50 @@ test('proteum dev invalidates client assets and reloads for transpiled package s
511
573
  fs.rmSync(root, { recursive: true, force: true });
512
574
  }
513
575
  });
576
+
577
+ test('proteum dev applies router HTTP cache config to HTML and public assets', { timeout: 180000 }, async () => {
578
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-router-cache-'));
579
+ const port = await resolvePortPair();
580
+ const dynamicHtmlCacheControl = 'private, max-age=7';
581
+ const staticHtmlCacheControl = 'private, max-age=13';
582
+ const publicAssetCacheControl = 'private, max-age=17';
583
+ const { appRoot } = createFixture(root, port, {
584
+ staticPage: true,
585
+ routerCache: `{
586
+ html: {
587
+ dynamic: { cacheControl: '${dynamicHtmlCacheControl}', surrogateControl: 'dynamic-surrogate' },
588
+ static: { cacheControl: '${staticHtmlCacheControl}', surrogateControl: 'static-surrogate' },
589
+ },
590
+ publicAssets: {
591
+ dev: '${publicAssetCacheControl}',
592
+ versioned: '${publicAssetCacheControl}',
593
+ unversioned: '${publicAssetCacheControl}',
594
+ etag: false,
595
+ lastModified: false,
596
+ },
597
+ }`,
598
+ });
599
+ const sessionFile = path.join(appRoot, 'var', 'run', 'proteum', 'dev', 'router-cache-test.json');
600
+ const { child, getOutput } = startDevServer(appRoot, port, sessionFile);
601
+
602
+ try {
603
+ await waitForSessionReady(sessionFile, child, getOutput);
604
+
605
+ const { response: dynamicResponse } = await request(port, '/', { Accept: 'text/html' });
606
+ assert.equal(dynamicResponse.headers.get('cache-control'), dynamicHtmlCacheControl);
607
+ assert.equal(dynamicResponse.headers.get('surrogate-control'), 'dynamic-surrogate');
608
+
609
+ const staticResponse = await waitForHeader(port, '/static-cache', 'cache-control', staticHtmlCacheControl);
610
+ assert.equal(staticResponse.headers.get('surrogate-control'), 'static-surrogate');
611
+
612
+ const asset = await waitForAssetContaining(appRoot, '.js', 'SCRIPT_MARKER_INITIAL');
613
+ const { response: assetResponse } = await request(port, toPublicAssetUrl(appRoot, asset));
614
+
615
+ assert.equal(assetResponse.headers.get('cache-control'), publicAssetCacheControl);
616
+ assert.equal(assetResponse.headers.get('etag'), null);
617
+ assert.equal(assetResponse.headers.get('last-modified'), null);
618
+ } finally {
619
+ await stopDevServer(child);
620
+ fs.rmSync(root, { recursive: true, force: true });
621
+ }
622
+ });
@@ -0,0 +1,67 @@
1
+ const assert = require('node:assert/strict');
2
+ const path = require('node:path');
3
+ const test = require('node:test');
4
+
5
+ const coreRoot = path.resolve(__dirname, '..');
6
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
7
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
8
+ require('ts-node/register/transpile-only');
9
+
10
+ const { explainOwner } = require('../common/dev/inspection.ts');
11
+
12
+ const createRoute = (routePath, filepath) => ({
13
+ chunkFilepath: filepath,
14
+ chunkId: filepath.replace(/[^A-Za-z0-9]+/g, '_'),
15
+ codeRaw: routePath,
16
+ filepath,
17
+ hasData: false,
18
+ kind: 'page',
19
+ methodName: 'page',
20
+ normalizedOptionKeys: [],
21
+ path: routePath,
22
+ pathRaw: routePath,
23
+ scope: 'app',
24
+ sourceLocation: { line: 1, column: 1 },
25
+ targetResolution: 'literal',
26
+ });
27
+
28
+ const createManifest = (routes) => ({
29
+ app: {
30
+ coreRoot,
31
+ root: '/tmp/proteum-app',
32
+ },
33
+ commands: [],
34
+ connectedProjects: [],
35
+ controllers: [],
36
+ diagnostics: [],
37
+ layouts: [],
38
+ routes: {
39
+ client: routes,
40
+ server: [],
41
+ },
42
+ services: {
43
+ app: [],
44
+ routerPlugins: [],
45
+ },
46
+ });
47
+
48
+ test('root owner lookup does not match every dynamic route containing a slash', () => {
49
+ const manifest = createManifest([
50
+ createRoute('/domains/:slug((?!tlds$|tld$|sector$)[^/]+)', '/tmp/proteum-app/client/pages/domains/slug.tsx'),
51
+ createRoute('/admin/data/:tab([^/]+)', '/tmp/proteum-app/client/pages/admin/data.tsx'),
52
+ ]);
53
+
54
+ assert.deepEqual(explainOwner(manifest, '/').matches, []);
55
+ });
56
+
57
+ test('root owner lookup returns only the literal root route when present', () => {
58
+ const manifest = createManifest([
59
+ createRoute('/domains/:slug((?!tlds$|tld$|sector$)[^/]+)', '/tmp/proteum-app/client/pages/domains/slug.tsx'),
60
+ createRoute('/', '/tmp/proteum-app/client/pages/home.tsx'),
61
+ ]);
62
+
63
+ const matches = explainOwner(manifest, '/').matches;
64
+
65
+ assert.equal(matches.length, 1);
66
+ assert.equal(matches[0].label, '/');
67
+ });