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.
- package/AGENTS.md +3 -2
- package/README.md +49 -11
- package/agents/project/AGENTS.md +43 -5
- package/agents/project/diagnostics.md +6 -2
- package/agents/project/optimizations.md +1 -0
- package/agents/project/root/AGENTS.md +14 -5
- package/agents/project/tests/AGENTS.md +6 -0
- package/agents/project/tests/e2e/AGENTS.md +13 -0
- package/agents/project/tests/e2e/REAL_WORLD_JOURNEY_TESTS.md +192 -0
- package/cli/commands/connect.ts +40 -4
- package/cli/commands/diagnose.ts +136 -5
- package/cli/commands/doctor.ts +24 -4
- package/cli/commands/explain.ts +105 -6
- package/cli/commands/mcp.ts +16 -0
- package/cli/commands/orient.ts +66 -3
- package/cli/commands/perf.ts +118 -13
- package/cli/commands/runtime.ts +151 -0
- package/cli/commands/trace.ts +116 -21
- package/cli/mcp/provider.ts +365 -0
- package/cli/mcp/stdio.ts +16 -0
- package/cli/presentation/commands.ts +77 -20
- package/cli/presentation/devSession.ts +2 -0
- package/cli/runtime/commands.ts +95 -12
- package/cli/utils/agentOutput.ts +46 -0
- package/cli/utils/agents.ts +116 -49
- package/common/dev/inspection.ts +14 -6
- package/common/dev/mcpPayloads.ts +736 -0
- package/common/dev/mcpServer.ts +254 -0
- package/docs/agent-routing.md +126 -0
- package/docs/dev-commands.md +2 -0
- package/docs/dev-sessions.md +2 -1
- package/docs/diagnostics.md +68 -23
- package/docs/mcp.md +149 -0
- package/docs/migrate-from-2.1.3.md +15 -5
- package/docs/request-tracing.md +12 -6
- package/package.json +2 -1
- package/server/app/devMcp.ts +159 -0
- package/server/services/router/http/cache.ts +116 -0
- package/server/services/router/http/index.ts +94 -35
- package/server/services/router/index.ts +8 -11
- package/tests/agents-utils.test.cjs +36 -13
- package/tests/dev-transpile-watch.test.cjs +117 -8
- package/tests/inspection.test.cjs +67 -0
- package/tests/mcp.test.cjs +127 -0
- 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
|
|
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:
|
|
417
|
-
lastModified:
|
|
405
|
+
etag: publicAssetEtag,
|
|
406
|
+
lastModified: publicAssetLastModified,
|
|
418
407
|
setHeaders: (res, filePath) => {
|
|
419
|
-
if (
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1141
|
+
} else {
|
|
1142
|
+
res.setHeader('Surrogate-Control', htmlCache.surrogateControl);
|
|
1143
1143
|
}
|
|
1144
1144
|
|
|
1145
|
-
|
|
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
|
|
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, /##
|
|
67
|
-
assert.match(agentsContent,
|
|
68
|
-
assert.
|
|
69
|
-
assert.match(codingStyleContent, /## Source:
|
|
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, /##
|
|
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, /##
|
|
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'), /##
|
|
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:
|
|
171
|
-
assert.match(fs.readFileSync(path.join(monorepoRoot, 'optimizations.md'), 'utf8'), /## Source:
|
|
172
|
-
assert.match(fs.readFileSync(path.join(
|
|
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
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
+
});
|