Repository: compiler-explorer/compiler-explorer
Pull request #7152 (2024-11-29 09:04:49)

.NET: Fix the redundant options in some cases
#7143 accidentally broke the compilation for mono as it would pass an unnecessary option `--parallalism 1` to `mono`. Missed this in my local test as the result of mono was cached.
This option is only used by Crossgen2 and NativeAOT.


Patch:
diff --git a/app.ts b/app.ts
index 12c70e5df..7c805cafd 100755
--- a/app.ts
+++ b/app.ts
@@ -57,11 +57,14 @@ import {startWineInit} from './lib/exec.js';
 import {RemoteExecutionQuery} from './lib/execution/execution-query.js';
 import {initHostSpecialties} from './lib/execution/execution-triple.js';
 import {startExecutionWorkerThread} from './lib/execution/sqs-execution-queue.js';
+import {FormattingService} from './lib/formatting-service.js';
 import {AssemblyDocumentationController} from './lib/handlers/api/assembly-documentation-controller.js';
+import {FormattingController} from './lib/handlers/api/formatting-controller.js';
 import {HealthcheckController} from './lib/handlers/api/healthcheck-controller.js';
 import {SiteTemplateController} from './lib/handlers/api/site-template-controller.js';
 import {SourceController} from './lib/handlers/api/source-controller.js';
 import {CompileHandler} from './lib/handlers/compile.js';
+import {cached, csp} from './lib/handlers/middleware.js';
 import {NoScriptHandler} from './lib/handlers/noscript.js';
 import {RouteAPI, ShortLinkMetaData} from './lib/handlers/route-api.js';
 import {languages as allLanguages} from './lib/languages.js';
@@ -337,23 +340,6 @@ const httpRoot = urljoin(ceProps('httpRoot', '/'), '/');
 const staticUrl = ceProps<string | undefined>('staticUrl');
 const staticRoot = urljoin(staticUrl || urljoin(httpRoot, 'static'), '/');
 
-/**
- * Sets appropriate headers for static resources. This is based on the staticMaxAgeSecs property from the
- * compiler-explorer.properties file.
- */
-export function addStaticHeaders(res: express.Response) {
-    if (staticMaxAgeSecs) {
-        res.setHeader('Cache-Control', `public, max-age=${staticMaxAgeSecs}, must-revalidate`);
-    }
-}
-
-function contentPolicyHeader(res: express.Response) {
-    // TODO: re-enable CSP
-    // if (csp) {
-    //     res.setHeader('Content-Security-Policy', csp);
-    // }
-}
-
 function measureEventLoopLag(delayMs: number) {
     return new Promise<number>(resolve => {
         const start = process.hrtime.bigint();
@@ -545,12 +531,16 @@ async function main() {
 
     RemoteExecutionQuery.initRemoteExecutionArchs(ceProps, defArgs.env);
 
+    const formattingService = new FormattingService();
+    await formattingService.initialize(ceProps);
+
     const clientOptionsHandler = new ClientOptionsHandler(sources, compilerProps, defArgs);
     const compilationQueue = CompilationQueue.fromProps(compilerProps.ceProps);
     const compilationEnvironment = new CompilationEnvironment(
         compilerProps,
         awsProps,
         compilationQueue,
+        formattingService,
         defArgs.doCache,
     );
     const compileHandler = new CompileHandler(compilationEnvironment, awsProps);
@@ -570,6 +560,7 @@ async function main() {
         compileHandler,
         isExecutionWorker,
     );
+    const formattingController = new FormattingController(formattingService);
 
     logger.info('=======================================');
     if (gitReleaseName) logger.info(`  git release ${gitReleaseName}`);
@@ -629,8 +620,6 @@ async function main() {
         defArgs,
         renderConfig,
         renderGoldenLayout,
-        staticHeaders: addStaticHeaders,
-        contentPolicyHeader,
     };
 
     const noscriptHandler = new NoScriptHandler(router, global.handler_config);
@@ -677,6 +666,9 @@ async function main() {
                 ip: true,
             }),
         )
+        // The healthcheck controller is hoisted to prevent it from being logged.
+        // TODO: Migrate the logger to a shared middleware.
+        .use(healthCheckController.createRouter())
         // eslint-disable-next-line no-unused-vars
         .use(
             responseTime((req, res, time) => {
@@ -744,9 +736,6 @@ async function main() {
         req: express.Request,
         res: express.Response,
     ) {
-        addStaticHeaders(res);
-        contentPolicyHeader(res);
-
         const embedded = req.query.embedded === 'true' ? true : false;
 
         res.render(
@@ -765,8 +754,6 @@ async function main() {
     }
 
     const embeddedHandler = function (req: express.Request, res: express.Response) {
-        addStaticHeaders(res);
-        contentPolicyHeader(res);
         res.render(
             'embed',
             renderConfig(
@@ -809,9 +796,7 @@ async function main() {
             }),
         )
         .use(compression())
-        .get('/', (req, res) => {
-            addStaticHeaders(res);
-            contentPolicyHeader(res);
+        .get('/', cached, csp, (req, res) => {
             res.render(
                 'index',
                 renderConfig(
@@ -823,12 +808,10 @@ async function main() {
                 ),
             );
         })
-        .get('/e', embeddedHandler)
+        .get('/e', cached, csp, embeddedHandler)
         // legacy. not a 301 to prevent any redirect loops between old e links and embed.html
-        .get('/embed.html', embeddedHandler)
-        .get('/embed-ro', (req, res) => {
-            addStaticHeaders(res);
-            contentPolicyHeader(res);
+        .get('/embed.html', cached, csp, embeddedHandler)
+        .get('/embed-ro', cached, csp, (req, res) => {
             res.render(
                 'embed',
                 renderConfig(
@@ -841,24 +824,19 @@ async function main() {
                 ),
             );
         })
-        .get('/robots.txt', (req, res) => {
-            addStaticHeaders(res);
+        .get('/robots.txt', cached, (req, res) => {
             res.end('User-agent: *\nSitemap: https://godbolt.org/sitemap.xml\nDisallow:');
         })
-        .get('/sitemap.xml', (req, res) => {
-            addStaticHeaders(res);
+        .get('/sitemap.xml', cached, (req, res) => {
             res.set('Content-Type', 'application/xml');
             res.render('sitemap');
         })
         .use(sFavicon(utils.resolvePathFromAppRoot('static/favicons', getFaviconFilename())))
-        .get('/client-options.js', (req, res) => {
-            addStaticHeaders(res);
+        .get('/client-options.js', cached, (req, res) => {
             res.set('Content-Type', 'application/javascript');
             res.end(`window.compilerExplorerOptions = ${clientOptionsHandler.getJSON()};`);
         })
-        .use('/bits/:bits(\\w+).html', (req, res) => {
-            addStaticHeaders(res);
-            contentPolicyHeader(res);
+        .use('/bits/:bits(\\w+).html', cached, csp, (req, res) => {
             res.render(
                 `bits/${sanitize(req.params.bits)}`,
                 renderConfig(
@@ -874,10 +852,8 @@ async function main() {
         .use(siteTemplateController.createRouter())
         .use(sourceController.createRouter())
         .use(assemblyDocumentationController.createRouter())
-        .use(healthCheckController.createRouter())
-        .get('/g/:id', oldGoogleUrlHandler)
-        // Deprecated old route for this -- TODO remove in late 2021
-        .post('/shortener', routeApi.apiHandler.shortener.handle.bind(routeApi.apiHandler.shortener));
+        .use(formattingController.createRouter())
+        .get('/g/:id', oldGoogleUrlHandler);
 
     noscriptHandler.InitializeRoutes({limit: ceProps('bodyParserLimit', maxUploadSize)});
     routeApi.InitializeRoutes();
diff --git a/lib/base-compiler.ts b/lib/base-compiler.ts
index 1d492d84e..7c9623169 100644
--- a/lib/base-compiler.ts
+++ b/lib/base-compiler.ts
@@ -1330,9 +1330,13 @@ export class BaseCompiler {
     }
 
     async applyClangFormat(output: string): Promise<string> {
-        // Currently hard-coding llvm style
         try {
-            const [stdout, stderr] = await this.env.formatHandler.internalFormat('clangformat', 'LLVM', output);
+            // Currently hard-coding llvm style
+            const {stdout, stderr} = await this.env.formattingService.format('clangformat', output, {
+                baseStyle: 'LLVM',
+                tabWidth: 4,
+                useSpaces: true,
+            });
             if (stderr) {
                 return stdout + '\n/* clang-format stderr:\n' + stderr.trim() + '\n*/';
             }
diff --git a/lib/compilation-env.ts b/lib/compilation-env.ts
index 4fc8ba08b..30d660b7a 100644
--- a/lib/compilation-env.ts
+++ b/lib/compilation-env.ts
@@ -35,7 +35,7 @@ import type {Cache} from './cache/base.interfaces.js';
 import {BaseCache} from './cache/base.js';
 import {createCacheFromConfig} from './cache/from-config.js';
 import {CompilationQueue, EnqueueOptions, Job} from './compilation-queue.js';
-import {FormattingHandler} from './handlers/formatting.js';
+import {FormattingService} from './formatting-service.js';
 import {logger} from './logger.js';
 import type {PropertyGetter} from './properties.interfaces.js';
 import {CompilerProps, PropFunc} from './properties.js';
@@ -54,7 +54,6 @@ export class CompilationEnvironment {
     reportCacheEvery: number;
     multiarch: string | null;
     baseEnv: Record<string, string>;
-    formatHandler: FormattingHandler;
     possibleToolchains?: CompilerOverrideOptions;
     statsNoter: IStatsNoter;
     private logCompilerCacheAccesses: boolean;
@@ -63,6 +62,7 @@ export class CompilationEnvironment {
         compilerProps: CompilerProps,
         awsProps: PropFunc,
         compilationQueue: CompilationQueue | undefined,
+        public formattingService: FormattingService,
         doCache?: boolean,
     ) {
         this.ceProps = compilerProps.ceProps;
@@ -106,9 +106,6 @@ export class CompilationEnvironment {
             if (environmentVariable === '') return;
             this.baseEnv[environmentVariable] = process.env[environmentVariable] ?? '';
         });
-        // I'm not sure that this is the best design; but each compiler having its own means each constructs its own
-        // handler, and passing it in from the outside is a pain as each compiler's constructor needs it.
-        this.formatHandler = new FormattingHandler(this.ceProps);
         this.logCompilerCacheAccesses = this.ceProps('logCompilerCacheAccesses', false);
         this.statsNoter = createStatsNoter(this.ceProps);
     }
diff --git a/lib/compilers/dotnet.ts b/lib/compilers/dotnet.ts
index 926911b40..5b76a9a95 100644
--- a/lib/compilers/dotnet.ts
+++ b/lib/compilers/dotnet.ts
@@ -540,7 +540,7 @@ do()
         const isIlDasm = this.compiler.group === 'dotnetildasm';
         const isIlSpy = this.compiler.group === 'dotnetilspy';
         const isCoreRun = this.compiler.group === 'dotnetcoreclr';
-        const toolOptions: string[] = isIlDasm || isIlSpy || isCoreRun ? [] : ['--parallelism', '1'];
+        const toolOptions: string[] = [];
 
         let overrideDiffable = false;
         let overrideDisasm = false;
@@ -559,7 +559,7 @@ do()
                 continue;
             }
 
-            if (isCoreRun && (currentOption === '-e' || currentOption === '--env')) {
+            if ((isCoreRun || isMono) && (currentOption === '-e' || currentOption === '--env')) {
                 const envVar = options.shift();
                 if (envVar) {
                     const [name] = envVar.split('=');
@@ -617,16 +617,22 @@ do()
             }
         }
 
+        const needCodegenOptions = isCrossgen2 || isAot;
+
+        if (needCodegenOptions) {
+            toolOptions.push('--parallelism', '1');
+        }
+
         if (!isIlDasm && !isIlSpy) {
             if (!overrideDiffable && compilerInfo.sdkMajorVersion < 8) {
-                if (!isCoreRun) {
+                if (needCodegenOptions) {
                     toolOptions.push('--codegenopt', 'JitDiffableDasm=1');
                 }
                 envVarFileContents.push('DOTNET_JitDiffableDasm=1');
             }
 
             if (!overrideDisasm) {
-                if (!isCoreRun) {
+                if (needCodegenOptions) {
                     toolOptions.push(
                         '--codegenopt',
                         compilerInfo.sdkMajorVersion === 6 ? 'NgenDisasm=*' : 'JitDisasm=*',
@@ -636,7 +642,7 @@ do()
             }
 
             if (!overrideAssembly) {
-                if (!isCoreRun && compilerInfo.sdkMajorVersion >= 9) {
+                if (needCodegenOptions && compilerInfo.sdkMajorVersion >= 9) {
                     toolOptions.push('--codegenopt', 'JitDisasmAssemblies=CompilerExplorer');
                 }
                 envVarFileContents.push('DOTNET_JitDisasmAssemblies=CompilerExplorer');
@@ -810,7 +816,7 @@ do()
         const ilspyPath = path.join(ilspyToolsDir, targetFramework, 'any', 'ilspycmd.dll');
 
         // prettier-ignore
-        const ilspyOptions = [ilspyPath, dllPath, '--disable-updatecheck'].concat(options);
+        const ilspyOptions = [ilspyPath, dllPath, '--disable-updatecheck', '-r', this.clrBuildDir].concat(options);
         const compilerPath = useDotNetHost ? this.compiler.exe : this.corerunPath;
         const compilerExecResult = await this.exec(compilerPath, ilspyOptions, execOptions);
         const result = this.transformToCompilationResult(compilerExecResult, dllPath);
diff --git a/lib/formatting-service.ts b/lib/formatting-service.ts
new file mode 100644
index 000000000..95ab61493
--- /dev/null
+++ b/lib/formatting-service.ts
@@ -0,0 +1,102 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import _ from 'underscore';
+
+import {UnprocessedExecResult} from '../types/execution/execution.interfaces.js';
+
+import * as exec from './exec.js';
+import {FormatOptions} from './formatters/base.interfaces.js';
+import {BaseFormatter, getFormatterTypeByKey} from './formatters/index.js';
+import {logger} from './logger.js';
+import {PropertyGetter} from './properties.interfaces.js';
+
+export class FormattingService {
+    /** Mapping of the formatter ids to the formatter implementations */
+    private registry: Map<string, BaseFormatter> = new Map();
+
+    public getFormatterById(id: string): BaseFormatter | null {
+        return this.registry.get(id) || null;
+    }
+
+    public getFormatters(): BaseFormatter[] {
+        return Array.from(this.registry.values());
+    }
+
+    public async format(formatterid: string, source: string, options: FormatOptions): Promise<UnprocessedExecResult> {
+        const formatter = this.getFormatterById(formatterid);
+        // Ensure the formatter exists
+        if (formatter === null) {
+            throw new Error(`Formatter ${formatterid} not found`);
+        }
+        // Ensure the formatting style is valid
+        if (!formatter.isValidStyle(options.baseStyle)) {
+            throw new Error(`Formatter ${formatterid} does not support style ${options.baseStyle}`);
+        }
+        return await formatter.format(source, options);
+    }
+
+    public async initialize(ceProps: PropertyGetter): Promise<void> {
+        const formatters = _.compact(ceProps('formatters', '').split(':'));
+        for (const formatter of formatters) {
+            logger.info(`Performing discovery of formatter named ${formatter}`);
+            const executable = ceProps<string>(`formatter.${formatter}.exe`);
+            const type = ceProps<string>(`formatter.${formatter}.type`);
+            if (!executable) {
+                logger.warn(`Formatter ${formatter} does not have a valid executable. Skipping...`);
+                continue;
+            }
+            if (!type) {
+                logger.warn(`Formatter ${formatter} does not have a valid type. Skipping...`);
+                continue;
+            }
+            const versionArgument = ceProps<string>(`formatter.${formatter}.version`, '--version');
+            const versionRegExp = ceProps<string>(`formatter.${formatter}.versionRe`, '.*');
+            const hasExplicitVersion = ceProps(`formatter.${formatter}.explicitVersion`, '') !== '';
+            try {
+                const result = await exec.execute(executable, [versionArgument], {});
+                const match = result.stdout.match(versionRegExp);
+                const formatterClass = getFormatterTypeByKey(type);
+                const styleList = ceProps<string>(`formatter.${formatter}.styles`, '');
+                const styles = styleList === '' ? [] : styleList.split(':');
+                // If there is an explicit version, grab it. Otherwise, try to filter the output
+                const version = hasExplicitVersion
+                    ? ceProps<string>(`formatter.${formatter}.explicitVersion`)
+                    : match
+                      ? match[0]
+                      : result.stdout;
+                const instance = new formatterClass({
+                    name: ceProps(`formatter.${formatter}.name`, executable),
+                    exe: executable,
+                    version,
+                    styles,
+                    type,
+                });
+                this.registry.set(formatter, instance);
+            } catch (err: unknown) {
+                logger.warn(`Error while fetching tool info for ${executable}:`, {err});
+            }
+        }
+    }
+}
diff --git a/lib/handlers/api.ts b/lib/handlers/api.ts
index 05880f63d..5835d7eef 100644
--- a/lib/handlers/api.ts
+++ b/lib/handlers/api.ts
@@ -42,7 +42,6 @@ import {BaseShortener, getShortenerTypeByKey} from '../shortener/index.js';
 import {StorageBase} from '../storage/index.js';
 
 import {CompileHandler} from './compile.js';
-import {FormattingHandler} from './formatting.js';
 
 function methodNotAllowed(req: express.Request, res: express.Response) {
     res.send('Method Not Allowed');
@@ -121,19 +120,6 @@ export class ApiHandler {
             .post(compileHandler.handleOptimizationArguments.bind(compileHandler))
             .get(compileHandler.handleOptimizationArguments.bind(compileHandler))
             .all(methodNotAllowed);
-
-        const formatHandler = new FormattingHandler(ceProps);
-        this.handle
-            .route('/format/:tool')
-            .post((req, res) => formatHandler.handle(req, res))
-            .all(methodNotAllowed);
-        this.handle
-            .route('/formats')
-            .get((req, res) => {
-                const all = formatHandler.getFormatterInfo();
-                res.send(all);
-            })
-            .all(methodNotAllowed);
         this.handle.route('/shortlinkinfo/:id').get(this.shortlinkInfoHandler.bind(this)).all(methodNotAllowed);
 
         const shortenerType = getShortenerTypeByKey(urlShortenService);
diff --git a/lib/handlers/api/assembly-documentation-controller.ts b/lib/handlers/api/assembly-documentation-controller.ts
index 9893105f4..85317b6a6 100644
--- a/lib/handlers/api/assembly-documentation-controller.ts
+++ b/lib/handlers/api/assembly-documentation-controller.ts
@@ -24,15 +24,15 @@
 
 import express from 'express';
 
-import {addStaticHeaders} from '../../../app.js';
 import {getDocumentationProviderTypeByKey} from '../../asm-docs/index.js';
+import {cached, cors} from '../middleware.js';
 
 import {HttpController} from './controller.interfaces.js';
 
 export class AssemblyDocumentationController implements HttpController {
     createRouter(): express.Router {
         const router = express.Router();
-        router.get('/api/asm/:arch/:opcode', this.getOpcodeDocumentation.bind(this));
+        router.get('/api/asm/:arch/:opcode', cors, cached, this.getOpcodeDocumentation.bind(this));
         return router;
     }
 
@@ -54,12 +54,10 @@ export class AssemblyDocumentationController implements HttpController {
             const contentType = req.accepts(['text', 'json']);
             switch (contentType) {
                 case 'text': {
-                    addStaticHeaders(res);
                     res.send(information.html);
                     break;
                 }
                 case 'json': {
-                    addStaticHeaders(res);
                     res.send(information);
                     break;
                 }
diff --git a/lib/handlers/api/formatting-controller.ts b/lib/handlers/api/formatting-controller.ts
new file mode 100644
index 000000000..3a348ed8a
--- /dev/null
+++ b/lib/handlers/api/formatting-controller.ts
@@ -0,0 +1,82 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import express from 'express';
+
+import {FormattingService} from '../../formatting-service.js';
+import {cached, cors} from '../middleware.js';
+
+import {HttpController} from './controller.interfaces.js';
+
+export class FormattingController implements HttpController {
+    public constructor(private formattingService: FormattingService) {}
+
+    createRouter(): express.Router {
+        const router = express.Router();
+        router.post('/api/format/:tool', cors, cached, this.format.bind(this));
+        router.get('/api/formats', cors, cached, this.getFormatters.bind(this));
+        return router;
+    }
+
+    /** Handle requests to /api/format/:tool */
+    public async format(req: express.Request, res: express.Response) {
+        const formatter = this.formattingService.getFormatterById(req.params.tool);
+        // Ensure the target formatter exists
+        if (formatter === null) {
+            return res.status(422).json({exit: 2, answer: `Unknown format tool '${req.params.tool}'`});
+        }
+        // Ensure there is source code to format
+        if (!req.body || !req.body.source) {
+            res.status(400).json({exit: 0, answer: ''});
+            return;
+        }
+        // Ensure that the requested style is supported by the formatter
+        if (!formatter.isValidStyle(req.body.base)) {
+            return res.status(422).json({exit: 3, answer: `Style '${req.body.base}' is not supported`});
+        }
+        // Do the formatting
+        try {
+            const formatted = await formatter.format(req.body.source, {
+                useSpaces: req.body.useSpaces === undefined ? true : req.body.useSpaces,
+                tabWidth: req.body.tabWidth === undefined ? 4 : req.body.tabWidth,
+                baseStyle: req.body.base,
+            });
+            res.json({exit: formatted.code, answer: formatted.stdout || formatted.stderr || ''});
+        } catch (err: unknown) {
+            res.status(500).json({
+                exit: 1,
+                thrown: true,
+                answer:
+                    (err && Object.hasOwn(err, 'message') && (err as Record<'message', 'string'>).message) ||
+                    'Internal server error',
+            });
+        }
+    }
+
+    /** Handle requests to /api/formats */
+    public async getFormatters(_: express.Request, res: express.Response) {
+        const formatters = this.formattingService.getFormatters();
+        res.send(formatters.map(formatter => formatter.formatterInfo));
+    }
+}
diff --git a/lib/handlers/api/site-template-controller.ts b/lib/handlers/api/site-template-controller.ts
index cc9ccca7b..03333bd86 100644
--- a/lib/handlers/api/site-template-controller.ts
+++ b/lib/handlers/api/site-template-controller.ts
@@ -25,13 +25,14 @@
 import express from 'express';
 
 import {getSiteTemplates} from '../../site-templates.js';
+import {cached, cors} from '../middleware.js';
 
 import {HttpController} from './controller.interfaces.js';
 
 export class SiteTemplateController implements HttpController {
     createRouter(): express.Router {
         const router = express.Router();
-        router.get('/api/siteTemplates', this.getSiteTemplates.bind(this));
+        router.get('/api/siteTemplates', cors, cached, this.getSiteTemplates.bind(this));
         return router;
     }
 
diff --git a/lib/handlers/api/source-controller.ts b/lib/handlers/api/source-controller.ts
index b7a48f609..add1a2111 100644
--- a/lib/handlers/api/source-controller.ts
+++ b/lib/handlers/api/source-controller.ts
@@ -25,8 +25,8 @@
 import express from 'express';
 import _ from 'underscore';
 
-import {addStaticHeaders} from '../../../app.js';
 import {Source} from '../../../types/source.interfaces.js';
+import {cached, cors} from '../middleware.js';
 
 import {HttpController} from './controller.interfaces.js';
 
@@ -35,8 +35,8 @@ export class SourceController implements HttpController {
 
     createRouter(): express.Router {
         const router = express.Router();
-        router.get('/source/:source/list', this.listEntries.bind(this));
-        router.get('/source/:source/load/:language/:filename', this.loadEntry.bind(this));
+        router.get('/source/:source/list', cors, cached, this.listEntries.bind(this));
+        router.get('/source/:source/load/:language/:filename', cors, cached, this.loadEntry.bind(this));
         return router;
     }
 
@@ -50,7 +50,6 @@ export class SourceController implements HttpController {
             return;
         }
         const entries = await source.list();
-        addStaticHeaders(res);
         res.json(entries);
     }
 
@@ -64,7 +63,6 @@ export class SourceController implements HttpController {
             return;
         }
         const entry = await source.load(req.params.language, req.params.filename);
-        addStaticHeaders(res);
         res.json(entry);
     }
 
diff --git a/lib/handlers/formatting.ts b/lib/handlers/formatting.ts
deleted file mode 100644
index 623f04447..000000000
--- a/lib/handlers/formatting.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-// Copyright (c) 2018, Compiler Explorer Authors
-// All rights reserved.
-//
-// Redistribution and use in source and binary forms, with or without
-// modification, are permitted provided that the following conditions are met:
-//
-//     * Redistributions of source code must retain the above copyright notice,
-//       this list of conditions and the following disclaimer.
-//     * Redistributions in binary form must reproduce the above copyright
-//       notice, this list of conditions and the following disclaimer in the
-//       documentation and/or other materials provided with the distribution.
-//
-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-// POSSIBILITY OF SUCH DAMAGE.
-
-import express from 'express';
-import _ from 'underscore';
-
-import * as exec from '../exec.js';
-import {BaseFormatter, getFormatterTypeByKey} from '../formatters/index.js';
-import {logger} from '../logger.js';
-import {PropertyGetter} from '../properties.interfaces.js';
-
-export class FormattingHandler {
-    private formatters: Record<string, BaseFormatter> = {};
-
-    public constructor(private ceProps: PropertyGetter) {
-        const formatters = _.compact(ceProps('formatters', '').split(':'));
-        for (const formatter of formatters) {
-            this.populateFormatterInfo(formatter);
-        }
-    }
-
-    private async populateFormatterInfo(formatterName: string): Promise<void> {
-        const exe = this.ceProps<string>(`formatter.${formatterName}.exe`);
-        const type = this.ceProps<string>(`formatter.${formatterName}.type`);
-        if (!exe) {
-            logger.warn(`Formatter ${formatterName} does not have a valid executable. Skipping...`);
-            return;
-        }
-        if (!type) {
-            logger.warn(`Formatter ${formatterName} does not have a formatter class. Skipping...`);
-            return;
-        }
-        const versionArgument = this.ceProps<string>(`formatter.${formatterName}.version`, '--version');
-        const versionRegExp = this.ceProps<string>(`formatter.${formatterName}.versionRe`, '.*');
-        const hasExplicitVersion = this.ceProps(`formatter.${formatterName}.explicitVersion`, '') !== '';
-        try {
-            const result = await exec.execute(exe, [versionArgument], {});
-            const match = result.stdout.match(versionRegExp);
-            const formatterClass = getFormatterTypeByKey(type);
-            const styleList = this.ceProps<string>(`formatter.${formatterName}.styles`, '');
-            const styles = styleList === '' ? [] : styleList.split(':');
-            // If there is an explicit version, grab it. Otherwise try to filter the output
-            const version = hasExplicitVersion
-                ? this.ceProps<string>(`formatter.${formatterName}.explicitVersion`)
-                : match
-                  ? match[0]
-                  : result.stdout;
-            this.formatters[formatterName] = new formatterClass({
-                name: this.ceProps(`formatter.${formatterName}.name`, exe),
-                exe,
-                version,
-                styles,
-                type,
-            });
-        } catch (err: unknown) {
-            logger.warn(`Error while fetching tool info for ${exe}:`, {err});
-        }
-    }
-
-    public getFormatterInfo() {
-        return Object.values(this.formatters).map(formatter => formatter.formatterInfo);
-    }
-
-    public async handle(req: express.Request, res: express.Response): Promise<void> {
-        const name = req.params.tool;
-        const formatter = this.formatters[name];
-        // Ensure the formatter exists
-        if (!formatter) {
-            res.status(422).send({
-                exit: 2,
-                answer: `Unknown format tool '${name}'`,
-            });
-            return;
-        }
-        // Ensure there is source code to format
-        if (!req.body || !req.body.source) {
-            res.send({exit: 0, answer: ''});
-            return;
-        }
-        // Ensure the wanted style is valid for the formatter
-        const style = req.body.base;
-        if (!formatter.isValidStyle(style)) {
-            res.status(422).send({
-                exit: 3,
-                answer: `Style '${style}' is not supported`,
-            });
-            return;
-        }
-        try {
-            // Perform the actual formatting
-            const result = await formatter.format(req.body.source, {
-                useSpaces: req.body.useSpaces === undefined ? true : req.body.useSpaces,
-                tabWidth: req.body.tabWidth === undefined ? 4 : req.body.tabWidth,
-                baseStyle: req.body.base,
-            });
-            res.send({
-                exit: result.code,
-                answer: result.stdout || result.stderr || '',
-            });
-        } catch (err: unknown) {
-            res.status(500).send({
-                exit: 1,
-                thrown: true,
-                answer:
-                    (err && Object.hasOwn(err, 'message') && (err as Record<'message', 'string'>).message) ||
-                    'Internal server error',
-            });
-        }
-    }
-
-    async internalFormat(formatterName: string, style: string, source: string): Promise<[string, string]> {
-        const formatter = this.formatters[formatterName];
-        // Ensure the formatter exists
-        if (!formatter) {
-            throw new Error('Unknown formatter name');
-        }
-        // Ensure the wanted style is valid for the formatter
-        if (!formatter.isValidStyle(style)) {
-            throw new Error('Unsupported formatter style');
-        }
-        // Perform the actual formatting
-        const result = await formatter.format(source, {
-            useSpaces: true, // hard coded for now, TODO should this be changed?
-            tabWidth: 4,
-            baseStyle: style,
-        });
-        return [result.stdout || '', result.stderr];
-    }
-}
diff --git a/lib/handlers/middleware.ts b/lib/handlers/middleware.ts
new file mode 100644
index 000000000..9b23f2782
--- /dev/null
+++ b/lib/handlers/middleware.ts
@@ -0,0 +1,50 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import express from 'express';
+
+import * as props from '../properties.js';
+
+const ceProps = props.propsFor('compiler-explorer');
+const staticMaxAge = ceProps('staticMaxAgeSecs', 31536000);
+
+/** Add static headers to the response */
+export const cached: express.Handler = (_, res, next) => {
+    res.set('Cache-Control', `public, max-age=${staticMaxAge}, must-revalidate`);
+    return next();
+};
+
+/** Allow any client to make cross-origin requests */
+export const cors: express.Handler = (_, res, next) => {
+    res.set('Access-Control-Allow-Origin', '*');
+    res.set('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
+    return next();
+};
+
+/** Add Content-Security-Policy header to the response */
+export const csp: express.Handler = (_, res, next) => {
+    // TODO: Consider if CSP should be re-enabled
+    // res.set('Content-Security-Policy', `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self';`);
+    return next();
+};
diff --git a/lib/handlers/noscript.ts b/lib/handlers/noscript.ts
index 0bf209ad1..94dd8931d 100644
--- a/lib/handlers/noscript.ts
+++ b/lib/handlers/noscript.ts
@@ -35,14 +35,13 @@ import {ClientOptionsHandler} from '../options-handler.js';
 import {StorageBase} from '../storage/index.js';
 
 import {CompileHandler} from './compile.js';
+import {cached, csp} from './middleware.js';
 
 function isMobileViewer(req: express.Request) {
     return req.header('CloudFront-Is-Mobile-Viewer') === 'true';
 }
 
 export class NoScriptHandler {
-    readonly staticHeaders: (res: express.Response) => void;
-    readonly contentPolicyHeader: (res: express.Response) => void;
     readonly clientOptionsHandler: ClientOptionsHandler;
     readonly renderConfig: (a: any, b: any) => any;
     readonly storageHandler: StorageBase;
@@ -56,8 +55,6 @@ export class NoScriptHandler {
         private readonly router: express.Router,
         config: any,
     ) {
-        this.staticHeaders = config.staticHeaders;
-        this.contentPolicyHeader = config.contentPolicyHeader;
         this.clientOptionsHandler = config.clientOptionsHandler;
         this.renderConfig = config.renderConfig;
         this.storageHandler = config.storageHandler;
@@ -74,15 +71,11 @@ export class NoScriptHandler {
         });
 
         this.router
-            .get('/noscript', (req, res) => {
-                this.staticHeaders(res);
-                this.contentPolicyHeader(res);
+            .get('/noscript', cached, csp, (req, res) => {
                 this.renderNoScriptLayout(undefined, req, res);
             })
-            .get('/noscript/z/:id', this.storedStateHandlerNoScript.bind(this))
-            .get('/noscript/sponsors', (req, res) => {
-                this.staticHeaders(res);
-                this.contentPolicyHeader(res);
+            .get('/noscript/z/:id', cached, csp, this.storedStateHandlerNoScript.bind(this))
+            .get('/noscript/sponsors', cached, csp, (req, res) => {
                 res.render(
                     'noscript/sponsors',
                     this.renderConfig(
@@ -94,9 +87,7 @@ export class NoScriptHandler {
                     ),
                 );
             })
-            .get('/noscript/:language', (req, res) => {
-                this.staticHeaders(res);
-                this.contentPolicyHeader(res);
+            .get('/noscript/:language', cached, csp, (req, res) => {
                 this.renderNoScriptLayout(undefined, req, res);
             })
             .post('/api/noscript/compile', this.formDataParser, this.compileHandler.handle.bind(this.compileHandler));
@@ -157,9 +148,6 @@ export class NoScriptHandler {
     }
 
     renderNoScriptLayout(state: ClientState | undefined, req: express.Request, res: express.Response) {
-        this.staticHeaders(res);
-        this.contentPolicyHeader(res);
-
         let wantedLanguage = 'c++';
         if (req.params && req.params.language) {
             wantedLanguage = req.params.language;
diff --git a/lib/handlers/route-api.ts b/lib/handlers/route-api.ts
index 8fbc8fe10..2c0577f4e 100644
--- a/lib/handlers/route-api.ts
+++ b/lib/handlers/route-api.ts
@@ -43,6 +43,7 @@ import * as utils from '../utils.js';
 
 import {ApiHandler} from './api.js';
 import {CompileHandler} from './compile.js';
+import {cached, csp} from './middleware.js';
 
 export type HandlerConfig = {
     compileHandler: CompileHandler;
@@ -53,8 +54,6 @@ export type HandlerConfig = {
     defArgs: AppDefaultArguments;
     renderConfig: any;
     renderGoldenLayout: any;
-    staticHeaders: any;
-    contentPolicyHeader: any;
     compilationEnvironment: CompilationEnvironment;
 };
 
@@ -97,11 +96,11 @@ export class RouteAPI {
     InitializeRoutes() {
         this.router
             .use('/api', this.apiHandler.handle)
-            .get('/z/:id', this.storedStateHandler.bind(this))
-            .get('/z/:id/code/:session', this.storedCodeHandler.bind(this))
-            .get('/resetlayout/:id', this.storedStateHandlerResetLayout.bind(this))
-            .get('/clientstate/:clientstatebase64([^]*)', this.unstoredStateHandler.bind(this))
-            .get('/fromsimplelayout', this.simpleLayoutHandler.bind(this));
+            .get('/z/:id', cached, csp, this.storedStateHandler.bind(this))
+            .get('/z/:id/code/:session', cached, csp, this.storedCodeHandler.bind(this))
+            .get('/resetlayout/:id', cached, csp, this.storedStateHandlerResetLayout.bind(this))
+            .get('/clientstate/:clientstatebase64([^]*)', cached, csp, this.unstoredStateHandler.bind(this))
+            .get('/fromsimplelayout', cached, csp, this.simpleLayoutHandler.bind(this));
     }
 
     storedCodeHandler(req: express.Request, res: express.Response, next: express.NextFunction) {
diff --git a/package-lock.json b/package-lock.json
index d117b0e1c..0f4b46ea3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5989,9 +5989,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001677",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001677.tgz",
-      "integrity": "sha512-fmfjsOlJUpMWu+mAAtZZZHz7UEwsUxIIvu1TJfO1HqFQvB/B+ii0xr9B5HpbZY/mC4XZ8SvjHJqtAY6pDPQEog==",
+      "version": "1.0.30001684",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001684.tgz",
+      "integrity": "sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==",
       "funding": [
         {
           "type": "opencollective",
@@ -6005,7 +6005,8 @@
           "type": "github",
           "url": "https://github.com/sponsors/ai"
         }
-      ]
+      ],
+      "license": "CC-BY-4.0"
     },
     "node_modules/caseless": {
       "version": "0.12.0",


Test patch:
diff --git a/test/compilation-env.ts b/test/compilation-env.ts
index 786f8ab24..1b97d4f81 100644
--- a/test/compilation-env.ts
+++ b/test/compilation-env.ts
@@ -26,6 +26,7 @@ import './utils.js';
 import {beforeAll, describe, expect, it} from 'vitest';
 
 import {CompilationEnvironment} from '../lib/compilation-env.js';
+import {FormattingService} from '../lib/formatting-service.js';
 import {CompilerProps, fakeProps} from '../lib/properties.js';
 
 const props = {
@@ -43,28 +44,40 @@ describe('Compilation environment', () => {
 
     it('Should cache by default', async () => {
         // TODO: Work will need to be done here when CompilationEnvironment's constructor is typed better
-        const ce = new CompilationEnvironment(compilerProps, fakeProps({}), undefined, undefined);
+        const ce = new CompilationEnvironment(
+            compilerProps,
+            fakeProps({}),
+            undefined,
+            new FormattingService(),
+            undefined,
+        );
         await expect(ce.cacheGet('foo')).resolves.toBeNull();
         await ce.cachePut('foo', {res: 'bar'}, undefined);
         await expect(ce.cacheGet('foo')).resolves.toEqual({res: 'bar'});
         await expect(ce.cacheGet('baz')).resolves.toBeNull();
     });
     it('Should cache when asked', async () => {
-        const ce = new CompilationEnvironment(compilerProps, fakeProps({}), undefined, true);
+        const ce = new CompilationEnvironment(compilerProps, fakeProps({}), undefined, new FormattingService(), true);
         await expect(ce.cacheGet('foo')).resolves.toBeNull();
         await ce.cachePut('foo', {res: 'bar'}, undefined);
         await expect(ce.cacheGet('foo')).resolves.toEqual({res: 'bar'});
     });
     it("Shouldn't cache when asked", async () => {
         // TODO: Work will need to be done here when CompilationEnvironment's constructor is typed better
-        const ce = new CompilationEnvironment(compilerProps, fakeProps({}), undefined, false);
+        const ce = new CompilationEnvironment(compilerProps, fakeProps({}), undefined, new FormattingService(), false);
         await expect(ce.cacheGet('foo')).resolves.toBeNull();
         await ce.cachePut('foo', {res: 'bar'}, undefined);
         await expect(ce.cacheGet('foo')).resolves.toBeNull();
     });
     it('Should filter bad options', () => {
         // TODO: Work will need to be done here when CompilationEnvironment's constructor is typed better
-        const ce = new CompilationEnvironment(compilerProps, fakeProps({}), undefined, undefined);
+        const ce = new CompilationEnvironment(
+            compilerProps,
+            fakeProps({}),
+            undefined,
+            new FormattingService(),
+            undefined,
+        );
         expect(ce.findBadOptions(['-O3', '-flto'])).toEqual([]);
         expect(ce.findBadOptions(['-O3', '-plugin'])).toEqual(['-plugin']);
     });
diff --git a/test/handlers/api-tests.ts b/test/handlers/api-tests.ts
index 92194b165..c9adbc649 100644
--- a/test/handlers/api-tests.ts
+++ b/test/handlers/api-tests.ts
@@ -164,23 +164,4 @@ describe('API handling', () => {
             .expect('Content-Type', /json/)
             .expect(200, [languages['c++'], languages.pascal]);
     });
-
-    it('should not go through with invalid tools', async () => {
-        await request(app)
-            .post('/api/format/invalid')
-            .set('Accept', 'application/json')
-            .expect('Content-Type', /json/)
-            .expect(422, {exit: 2, answer: "Unknown format tool 'invalid'"});
-    });
-    it('should not go through with invalid base styles', async () => {
-        await request(app)
-            .post('/api/format/formatt')
-            .send({
-                base: 'bad-base',
-                source: 'i am source',
-            })
-            .set('Accept', 'application/json')
-            .expect(422, {exit: 3, answer: "Style 'bad-base' is not supported"})
-            .expect('Content-Type', /json/);
-    });
 });
diff --git a/test/handlers/api/formatting-controller.tests.ts b/test/handlers/api/formatting-controller.tests.ts
new file mode 100644
index 000000000..f1aee3328
--- /dev/null
+++ b/test/handlers/api/formatting-controller.tests.ts
@@ -0,0 +1,83 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import express from 'express';
+import request from 'supertest';
+import {beforeAll, describe, it} from 'vitest';
+
+import {FormattingService} from '../../../lib/formatting-service.js';
+import {FormattingController} from '../../../lib/handlers/api/formatting-controller.js';
+import {fakeProps} from '../../../lib/properties.js';
+
+describe('FormattingController', () => {
+    let app;
+
+    beforeAll(async () => {
+        app = express();
+        const formattingService = new FormattingService();
+        const formattingController = new FormattingController(formattingService);
+        await formattingService.initialize(
+            fakeProps({
+                formatters: 'formatt:badformatt',
+                'formatter.formatt.exe': process.platform === 'win32' ? 'cmd' : 'echo',
+                'formatter.formatt.type': 'clangformat',
+                'formatter.formatt.version': 'Release',
+                'formatter.formatt.name': 'FormatT',
+            }),
+        );
+        app.use(express.json());
+        app.use(formattingController.createRouter());
+    });
+
+    it('should not go through with invalid tools', async () => {
+        await request(app)
+            .post('/api/format/invalid')
+            .set('Accept', 'application/json')
+            .expect('Content-Type', /json/)
+            .expect(422, {exit: 2, answer: "Unknown format tool 'invalid'"});
+    });
+
+    it('should not go through with invalid base styles', async () => {
+        await request(app)
+            .post('/api/format/formatt')
+            .send({
+                base: 'bad-base',
+                source: 'i am source',
+            })
+            .set('Accept', 'application/json')
+            .expect(422, {exit: 3, answer: "Style 'bad-base' is not supported"})
+            .expect('Content-Type', /json/);
+    });
+
+    it('should reject requests with no source', async () => {
+        await request(app)
+            .post('/api/format/formatt')
+            .send({
+                base: 'bad-base',
+            })
+            .set('Accept', 'application/json')
+            .expect(400, {exit: 0, answer: ''})
+            .expect('Content-Type', /json/);
+    });
+});
diff --git a/test/handlers/middleware-tests.ts b/test/handlers/middleware-tests.ts
new file mode 100644
index 000000000..65b1bc9ab
--- /dev/null
+++ b/test/handlers/middleware-tests.ts
@@ -0,0 +1,50 @@
+// Copyright (c) 2024, Compiler Explorer Authors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//     * Redistributions of source code must retain the above copyright notice,
+//       this list of conditions and the following disclaimer.
+//     * Redistributions in binary form must reproduce the above copyright
+//       notice, this list of conditions and the following disclaimer in the
+//       documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import express from 'express';
+import request from 'supertest';
+import {describe, it} from 'vitest';
+
+import {cached, cors} from '../../lib/handlers/middleware.js';
+
+describe('middleware functions', () => {
+    it('adds cache controls headers with cached()', async () => {
+        const app = express();
+        app.use('/', cached, (_, res) => res.send('Hello World!'));
+        await request(app)
+            .get('/')
+            .expect(200, 'Hello World!')
+            .expect('cache-control', /public, max-age=\d+, must-revalidate/);
+    });
+
+    it('adds cors headers with cors()', async () => {
+        const app = express();
+        app.use('/', cors, (_, res) => res.send('Hello World!'));
+        await request(app)
+            .get('/')
+            .expect(200, 'Hello World!')
+            .expect('access-control-allow-origin', '*')
+            .expect('access-control-allow-headers', 'Origin, X-Requested-With, Content-Type, Accept');
+    });
+});