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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Other commands:

### Calling the API from the frontend

The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 3001) and the frontend (`npm run dev`). In development Vite sends `/graphql` and `/images` to the backend, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; the default is `/graphql`. The app is built for the virtual machine with `VITE_API_URL=/project2/graphql npm run build`, because it is served under `/project2/` and Apache must send that address to the backend (see "Connect the frontend to the backend" in [docs/VM_SETUP.md](docs/VM_SETUP.md)).
The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 4000) and the frontend (`npm run dev`). In development Vite forwards `/project2/graphql` and `/project2/images` to the backend at `/graphql` and `/images`, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; by default the API address is the app base path followed by `graphql`. Image paths are likewise resolved under the app base path. See "Connect the frontend to the backend" in [docs/VM_SETUP.md](docs/VM_SETUP.md).

**Bundle size.** A current `npm run build` reports a JavaScript bundle of about 548 kB (171 kB gzip), above Vite's 500 kB warning threshold. The catalog JSON was removed from the frontend when the detail page moved to the API; the historical before/after sizes and possible improvements are described in [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md).

Expand Down Expand Up @@ -418,7 +418,7 @@ npm run backend # restarts when a file changes
npm run start -w backend # starts once
```

It listens on port 3001. Open http://localhost:3001/graphql in a browser to try queries in Apollo Sandbox, for example:
It listens on port 4000. Open http://localhost:4000/graphql in a browser to try queries in Apollo Sandbox, for example:

```graphql
{
Expand Down Expand Up @@ -452,8 +452,8 @@ Other addresses: `/images/<apparatus>/<file>.webp` serves the drawings in `data/

| Variable | Default | Used for |
| ---------------- | ------------------------------ | ------------------------------------------------------------------- |
| `PORT` | `3001` | The port of the server |
| `IMAGE_BASE_URL` | `http://localhost:3001/images` | The address the drawings are served from (`imageUrl` in the API) |
| `PORT` | `4000` | The port of the server |
| `IMAGE_BASE_URL` | `http://localhost:4000/images` | The address the drawings are served from (`imageUrl` in the API) |
| `CORS_ORIGIN` | `http://localhost:5173` | The addresses that may call the API from a browser, comma separated |
| `SESSION_SECRET` | none | Signs the access tokens, at least 32 random characters |

Expand Down Expand Up @@ -572,7 +572,7 @@ Settings, all optional, as environment variables:
| Variable | Meaning |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `E2E_BASE_URL` | An address that is already running, for example `http://it2810-29.idi.ntnu.no/project2`. Nothing is started, and the same tests check the real deployment (including the Apache proxy of `/project2/graphql`). |
| `E2E_FRONTEND_PORT`, `E2E_BACKEND_PORT` | The ports that the tests start the app on, 5173 and 3001 by default. Change them if another program uses them, or the tests would use that program. |
| `E2E_FRONTEND_PORT`, `E2E_BACKEND_PORT` | The ports that the tests start the app on, 5173 and 4000 by default. Change them if another program uses them, or the tests would use that program. |
| `E2E_CHANNEL` | A browser that is already installed, for example `msedge`, instead of the Chromium that Playwright downloads. |
| `E2E_API_URL` | The GraphQL address of `e2e/api.e2e.ts`, if it is not the one that follows from the others. |

Expand Down
6 changes: 3 additions & 3 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ DATABASE_URL="postgresql://wag_app:YOUR_APP_PASSWORD@localhost:5432/wag_catalog"
MIGRATION_DATABASE_URL="postgresql://wag_migrator:YOUR_MIGRATOR_PASSWORD@localhost:5432/wag_catalog"

# The GraphQL server (npm run dev -w backend). All three are optional.
# The port of the server. The backend should be on 3001 on the virtual machine.
# PORT=3001
# The port of the server. Vite and Apache proxy to this port.
# PORT=4000
# The address the drawings are served from, without a final slash. In development
# the server serves them itself. On the virtual machine it is the Apache address,
# for example http://it2810-XX.idi.ntnu.no/project2/images
# IMAGE_BASE_URL=http://localhost:3001/images
# IMAGE_BASE_URL=http://localhost:4000/images
# The addresses that may call the API from a browser, separated by commas.
# CORS_ORIGIN=http://localhost:5173

Expand Down
2 changes: 1 addition & 1 deletion backend/src/seed/demo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe.runIf(hasDatabase)('resetDemoCoach', () => {
contextValue: createContext(
prisma,
{
port: 3001,
port: 4000,
imageBaseUrl: 'http://images.test/images',
corsOrigins: [],
sessionSecret: 'a-secret-that-is-at-least-32-characters-long',
Expand Down
2 changes: 1 addition & 1 deletion backend/src/server/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const minutes = (n: number) => new Date(START.getTime() + n * 60 * 1000)
const days = (n: number) => minutes(n * 24 * 60)

const config: Config = {
port: 3001,
port: 4000,
imageBaseUrl: 'http://images.test/images',
corsOrigins: ['http://allowed.test'],
sessionSecret: 'a-secret-that-is-at-least-32-characters-long',
Expand Down
2 changes: 1 addition & 1 deletion backend/src/server/calculator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { MAX_CALCULATOR_IDS } from './resolvers/calculator.js'

const hasDatabase = Boolean(process.env['DATABASE_URL'])
const config: Config = {
port: 3001,
port: 4000,
imageBaseUrl: 'http://images.test/images',
corsOrigins: [],
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type Config = {

export const MIN_SESSION_SECRET_LENGTH = 32

export const DEFAULT_PORT = 3001
export const DEFAULT_PORT = 4000
export const DEFAULT_CORS_ORIGIN = 'http://localhost:5173' // the Vite dev server

/** Reads and checks the settings. A wrong value stops the start-up with a clear message. */
Expand Down
2 changes: 1 addition & 1 deletion backend/src/server/devLogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const EMAIL = 'test-98-dev-login@example.com'
const NOW = new Date('2026-10-27T10:00:00Z')

const base: Config = {
port: 3001,
port: 4000,
imageBaseUrl: 'http://images.test/images',
corsOrigins: ['http://allowed.test'],
sessionSecret: 'a-secret-that-is-at-least-32-characters-long',
Expand Down
2 changes: 1 addition & 1 deletion backend/src/server/errorCodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { MAX_QUERY_DEPTH } from './depthLimit.js'
import { INTERNAL_ERROR_MESSAGE } from './formatError.js'

const config: Config = {
port: 3001,
port: 4000,
imageBaseUrl: 'http://images.test/images',
corsOrigins: [],
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { loadTypeDefs } from './schema.js'
const hasDatabase = Boolean(process.env['DATABASE_URL'])

const config: Config = {
port: 3001,
port: 4000,
imageBaseUrl: 'http://images.test/images',
corsOrigins: ['http://allowed.test'],
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/server/userData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const PREFIX = 'test-73-'
const START = new Date('2026-10-17T10:00:00Z')

const config: Config = {
port: 3001,
port: 4000,
imageBaseUrl: 'http://images.test/images',
corsOrigins: [],
sessionSecret: 'a-secret-that-is-at-least-32-characters-long',
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ The settings are read from environment variables, and an example file with all n

| Variable | Used for |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | The port of the server, `3001` |
| `PORT` | The port of the server, `4000` |
| `DATABASE_URL` | The PostgreSQL connection for Prisma |
| `IMAGE_BASE_URL` | The address where the drawings are served (see Images) |
| `CORS_ORIGIN` | The addresses that may call the API from a browser |
Expand Down
25 changes: 12 additions & 13 deletions docs/VM_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ DATABASE_URL="postgresql://wag_app:YOUR_SECURE_APP_PASSWORD@localhost:5432/wag_c
# Migration script connection (wag_migrator)
MIGRATION_DATABASE_URL="postgresql://wag_migrator:YOUR_SECURE_MIGRATOR_PASSWORD@localhost:5432/wag_catalog"
PORT=3001
PORT=4000
NODE_ENV=production
SESSION_SECRET="your_production_session_secret"
```
Expand Down Expand Up @@ -103,9 +103,10 @@ _Note: The initial setup and database seeding have been successfully executed on

## 5. Connect the frontend to the backend

The frontend is served by Apache under `/project2/`, and the backend listens on port 3001. The browser must reach the backend on the same address as the frontend, so Apache sends `/project2/graphql` and `/project2/images` to the backend. This also means there is no CORS to set up.
The frontend is served by Apache under `/project2/`, and the backend listens on port 4000. The browser must reach the backend on the same address as the frontend, so Apache sends `/project2/graphql` and `/project2/images` to the backend. This also means there is no CORS to set up.

_Status: this configuration follows the Apache documentation for `mod_proxy`, but it has **not been tried on the VM yet**. Check it with the `curl` command below._
If Apache is not already proxying these paths on the VM, configure it as follows.
Check the result with the `curl` command below before deploying the frontend.

1. Enable the proxy modules and restart Apache:

Expand All @@ -118,23 +119,21 @@ _Status: this configuration follows the Apache documentation for `mod_proxy`, bu

```apache
ProxyRequests Off
ProxyPass /project2/graphql http://localhost:3001/graphql
ProxyPassReverse /project2/graphql http://localhost:3001/graphql
ProxyPass /project2/images http://localhost:3001/images
ProxyPassReverse /project2/images http://localhost:3001/images
ProxyPass /project2/graphql http://localhost:4000/graphql
ProxyPassReverse /project2/graphql http://localhost:4000/graphql
ProxyPass /project2/images http://localhost:4000/images
ProxyPassReverse /project2/images http://localhost:4000/images
```

`ProxyRequests Off` is the default and must stay off: with it on, Apache would work as an open proxy for anyone.

3. Build the frontend with the API address, and put `IMAGE_BASE_URL` in `backend/.env` (the drawings are then fetched through the same address):
3. Build the frontend. The API and image addresses use the `/project2/` base path by default; `VITE_API_URL` can override the GraphQL address if needed:

```bash
VITE_API_URL=/project2/graphql npm run build
npm run build
```

```env
IMAGE_BASE_URL=http://it2810-XX.idi.ntnu.no/project2/images
```
The frontend resolves image paths under `/project2/images/`, and Apache proxies them to the backend. `IMAGE_BASE_URL` is used to construct the API's `imageUrl`; the frontend maps its `/images/` path under the app base path.

4. Check that the backend answers through Apache:

Expand Down Expand Up @@ -164,7 +163,7 @@ The VM serves plain http, so Sign in with Google does not work there (Google onl
2. Build the frontend with the form turned on:

```bash
VITE_API_URL=/project2/graphql VITE_DEV_LOGIN=true npm run build
VITE_DEV_LOGIN=true npm run build
```

3. Make the demo coach, once the catalog is seeded (see "4. Run Migrations & Initial Seed"):
Expand Down
2 changes: 1 addition & 1 deletion e2e/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

/** The port of the frontend (Vite) and of the backend when the tests start them. */
export const frontendPort = Number(process.env['E2E_FRONTEND_PORT'] ?? 5173)
export const backendPort = Number(process.env['E2E_BACKEND_PORT'] ?? 3001)
export const backendPort = Number(process.env['E2E_BACKEND_PORT'] ?? 4000)

/** An address that is already running. Not set: the tests start the app themselves. */
export const externalBaseUrl = process.env['E2E_BASE_URL']?.replace(/\/+$/, '')
Expand Down
3 changes: 2 additions & 1 deletion src/components/elements/ElementDiagram.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Card, CardContent } from '@/components/ui/card'
import { displayCode, type ElementDetail } from '@/lib/catalog'
import { imageUrlForApp } from '@/lib/imageUrl'

export default function ElementDiagram({
element,
Expand All @@ -11,7 +12,7 @@ export default function ElementDiagram({
<Card className="w-full bg-(--background-color-element) p-0">
<CardContent className="p-0">
<img
src={element.imageUrl}
src={imageUrlForApp(element.imageUrl)}
alt={`Illustration for ${displayCode(element, element.variants.length > 0)}${element.name ? `: ${element.name}` : ''}`}
className="block h-auto max-h-[75svh] w-full object-contain"
/>
Expand Down
3 changes: 2 additions & 1 deletion src/components/elements/ResultsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import DifficultyBadge from '@/components/ui/DifficultyBadge'
import { getDifficultyLetter } from '@/lib/difficulty'
import { groupLabel, type ElementListRow } from '@/lib/elementList'
import { imageUrlForApp } from '@/lib/imageUrl'

// On a wide screen the results are a table. On a phone and a small screen (below
// the "md" breakpoint) they are cards below each other instead, because four
Expand Down Expand Up @@ -82,7 +83,7 @@ function ResultCards({ rows }: { rows: ElementListRow[] }) {
{/* The drawing is below the text. If it is missing, nothing is shown,
and not a broken image. */}
<img
src={row.imageUrl}
src={imageUrlForApp(row.imageUrl)}
alt={`Illustration for ${row.code}${row.name ? `: ${row.name}` : ''}`}
loading="lazy"
decoding="async"
Expand Down
10 changes: 5 additions & 5 deletions src/lib/apollo/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// The Apollo Client of the app, and the functions for the session. The address
// of the API is VITE_API_URL (default "/graphql"). In development Vite sends
// "/graphql" to the backend on port 3001 (vite.config.ts), so the browser sees
// one address and there is no CORS to set up.
// The Apollo Client of the app, and the functions for the session. The API
// address is VITE_API_URL or, by default, the GraphQL endpoint under the app's
// base path. In development Vite forwards it to the backend.

import { ApolloClient } from '@apollo/client'
import { HttpLink } from '@apollo/client/link/http'
Expand All @@ -17,7 +16,8 @@ import { createAuthLink } from './authLink'
import { createCache } from './cache'
import { type Session, type TokenStorage, createSession } from './session'

export const API_URL: string = import.meta.env.VITE_API_URL ?? '/graphql'
export const API_URL: string =
import.meta.env.VITE_API_URL ?? `${import.meta.env.BASE_URL}graphql`

export function createApolloClient(options: {
uri: string
Expand Down
21 changes: 21 additions & 0 deletions src/lib/imageUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { imageUrlForApp } from './imageUrl'

describe('imageUrlForApp', () => {
it('resolves backend image paths under the app base path', () => {
expect(
imageUrlForApp('http://localhost:4000/images/UB/UB-1.101-a.webp'),
).toBe(`${import.meta.env.BASE_URL}images/UB/UB-1.101-a.webp`)
})

it('preserves query strings and fragments', () => {
expect(imageUrlForApp('/images/VT/VT-1.webp?size=small#preview')).toBe(
`${import.meta.env.BASE_URL}images/VT/VT-1.webp?size=small#preview`,
)
})

it('leaves URLs outside the backend images route unchanged', () => {
const externalImage = 'https://images.example/UB/UB-1.webp'
expect(imageUrlForApp(externalImage)).toBe(externalImage)
})
})
19 changes: 19 additions & 0 deletions src/lib/imageUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const IMAGE_PATH = '/images/'

/**
* Route backend image URLs through the frontend's base path. The backend URL
* still supplies the apparatus and filename, while the app base may differ
* between environments.
*/
export function imageUrlForApp(imageUrl: string): string {
const suffixIndex = imageUrl.search(/[?#]/)
const path = suffixIndex === -1 ? imageUrl : imageUrl.slice(0, suffixIndex)
const imagePathIndex = path.lastIndexOf(IMAGE_PATH)
if (imagePathIndex === -1) return imageUrl

const imageName = path.slice(imagePathIndex + IMAGE_PATH.length)
if (!imageName) return imageUrl

const suffix = suffixIndex === -1 ? '' : imageUrl.slice(suffixIndex)
return `${import.meta.env.BASE_URL}images/${imageName}${suffix}`
}
2 changes: 1 addition & 1 deletion src/tests/components/elements/ElementActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const element: ElementDetail = {
apparatusName: 'Uneven bars',
elementGroupName: 'Casts and clear hip circles',
elementGroupNumber: 2,
imageUrl: 'http://localhost:3001/images/UB/UB-2.305-a.webp',
imageUrl: 'http://localhost:4000/images/UB/UB-2.305-a.webp',
removed: false,
variants: [],
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const element: ElementDetail = {
apparatusName: 'Uneven bars',
elementGroupName: 'Giant circles',
elementGroupNumber: 3,
imageUrl: 'http://localhost:3001/images/UB/UB-2.305-a.webp',
imageUrl: 'http://localhost:4000/images/UB/UB-2.305-a.webp',
removed: false,
variants: [],
}
Expand Down
2 changes: 1 addition & 1 deletion src/tests/lib/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { CatalogItemResult } from '@/lib/catalogOperations'

const shared = {
name: null,
imageUrl: 'http://localhost:3001/images/UB/UB-2.305-a.webp',
imageUrl: 'http://localhost:4000/images/UB/UB-2.305-a.webp',
removed: false,
elementGroup: {
id: 'UB-2',
Expand Down
15 changes: 10 additions & 5 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ export default defineConfig({
tailwindcss(),
],
server: {
// In development the browser calls the backend through Vite, so the app
// and the API have the same address and CORS is not needed. The backend
// listens on port 3001 (npm run backend).
// Keep requests under the app's base path in development and production.
// Vite strips the prefix before forwarding to the backend.
proxy: {
'/graphql': 'http://localhost:3001',
'/images': 'http://localhost:3001',
'/project2/graphql': {
target: 'http://localhost:4000',
rewrite: (path) => path.replace(/^\/project2/, ''),
},
'/project2/images': {
target: 'http://localhost:4000',
rewrite: (path) => path.replace(/^\/project2/, ''),
},
},
},
resolve: {
Expand Down