Repository: compiler-explorer/compiler-explorer
Pull request #7709 (2025-05-21 22:20:20)

Fix static URL double slash issue in CDN asset paths
## Summary
Fix production CDN loading issues where URLs like `https://static.ce-cdn.net//vendor.v57...css` (double slash) were being generated when the `staticUrl` config has a trailing slash.

## Root Cause
PR #7681 refactored static asset handling and replaced `urljoin(staticRoot, path)` with `${staticRoot}/${path}` string interpolation, losing URL normalization that `urljoin` provides.

## Changes
- Replace string interpolation with `urljoin()` in `createDefaultPugRequireHandler()` 
- Add comprehensive tests covering all trailing slash scenarios
- Maintain full backward compatibility with existing configurations

## Test plan
- [x] All existing tests pass
- [x] Added 4 new test cases for static URL handling with various slash scenarios  
- [x] Verified TypeScript compilation and linting
- [x] Tested production CDN scenario where `staticUrl=https://static.ce-cdn.net/`

🤖 Generated with [Claude Code](https://claude.ai/code)


Patch:
diff --git a/lib/app/static-assets.ts b/lib/app/static-assets.ts
index f1d16c990..bc877ecdc 100644
--- a/lib/app/static-assets.ts
+++ b/lib/app/static-assets.ts
@@ -44,13 +44,13 @@ export function createDefaultPugRequireHandler(
 ): PugRequireHandler {
     return (path: string) => {
         if (manifest && Object.prototype.hasOwnProperty.call(manifest, path)) {
-            return `${staticRoot}/${manifest[path]}`;
+            return urljoin(staticRoot, manifest[path]);
         }
         if (manifest) {
             logger.error(`Failed to locate static asset '${path}' in manifest`);
             return '';
         }
-        return `${staticRoot}/${path}`;
+        return urljoin(staticRoot, path);
     };
 }
 


Test patch:
diff --git a/test/app/config-tests.ts b/test/app/config-tests.ts
index 5b6364e65..c10e07692 100644
--- a/test/app/config-tests.ts
+++ b/test/app/config-tests.ts
@@ -412,5 +412,20 @@ describe('Config Module', () => {
             expect(result.staticUrl).toBe('https://static.example.com');
             expect(result.staticRoot).toBe('https://static.example.com/');
         });
+
+        it('should handle staticUrl with trailing slash correctly', () => {
+            // This tests the production scenario where staticUrl already has trailing slash
+            mockCeProps.staticUrl = 'https://static.ce-cdn.net/';
+            const appArgs = createMockAppArgs({
+                useLocalProps: true,
+                propDebug: false,
+            });
+
+            const result = loadConfiguration(appArgs);
+
+            expect(result.staticUrl).toBe('https://static.ce-cdn.net/');
+            // urljoin normalizes trailing slashes correctly
+            expect(result.staticRoot).toBe('https://static.ce-cdn.net/');
+        });
     });
 });
diff --git a/test/app/static-assets-tests.ts b/test/app/static-assets-tests.ts
index 161ccd05f..c1480c671 100644
--- a/test/app/static-assets-tests.ts
+++ b/test/app/static-assets-tests.ts
@@ -52,6 +52,62 @@ describe('Static assets', () => {
 
             expect(handler('file1.js')).toBe('/static/file1.js');
         });
+
+        it('should handle staticRoot with trailing slash correctly', () => {
+            const manifest = {
+                'vendor.js': 'vendor.v57.0aa06caf727cfaf434aa.js',
+                'app.css': 'app.v42.123456789abcdef.css',
+            };
+
+            // Test with trailing slash - this is the production scenario
+            const handler = createDefaultPugRequireHandler('https://static.ce-cdn.net/', manifest);
+
+            expect(handler('vendor.js')).toBe('https://static.ce-cdn.net/vendor.v57.0aa06caf727cfaf434aa.js');
+            expect(handler('app.css')).toBe('https://static.ce-cdn.net/app.v42.123456789abcdef.css');
+
+            // Test path not in manifest
+            expect(handler('unknown.js')).toBe('');
+        });
+
+        it('should handle staticRoot with double slash correctly', () => {
+            const manifest = {
+                'vendor.js': 'vendor.v57.0aa06caf727cfaf434aa.js',
+            };
+
+            // Test with double slash - this is what happens with config bug
+            const handler = createDefaultPugRequireHandler('https://static.ce-cdn.net//', manifest);
+
+            // Should normalize to single slash
+            expect(handler('vendor.js')).toBe('https://static.ce-cdn.net/vendor.v57.0aa06caf727cfaf434aa.js');
+        });
+
+        it('should handle staticRoot without trailing slash correctly', () => {
+            const manifest = {
+                'vendor.js': 'vendor.v57.0aa06caf727cfaf434aa.js',
+            };
+
+            // Test without trailing slash
+            const handler = createDefaultPugRequireHandler('https://static.ce-cdn.net', manifest);
+
+            expect(handler('vendor.js')).toBe('https://static.ce-cdn.net/vendor.v57.0aa06caf727cfaf434aa.js');
+        });
+
+        it('should handle local paths with various trailing slash scenarios', () => {
+            const manifest = {
+                'main.js': 'main.hash123.js',
+            };
+
+            // Test various local path formats
+            const scenarios = ['/static', '/static/', '/static//'];
+
+            for (const staticRoot of scenarios) {
+                const handler = createDefaultPugRequireHandler(staticRoot, manifest);
+                const result = handler('main.js');
+
+                // All should resolve to the same normalized path
+                expect(result).toBe('/static/main.hash123.js');
+            }
+        });
     });
 
     describe('getFaviconFilename', () => {