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
52 changes: 49 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
29 changes: 14 additions & 15 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 11 additions & 12 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 }}
Expand Down
99 changes: 99 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <target>` 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 -- <name-pattern>` (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 `<!--app-head-->`, `<!--app-html-->`, a theme class on `<html>`, 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.
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion apps/web/Dockerfile → Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading