Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,73 @@ jobs:
- uses: actions/checkout@v4
- name: Build all images
run: NODE_VERSION=$(cat .nvmrc) docker compose build

# ── 6. Bundle-size budget (size-limit on the built frontend) ─────────────────
bundle-size:
name: Bundle Size Budget
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
- run: npm ci
- name: Build frontend
run: npm run -w frontend build
- name: Check bundle-size budget
run: npm run -w frontend size

# ── 7. Lighthouse benchmarks (SEO / a11y / best-practices / perf gate) ───────
lighthouse:
name: Lighthouse Benchmarks
needs: e2e
runs-on: ubuntu-latest
env:
JWT_ACCESS_SECRET: e2e_ci_access_secret_32chars_long
JWT_REFRESH_SECRET: e2e_ci_refresh_secret_32chars_long
LH_BASE_URL: http://localhost:18080
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
- run: npm ci

- name: Start Dockerized stack
env:
FRONTEND_PORT: "18080"
WEB_ORIGIN: "http://localhost:18080"
run: |
NODE_VERSION=$(cat .nvmrc) docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build --wait
timeout-minutes: 15

- name: Dump stack logs on failure
if: failure()
run: |
NODE_VERSION=$(cat .nvmrc) FRONTEND_PORT=18080 WEB_ORIGIN=http://localhost:18080 \
docker compose -f docker-compose.yml -f docker-compose.e2e.yml ps -a || true
docker logs tweeter-backend-1 --tail 400 2>&1 || true

- name: Run database migrations
run: |
docker exec -w /app/backend tweeter-backend-1 \
npx typeorm migration:run -d dist/infra/database/data-source.js

- name: Seed public content for Lighthouse
run: node scripts/seed-lighthouse.mjs

- name: Run Lighthouse CI
run: npx lhci autorun

- uses: actions/upload-artifact@v4
if: always()
with:
name: lighthouse-results
path: .lighthouseci/

- name: Tear down stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,11 @@ tests/e2e/tour/.auth/
playwright-report/
test-results/
tests/node_modules

# Lighthouse CI run artifacts
.lighthouseci/
.lighthouse-urls.json

# Local verification artifacts
.playwright-mcp/
a11y-*.png
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,17 @@ make test-e2e # boots the stack, runs migrations, runs Playwright, te
# Per-package, if you prefer:
npm run test -w backend # npm run test:coverage -w backend for coverage
npm run test -w frontend

# SEO / quality benchmarks (Lighthouse + bundle-size)
npm run -w frontend size # bundle-size budget (size-limit)
node scripts/seed-lighthouse.mjs # seed a public profile + post (stack must be up)
npx lhci autorun # Lighthouse: SEO / a11y / best-practices / perf
```

Coverage is gated at **80% lines** in CI. The pipeline runs the layers in order —
`lint → unit → integration → e2e → build` (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
`lint → unit → integration → e2e → build`, plus a **bundle-size** budget and a
**Lighthouse** gate (SEO 100, a11y/best-practices ≥ 90, performance reported) — see
[.github/workflows/ci.yml](.github/workflows/ci.yml) and [ADR-0011](docs/adr/0011-seo-benchmark-gates.md).

## 🗄️ Migrations

Expand All @@ -150,6 +157,7 @@ The load-bearing engineering decisions are recorded as ADRs (see the [decision l
- **Secure sessions** — a short-lived access JWT (in memory) plus a **rotating** refresh token in an httpOnly/Secure/SameSite cookie, with a Redis denylist enforced in the auth guard so logout revokes immediately. → [ADR-0003](docs/adr/0003-auth-strategy.md), [ADR-0009](docs/adr/0009-session-revocation-denylist.md)
- **Media pipeline** — presigned direct-to-storage uploads (MinIO/S3), async processing into variants, then attachment to posts.
- **Search without extra infrastructure** — PostgreSQL full-text search + `pg_trgm` fuzzy matching behind a `SearchPort`, swappable for a dedicated engine later. → [ADR-0005](docs/adr/0005-search-approach.md)
- **SEO without an SSR rewrite** — public profiles/posts are crawlable; nginx routes social scrapers (which don't run JS) to a backend **dynamic-rendering** layer that emits Open Graph / Twitter Card / JSON-LD, plus a DB-backed `sitemap.xml` and host-aware `robots.txt`. Per-route metadata for the human/Googlebot path uses **React 19 native metadata** (no extra dependency). → [ADR-0010](docs/adr/0010-seo-crawlability-and-dynamic-rendering.md)

## 📂 Project structure

Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
import { SearchModule } from './modules/search/search.module';
import { HashtagsModule } from './modules/hashtags/hashtags.module';
import { ReportsModule } from './modules/reports/reports.module';
import { SeoModule } from './modules/seo/seo.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
import { NOTIFICATION_PORT } from './modules/users/notification.port';
Expand Down Expand Up @@ -56,6 +57,7 @@ import { ViewerFlagsAdapter } from './modules/engagement/viewer-flags.adapter';
HashtagsModule,
SearchModule,
ReportsModule,
SeoModule,
],
providers: [
{
Expand Down
67 changes: 67 additions & 0 deletions backend/src/modules/seo/seo.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Controller, Get, Headers, Res } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { SeoService } from './seo.service';

/**
* SeoController — crawler-facing endpoints.
*
* GET /api/v1/seo/prerender ← nginx routes bot requests for public content
* routes here (X-Original-URI carries the path)
* GET /api/v1/seo/sitemap.xml ← nginx maps /sitemap.xml here
* GET /api/v1/seo/robots.txt ← nginx maps /robots.txt here
*
* No auth guard: these are public by design and only ever reached via nginx.
*/
@Controller('seo')
export class SeoController {
constructor(private readonly seoService: SeoService) {}

private baseUrl(
proto: string | undefined,
fwdHost: string | undefined,
host: string | undefined,
): string {
const scheme = (proto ?? 'http').split(',')[0].trim() || 'http';
const h = (fwdHost ?? host ?? 'localhost').split(',')[0].trim() || 'localhost';
return `${scheme}://${h}`;
}

@Get('prerender')
async prerender(
@Headers('x-original-uri') originalUri: string | undefined,
@Headers('x-forwarded-proto') proto: string | undefined,
@Headers('x-forwarded-host') fwdHost: string | undefined,
@Headers('host') host: string | undefined,
@Res({ passthrough: true }) reply: FastifyReply,
): Promise<string> {
const baseUrl = this.baseUrl(proto, fwdHost, host);
const { html, status } = await this.seoService.renderPath(originalUri ?? '/', baseUrl);
void reply.status(status).type('text/html; charset=utf-8');
return html;
}

@Get('sitemap.xml')
async sitemap(
@Headers('x-forwarded-proto') proto: string | undefined,
@Headers('x-forwarded-host') fwdHost: string | undefined,
@Headers('host') host: string | undefined,
@Res({ passthrough: true }) reply: FastifyReply,
): Promise<string> {
const baseUrl = this.baseUrl(proto, fwdHost, host);
const xml = await this.seoService.buildSitemap(baseUrl);
void reply.type('application/xml; charset=utf-8');
return xml;
}

@Get('robots.txt')
robots(
@Headers('x-forwarded-proto') proto: string | undefined,
@Headers('x-forwarded-host') fwdHost: string | undefined,
@Headers('host') host: string | undefined,
@Res({ passthrough: true }) reply: FastifyReply,
): string {
const baseUrl = this.baseUrl(proto, fwdHost, host);
void reply.type('text/plain; charset=utf-8');
return this.seoService.buildRobots(baseUrl);
}
}
19 changes: 19 additions & 0 deletions backend/src/modules/seo/seo.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { PostsModule } from '../posts/posts.module';
import { UsersModule } from '../users/users.module';
import { SeoController } from './seo.controller';
import { SeoService } from './seo.service';

/**
* SeoModule — server-rendered crawler artifacts (dynamic rendering for
* social scrapers, sitemap.xml, robots.txt).
*
* Imports PostsModule + UsersModule for PostsService/UsersService and the
* re-exported Post/User repositories (via their exported TypeOrmModule).
*/
@Module({
imports: [PostsModule, UsersModule],
controllers: [SeoController],
providers: [SeoService],
})
export class SeoModule {}
Loading
Loading