diff --git a/.env.example b/.env.example index 00c3de9..4e2a3e8 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,54 @@ +# --------------------------------------------------------------------------- +# Supabase project +# --------------------------------------------------------------------------- +# Local-only: consumed by `pnpm --filter app-backend generate-types` (supabase gen types). +# Not needed at build or runtime, so CI does not set it. SUPABASE_PROJECT_ID= -VITE_SUPABASE_PROJECT_URL= -VITE_SUPABASE_PROJECT_KEY= -; S3 Access key to static content +# Read by apps/backend, by the apps/web SSR server, and — through window.CLIENT — by the +# browser. One pair for all three: everything reads them from the environment at runtime, +# so a rotated key takes effect on the next request rather than the next build. +# +# SUPABASE_ANON_KEY is deliberately the *anon* key. It is public by design: browsers +# authenticate with it and Row Level Security, not secrecy, decides what a caller may read. +# Every backend query runs through a client bound to the caller's JWT so RLS applies. A +# service role key here would bypass RLS entirely, which is why nothing asks for one. +# +# The backend validates both at startup and refuses to boot without them. In production +# they are injected into the Serverless Container via `--environment` +# (see .github/workflows/deploy.yml), never baked into the image. +SUPABASE_PROJECT_URL= +SUPABASE_ANON_KEY= + +# --------------------------------------------------------------------------- +# Ports +# --------------------------------------------------------------------------- +# Express SSR server (apps/web). +PORT=5173 +# NestJS backend (apps/backend). apps/web proxies /api/* here. +BACKEND_PORT=3001 + +# --------------------------------------------------------------------------- +# Backend container watcher (optional) +# --------------------------------------------------------------------------- +# The backend in docker-compose.dev.yml watches bind-mounted sources, and containers get +# inotify events only for files stored in the Linux filesystem — so it polls by default. +# On a Linux host, inotify works and polling is pure overhead: +# TSC_WATCHFILE=UseFsEvents +# TSC_WATCHDIRECTORY=UseFsEvents + +# --------------------------------------------------------------------------- +# Backend hardening (optional — sensible defaults apply when unset) +# --------------------------------------------------------------------------- +# Comma-separated CORS allowlist. Defaults to http://localhost:5173. +CORS_ORIGINS=http://localhost:5173 +# Rate limit window in ms and max requests per window per IP. Defaults: 60000 / 120. +THROTTLE_TTL=60000 +THROTTLE_LIMIT=120 + +# --------------------------------------------------------------------------- +# S3 Access key to static content +# --------------------------------------------------------------------------- AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=ru-central1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b2d4858..fcfd376 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,8 +11,6 @@ on: S3_SECRET_KEY: required: true description: S3 secret key - SUPABASE_PROJECT_ID: - required: true SUPABASE_PROJECT_URL: required: true SUPABASE_PROJECT_KEY: @@ -77,12 +75,13 @@ jobs: version: 2.22.35 arch: amd64 - - name: Fill .env - run: | - cp .env.example .env - echo "SUPABASE_PROJECT_ID=${{ secrets.SUPABASE_PROJECT_ID }}" >> .env - echo "VITE_SUPABASE_PROJECT_URL=${{ secrets.SUPABASE_PROJECT_URL }}" >> .env - echo "VITE_SUPABASE_PROJECT_KEY=${{ secrets.SUPABASE_PROJECT_KEY }}" >> .env + # The build needs no Supabase credentials: nothing is inlined into the bundle any + # more, and both the SSR server and the browser read the values injected into the + # container revision at runtime (see the --environment flags below). The image still + # needs a .env to exist because the Dockerfile copies one in, so seed it from the + # example — the runtime --environment values override its empty entries. + - name: Seed .env for the image + run: cp .env.example .env - name: Fetch bundle-stats (baseline) to compare with if: inputs.main == false @@ -95,12 +94,10 @@ jobs: - name: Create Docker Image if: inputs.main == false - working-directory: apps/web run: pnpm run build:docker - name: Create Docker Image (main) if: inputs.main == true - working-directory: apps/web run: pnpm run build:baseline:docker - name: Extract bundle-stats @@ -171,12 +168,13 @@ jobs: --container-name ${{ inputs.revision_name }} \ --image "${{ steps.push_docker_image.outputs.docker_image_tag }}" \ --cores 1 \ - --memory 512mb \ + --memory 1536mb \ --concurrency 1 \ --execution-timeout 30s \ --cloud-id ${{ inputs.cloud_id }} \ --folder-name "${{ inputs.folder_name }}" \ - --service-account-id ${{ inputs.sa_deployer_id }} + --service-account-id ${{ inputs.sa_deployer_id }} \ + --environment SUPABASE_PROJECT_URL=${{ secrets.SUPABASE_PROJECT_URL }},SUPABASE_ANON_KEY=${{ secrets.SUPABASE_PROJECT_KEY }},NODE_ENV=production - name: Deploy Container Revision (main) if: inputs.main == true @@ -185,13 +183,14 @@ jobs: --container-name ${{ inputs.revision_name }} \ --image "${{ steps.push_docker_image.outputs.docker_image_tag }}" \ --cores 2 \ - --memory 1gb \ + --memory 1536mb \ --concurrency 10 \ --execution-timeout 30s \ --cloud-id ${{ inputs.cloud_id }} \ --folder-name "${{ inputs.folder_name }}" \ --service-account-id ${{ inputs.sa_deployer_id }} \ - --min-instances 1 + --min-instances 1 \ + --environment SUPABASE_PROJECT_URL=${{ secrets.SUPABASE_PROJECT_URL }},SUPABASE_ANON_KEY=${{ secrets.SUPABASE_PROJECT_KEY }},NODE_ENV=production - name: Configure Api Gateway id: configure_api_gateway @@ -206,7 +205,7 @@ jobs: gw_info=$(yc serverless api-gateway get \ --name ${{ inputs.revision_name }} \ --cloud-id ${{ inputs.cloud_id }} \ - --folder-name "${{ inputs.folder_name }}" + --folder-name "${{ inputs.folder_name }}" \ --format json 2>/dev/null || true) if [ -n "$gw_info" ]; then diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cad8e03..8115b2a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,7 +23,6 @@ jobs: SA_DEPLOYER_PRIVATE_KEY: ${{ secrets.SA_STAGING_DEPLOYER_PRIVATE_KEY }} S3_KEY_ID: ${{ secrets.S3_KEY_ID }} S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} - SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} SUPABASE_PROJECT_URL: ${{ secrets.SUPABASE_PROJECT_URL }} SUPABASE_PROJECT_KEY: ${{ secrets.SUPABASE_PROJECT_KEY }} with: diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0aa8977..d61ce96 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,12 +14,12 @@ jobs: name: "pnpm install" uses: ./.github/workflows/pnpm-install.yml - check-web-affected: - name: "Check if app-web is affected" + check-deploy-affected: + name: "Check if a deployable app is affected" runs-on: ubuntu-latest needs: npm-ci outputs: - web-affected: ${{ steps.check.outputs.web-affected }} + deploy-affected: ${{ steps.check.outputs.deploy-affected }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -36,25 +36,24 @@ jobs: - name: Derive SHAs for base and head uses: nrwl/nx-set-shas@v4 - - name: Check if app-web is affected + - name: Check if a deployable app is affected id: check run: | AFFECTED=$(pnpm nx show projects --affected --json) - WEB_AFFECTED=$(echo "$AFFECTED" | jq -r 'any(. == "app-web")') - echo "web-affected=$WEB_AFFECTED" >> $GITHUB_OUTPUT + DEPLOY_AFFECTED=$(echo "$AFFECTED" | jq -r 'any(. == "app-web" or . == "app-backend")') + echo "deploy-affected=$DEPLOY_AFFECTED" >> $GITHUB_OUTPUT echo "Affected projects: $AFFECTED" - echo "app-web affected: $WEB_AFFECTED" + echo "deployable app affected: $DEPLOY_AFFECTED" deploy: name: "Deploy staging" uses: ./.github/workflows/deploy.yml - needs: [npm-ci, check-web-affected] - if: needs.check-web-affected.outputs.web-affected == 'true' + needs: [npm-ci, check-deploy-affected] + if: needs.check-deploy-affected.outputs.deploy-affected == 'true' secrets: SA_DEPLOYER_PRIVATE_KEY: ${{ secrets.SA_STAGING_DEPLOYER_PRIVATE_KEY }} S3_KEY_ID: ${{ secrets.S3_KEY_ID }} S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} - SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} SUPABASE_PROJECT_URL: ${{ secrets.SUPABASE_PROJECT_URL }} SUPABASE_PROJECT_KEY: ${{ secrets.SUPABASE_PROJECT_KEY }} with: @@ -69,8 +68,8 @@ jobs: bundle-stats: name: "Bundle stats" uses: ./.github/workflows/bundle-stats.yml - needs: [deploy, check-web-affected] - if: needs.check-web-affected.outputs.web-affected == 'true' + needs: [deploy, check-deploy-affected] + if: needs.check-deploy-affected.outputs.deploy-affected == 'true' secrets: S3_KEY_ID: ${{ secrets.S3_KEY_ID }} S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1968407 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository + +Nx + pnpm monorepo for **Languages Learner**, a language-learning web app. Workspaces are `apps/*` and `packages/*` (see `pnpm-workspace.yaml`). Node 20 (`.nvmrc`), pnpm 10.6.2 via Corepack. + +`nx.json` is intentionally minimal — Nx infers targets from each package's `package.json` scripts. Root scripts are `nx run-many -t ` fan-outs, so a target only runs where the package defines it. + +## Commands + +Root (all workspaces): + +```bash +pnpm lint # nx run-many -t lint +pnpm typecheck # nx run-many -t typecheck +pnpm test:unit # vitest watch; pnpm test:unit:ci for a single run +pnpm stylelint +pnpm circular-deps # madge, UI code only +pnpm knip # unused files/exports/deps +pnpm deps:check # syncpack — dependency versions consistent across packages +``` + +Single package/app — use pnpm filters with the **package name**, not the folder: + +```bash +pnpm --filter app-web dev # apps/web +pnpm --filter app-backend start:dev # apps/backend +pnpm --filter @languages-learner/uikit typecheck +``` + +Full local stack: `pnpm dev` at the root — starts the backend container from `docker-compose.dev.yml` (detached), then runs `apps/web` natively in the foreground. `pnpm dev:logs` follows the backend, `pnpm dev:build` rebuilds its image after a dependency change, `pnpm dev:down` stops it. + +**`apps/web` is deliberately not containerised.** Bind-mounted reads cross the host boundary at milliseconds per file, which Vite pays thousands of times per render, and containers get inotify events only for files in the Linux filesystem, so the watcher would have to poll. Both costs are invisible for the backend and severe for Vite. Do not "complete" the compose file by adding a web service. + +Single unit test: only `packages/class-names` and `packages/error-utils` define `test:unit`. Run e.g. `pnpm --filter @languages-learner/class-names test:unit -- ` (vitest). Root `vitest.config.ts` only picks up `**/*.test.ts`. + +Component tests (Playwright CT, uikit only) run inside Docker for stable screenshots: + +```bash +pnpm --filter @languages-learner/uikit test:component:docker +pnpm --filter @languages-learner/uikit test:component:update:docker # update snapshots +``` + +Storybook: `pnpm --filter @languages-learner/storybook storybook` (port 6006). + +## Architecture + +### apps/web — custom SSR, not a framework + +Two independent TypeScript projects under `src/`, each with its own `tsconfig.json` and its own build: + +- `src/server/` — Express server (`src/server/main.ts`), built by `tsx`/nodemon in dev. +- `src/ui/` — React app, built by Vite twice: `vite build` (client) and `vite build --ssr src/ui/app/entries/main-server.tsx` (server bundle). + +The server is the SSR orchestrator. In development it runs Vite in `middlewareMode` and loads the render function through `vite.ssrLoadModule`; in production it reads the prebuilt HTML shell and imports `dist/server/main-server.mjs`. It then string-replaces ``, ``, a theme class on ``, and `window.CLIENT = {}` with `res.locals`. + +**`res.locals` is the SSR contract.** Express middlewares (`src/server/middlewares/`: `supabaseConfig`, `user`, `locale`, `theme`) populate it, `render(res.locals)` consumes it, and the same object is serialized into `window.CLIENT` for client hydration. Adding server-derived state means touching all three points — plus `Locals` in `src/server/typings.d.ts`, and `Window.CLIENT` in `src/vite-env.d.ts` for anything the browser reads. + +**Supabase credentials are runtime config, not build config.** `SUPABASE_PROJECT_URL` / `SUPABASE_ANON_KEY` are read from the environment by `src/shared/supabase-config.ts` and reach the browser through `window.CLIENT` — nothing is inlined into the bundle, so one image serves every environment and a rotated key applies on the next request. Do not reintroduce `VITE_`-prefixed copies: that would bake credentials into the bundle at build time and silently desynchronise the SSR server from the backend, which reads the runtime values. + +`/api/*` is proxied to the NestJS backend (`BACKEND_PORT`, default 3001) — the proxy middleware must stay registered before Vite's middlewares. + +### apps/web UI layers (Feature-Sliced Design) + +`src/ui/` follows FSD: `app` → `pages` → `widgets` → `features` → `entities` → `shared`. Imports flow downward only; circular imports are enforced by `madge` (`circular-deps:ui`). Path aliases inside `src/ui`: `@/*` → `src/ui/*`, `@@/*` → repo root, `shared/*` → `src/shared/*` (cross-cutting, server+ui), `locales/*` → `src/locales/*`. The server project deliberately has no aliases yet (relative paths only). + +### Data layer + +Two coexisting systems: **TanStack Query** wrapped by `@normy/react-query`'s `QueryNormalizerProvider` for normalized cache updates, and **Gravity UI DataSource** via the shared `dataManager` from `@languages-learner/data-source` (`DataManagerContext`). Check which one a slice already uses before adding data fetching. + +### apps/backend — NestJS + +Standard Nest module layout (`words`, `user`, `auth`, `supabase`, `config`, `common`). `SupabaseService` is the only place clients are created: `getAuthClient()` verifies tokens, `getClientForUser(token)` returns an RLS-scoped client for the caller. There is deliberately **no service-role key** — a service-role client would bypass RLS on every request. Auth is a global `AuthGuard` (opt out with `@Public()`) that attaches `{ id, email, accessToken }`, read in handlers via `@CurrentUser()`. + +**Keep this provider graph free of `Scope.REQUEST`, and never authenticate inside a provider factory.** A request-scoped Supabase client used to throw `UnauthorizedException` from its factory; because Nest resolves request-scoped providers before guards run, that throw short-circuited the guard chain and the global `ThrottlerGuard` never executed on protected routes. The 401 looked correct, so nothing surfaced it. Covered by `apps/backend/test/rate-limit.e2e.test.ts`. + +Env is validated at startup (`src/config/env.validation.ts`); missing vars exit the process with a readable message instead of failing on the first request. See `apps/backend/README.md`. + +Database types are generated, not hand-written: `generate-types` in `apps/backend` regenerates `database.types.ts` and copies it into `packages/api`. Do not edit those files, and note `lint` runs with `--fix` — the generated types are eslint-ignored so it cannot reformat them. + +**The API contract is generated too.** Backend DTOs → Swagger → `packages/api/src/schemas/openapi.json` → `api.ts` → SDK → `apps/web`. After changing a DTO or a handler's return type, run `pnpm --filter app-backend generate:api-schemas` (needs a valid root `.env`) and commit the regenerated schemas, otherwise `apps/web` typechecks against a stale contract. + +### i18n + +FormatJS + react-intl with **hash-based message IDs** — `formatjs/enforce-id` (ESLint) requires the `[sha512:contenthash:base64:6]` pattern, so IDs are generated, never authored. Workflow: `pnpm --filter app-web i18n:extract` → `src/locales/extracted.json`, then `i18n:manage` extracts, syncs `en.json`/`ru.json`, and compiles into `src/locales/compiled/` (which the app imports directly). Compiled locales are a build input for typecheck too. + +## Conventions + +- Prettier: 4-space indent, 100 columns, trailing commas, LF line endings (`linebreak-style` is an ESLint error on Windows too). Tailwind class sorting via `prettier-plugin-tailwindcss`. +- Type imports must be inline (`import { type Foo }` style enforced by `@typescript-eslint/consistent-type-imports` with `fixStyle: "inline-type-imports"`). +- `newline-before-return` is an error. +- PR titles must be conventional/semantic (`feat:`, `fix:`, `chore:` …) — enforced by `.github/workflows/check-pr-title.yml`. +- Each app and package has its own `README.md` listing its scripts; check it before inventing commands. + +## Notes + +The public tree ships no database migrations and no turnkey local backend — API-dependent work needs your own Supabase project (see `.env.example`) or mocks. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e2d40f..1c7a162 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,21 @@ Thank you for your interest in improving Languages Learner. pnpm install ``` +To run the web app and the API together, copy `.env.example` to `.env`, fill in the Supabase values, and: + +```bash +pnpm dev # http://localhost:5173, API on http://localhost:3001 +pnpm dev:logs # follow the backend container's output +pnpm dev:build # rebuild the backend image after a dependency change +pnpm dev:down # stop the backend container +``` + +`pnpm dev` starts the backend in Docker ([`docker-compose.dev.yml`](docker-compose.dev.yml)) and then runs `apps/web` natively in the foreground. Stopping it (Ctrl+C) leaves the backend running; `pnpm dev:down` stops that too. + +**Why the web app is not containerised.** A container reads bind-mounted sources across the host boundary, which costs milliseconds per file rather than microseconds — Vite pays that thousands of times per render, so dev startup and HMR become slow enough to hurt. Containers also receive inotify events only for files stored in the Linux filesystem, so a bind-mounted host directory forces the watcher into polling, adding constant `stat()` traffic over the same slow boundary. The backend reads files rarely enough that neither cost is noticeable, so it stays in Docker where its runtime is pinned. (On a Linux host both costs disappear; see [Docker's WSL 2 best practices](https://docs.docker.com/desktop/features/wsl/best-practices/) for the underlying rules.) + +Each service also runs on its own — `pnpm --filter app-web dev` and `pnpm --filter app-backend start:dev`. + The public tree does **not** ship database migrations or a guaranteed-local backend. Use your own Supabase project (or mocks) when your change requires API access. ## Checks before a pull request diff --git a/apps/web/Dockerfile b/Dockerfile similarity index 69% rename from apps/web/Dockerfile rename to Dockerfile index b3cfc9a..c2a52cd 100644 --- a/apps/web/Dockerfile +++ b/Dockerfile @@ -28,10 +28,15 @@ RUN pnpm install --frozen-lockfile RUN echo "BUNDLE_STATS_BASELINE is $BUNDLE_STATS_BASELINE" RUN pnpm --filter app-web run build +RUN pnpm --filter app-backend run build EXPOSE 8080 ENV PORT=8080 +ENV BACKEND_PORT=3001 ENV NODE_ENV=production -CMD ["pnpm", "--filter", "app-web", "run", "preview"] +# Start both backend and frontend servers (backend on 3001, frontend on 8080). +# `wait -n` returns as soon as either process exits, and `exit 1` tears the +# container down so the platform replaces it instead of keeping a half-dead one. +CMD ["sh", "-c", "PORT=3001 pnpm --filter app-backend run start:prod & PORT=8080 pnpm --filter app-web run start:prod & wait -n; exit 1"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..c5e3862 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,19 @@ +FROM node:20-alpine + +WORKDIR /app + +# Corepack picks pnpm up from the root package.json "packageManager" field, so the +# container runs exactly the version the workspace is locked to. +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + +# The whole workspace is copied because pnpm needs every package.json to resolve the +# workspace links. .dockerignore already strips node_modules and build output. +COPY . . + +RUN corepack enable && pnpm install --frozen-lockfile + +EXPOSE 3001 + +# Nothing is built here: sources are bind-mounted by docker-compose.dev.yml and the +# server runs in watch mode. +CMD ["pnpm", "--filter", "app-backend", "run", "start:dev"] diff --git a/README.md b/README.md index c2d141d..33677a9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ This repository is shared as a **reference implementation**: it does **not** inc | Path | Purpose | | ---------------------------------- | ------------------------------------------------------ | | [`apps/web`](apps/web) | Main SSR web application | +| [`apps/backend`](apps/backend) | NestJS API behind `/api/*` | | [`apps/storybook`](apps/storybook) | Storybook instance for UI documentation | | [`apps/web-e2e`](apps/web-e2e) | End-to-end / integration test package | | [`packages/*`](packages) | Shared libraries (API client, UI kit, utilities, etc.) | diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..38d755f --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,104 @@ +# app-backend + +NestJS API for **Languages Learner**. Serves `/api/*`, verifies Supabase JWTs, and executes +every database query through a client bound to the caller's token so that Row Level Security +decides what the caller can see. + +`apps/web` proxies `/api/*` to this service, so browsers normally never talk to it directly. + +## Scripts + +Run from the monorepo root with `pnpm --filter app-backend