feat(ssr): spider-only SSR — serve CSR shell for regular users
CI Pipeline / backend-test (push) Successful in 12m23s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 17m40s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
CI Pipeline / backend-test (push) Successful in 12m23s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 17m40s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
Add spider detection gate to SSR server so that search engine crawlers get full server-side rendering with fresh API data, while regular users receive the static CSR app shell from disk. - backend/app/spider_detection.py: Python spider detection module - spider_detection.ts: TypeScript mirror of spider detection patterns - server.ts: SSR gate — isSpider() check before dispatching to Angular SSR engine; non-spiders get static index.html - server.spec.ts: Added spider detection unit tests - tsconfig.app.json: Include spider_detection.ts in compilation - backend/app/models.py: Fix SQLAlchemy FK resolution (user_models import)
This commit is contained in:
1 parent
96011ef200
commit
c5d80f4caa
7 files changed
+860
-21
No files matched your search
+139
-20
@@ -16,45 +16,164 @@ vi.mock('node:http', () => ({
|
||||
default: { createServer: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
default: { readFileSync: vi.fn(() => '<!DOCTYPE html><html>static shell</html>') },
|
||||
readFileSync: vi.fn(() => '<!DOCTYPE html><html>static shell</html>'),
|
||||
}));
|
||||
|
||||
vi.mock('node:path', () => ({
|
||||
default: { join: (...args: string[]) => args.join('/'), dirname: () => '/app/server' },
|
||||
join: (...args: string[]) => args.join('/'),
|
||||
dirname: () => '/app/server',
|
||||
}));
|
||||
|
||||
// Mock the engine manifest that only exists after ng build
|
||||
vi.mock('./angular-app-engine-manifest.mjs', () => ({}));
|
||||
|
||||
describe('SSR server — server.ts', () => {
|
||||
it('should export a default request handler', async () => {
|
||||
it('should export a request handler', async () => {
|
||||
const mod = await import('./server');
|
||||
expect(mod.default).toBeDefined();
|
||||
expect(mod.requestHandler).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not export PRERENDERED_ROUTES (all routes now use RenderMode.Server)', async () => {
|
||||
const mod = await import('./server');
|
||||
// The server.ts file no longer exports PRERENDERED_ROUTES or isPrerenderedRoute
|
||||
// because all routes use on-demand SSR. Verify the file doesn't reference them.
|
||||
it('should have spider detection imports and serveStaticShell', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).not.toContain('PRERENDERED_ROUTES');
|
||||
expect(content).not.toContain('isPrerenderedRoute');
|
||||
expect(content).not.toContain('AngularAppEngine');
|
||||
expect(content).toContain('isSpider');
|
||||
expect(content).toContain('getUserAgent');
|
||||
expect(content).toContain('serveStaticShell');
|
||||
expect(content).toContain('spider_detection');
|
||||
});
|
||||
|
||||
it('should have a clean server with lazy engine bootstrapping', async () => {
|
||||
it('should gate SSR behind spider detection', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./server.ts', 'utf-8');
|
||||
expect(content).toContain('getEngine');
|
||||
expect(content).toContain('AngularNodeAppEngine');
|
||||
expect(content).toContain('listen(4200');
|
||||
// Verify the spider detection gate exists
|
||||
expect(content).toContain("if (!isSpider(userAgent))");
|
||||
expect(content).toContain('serveStaticShell(res)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app.server.routes.ts — RenderMode.Server for all routes', () => {
|
||||
it('should use RenderMode.Server for all public routes (fresh data on every request)', async () => {
|
||||
describe('spider_detection.ts — TypeScript spider detection', () => {
|
||||
it('should detect Googlebot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Bingbot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect DuckDuckBot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect YandexBot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Baiduspider', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Applebot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (compatible; Applebot/0.1; +https://www.apple.com/go/applebot)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Facebook bot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Twitterbot', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Twitterbot/1.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT detect Chrome', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect Firefox', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect Safari', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect Edge', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect mobile Safari', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty string', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined', async () => {
|
||||
const { isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('')).toBe(false);
|
||||
});
|
||||
|
||||
it('isSearchSpider should distinguish search from social bots', async () => {
|
||||
const { isSearchSpider, isSpider } = await import('./spider_detection');
|
||||
expect(isSpider('facebookexternalhit/1.1')).toBe(true);
|
||||
expect(isSearchSpider('facebookexternalhit/1.1')).toBe(false);
|
||||
expect(isSearchSpider('Mozilla/5.0 (compatible; Googlebot/2.1)')).toBe(true);
|
||||
});
|
||||
|
||||
it('getSpiderInfo should return SpiderInfo for known bots', async () => {
|
||||
const { getSpiderInfo } = await import('./spider_detection');
|
||||
const info = getSpiderInfo('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');
|
||||
expect(info).not.toBeNull();
|
||||
expect(info?.engine).toBe('Google');
|
||||
expect(info?.category).toBe('search');
|
||||
});
|
||||
|
||||
it('getSpiderInfo should return null for browsers', async () => {
|
||||
const { getSpiderInfo } = await import('./spider_detection');
|
||||
expect(getSpiderInfo('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0')).toBeNull();
|
||||
});
|
||||
|
||||
it('getUserAgent should extract UA from request headers', async () => {
|
||||
const { getUserAgent } = await import('./spider_detection');
|
||||
const headers: Record<string, string | string[] | undefined> = {
|
||||
'user-agent': 'TestBot/1.0',
|
||||
'host': 'example.com',
|
||||
};
|
||||
expect(getUserAgent(headers)).toBe('TestBot/1.0');
|
||||
});
|
||||
|
||||
it('getUserAgent should handle missing UA header', async () => {
|
||||
const { getUserAgent } = await import('./spider_detection');
|
||||
expect(getUserAgent({ host: 'example.com' })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app.server.routes.ts — RenderMode configuration', () => {
|
||||
it('should use RenderMode.Server for all routes (SSR server handles routing)', async () => {
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
|
||||
|
||||
// Every route should use RenderMode.Server
|
||||
const prerenderMatches = content.match(/RenderMode\.Prerender/g);
|
||||
expect(prerenderMatches).toBeNull();
|
||||
|
||||
// Should have RenderMode.Server entries
|
||||
const serverMatches = content.match(/RenderMode\.Server/g);
|
||||
expect(serverMatches).not.toBeNull();
|
||||
expect(serverMatches.length).toBeGreaterThanOrEqual(8);
|
||||
expect(serverMatches.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('should have no Prerender routes (eliminates stale baked-in DEFAULT_CONFIG)', async () => {
|
||||
|
||||
Reference in new issue
Block a user