feat: initial commit
scaffolding
This commit is contained in:
commit
8143ba02b0
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
backend/bin/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.task/
|
||||||
2
.tool-versions
Normal file
2
.tool-versions
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
nodejs 24.16.0
|
||||||
|
golang 1.27.2
|
||||||
137
README.md
Normal file
137
README.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Subalineado
|
||||||
|
|
||||||
|
Go API and SolidJS + TypeScript frontend in one repository. No database is required.
|
||||||
|
|
||||||
|
## Start in development
|
||||||
|
|
||||||
|
Install Docker with Compose **2.32+** and [Task v3](https://taskfile.dev/docs/installation). Task is a standalone binary. With Node installed, use `npm install --global @go-task/cli`; other installation options are linked above. Then run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
task dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:5173. The API is at http://localhost:8080.
|
||||||
|
If a port is occupied, prefix the command with `API_PORT=18080 DEV_WEB_PORT=15173` (or `WEB_PORT=13000` for production).
|
||||||
|
|
||||||
|
Ctrl+C stops both services; remove their containers with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
task dev:down
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose Watch syncs frontend source into Vite for hot reload and rebuilds the Go service on changes. Dependency and Vite configuration changes rebuild the frontend. Go module and compilation caches persist through BuildKit cache mounts. After a broken Go build, fix and save the source to trigger another build. Dockerfile changes require restarting with `--build`.
|
||||||
|
|
||||||
|
## Discover and extend commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
task # List commands with descriptions
|
||||||
|
task --list # Same list, explicitly
|
||||||
|
task --summary dev # Read usage details for development
|
||||||
|
task dev:status # See running services
|
||||||
|
task dev:logs # Follow their logs
|
||||||
|
task install # Install native Go and frontend dependencies
|
||||||
|
task check # Go vet, Biome and TypeScript checks
|
||||||
|
task test # Go race tests and Vitest tests
|
||||||
|
task format # Go formatting and safe Biome fixes
|
||||||
|
task build # Build both production Docker images
|
||||||
|
```
|
||||||
|
|
||||||
|
`install`, `check`, `test`, and `format` use your native Go/Node/pnpm tools. `dev`, `dev:*`, and `build` use Docker. Individual commands such as `task backend:test` and `task frontend:check` are also listed. Tasks stop when a command fails.
|
||||||
|
|
||||||
|
All commands live in the root `Taskfile.yml`. Add a named task with `desc` and `cmds`; use `dir` for commands in a service directory and `summary` for detailed usage. Task finds the file from repository subdirectories too.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:build:
|
||||||
|
desc: Build the frontend locally
|
||||||
|
dir: frontend
|
||||||
|
cmds:
|
||||||
|
- pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose remains the source of truth for services and watch rules. Task supplies short, discoverable command names. The underlying development command is still available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f compose.yaml -f compose.dev.yaml up --build --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run production images locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build -d --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000. Nginx serves the compiled frontend and proxies `/api/*` and `/health` to Go. Both runtime containers run as non-root users and expose health checks. Ports bind to loopback for local use.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native development and checks
|
||||||
|
|
||||||
|
Use Node **24**, pnpm **10.28.2**, and Go **1.27.2**. `.tool-versions` pins the native Node and Go versions for asdf; the Go module also requires Go 1.27.2. Install pnpm with `npm install --global pnpm@10.28.2`.
|
||||||
|
|
||||||
|
In one terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd backend
|
||||||
|
go mod download
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
In another:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd frontend
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Native Go development requires restarting `go run` after edits; Compose Watch automates this in the container workflow.
|
||||||
|
|
||||||
|
Backend checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd backend
|
||||||
|
go test -race ./...
|
||||||
|
go vet ./...
|
||||||
|
go build -o bin/api ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd frontend
|
||||||
|
pnpm check
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm format` applies Biome formatting and safe fixes; `pnpm test:watch` runs Vitest interactively.
|
||||||
|
|
||||||
|
## Structure and API
|
||||||
|
|
||||||
|
- `backend/cmd/api`: process startup, HTTP timeouts, signal handling and graceful shutdown.
|
||||||
|
- `backend/internal/httpapi`: router, HTTP handlers and endpoint tests.
|
||||||
|
- `frontend/src`: page, typed API client and component tests.
|
||||||
|
- `Taskfile.yml`: discoverable development, dependency, formatting and verification commands.
|
||||||
|
- `compose.yaml`: production images and service dependencies.
|
||||||
|
- `compose.dev.yaml`: development overrides and file-watch rules.
|
||||||
|
|
||||||
|
| Request | Response |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /health` | `200 {"status":"ok"}` |
|
||||||
|
| `GET /api/test` | `200 {"message":"API connection successful"}` |
|
||||||
|
|
||||||
|
Responses use `application/json`. Other methods return 405; unknown routes return 404. `HTTP_ADDR` configures the API listener (default `:8080`). Vite reads `API_PROXY_TARGET` (default `http://localhost:8080`); Compose sets it to the backend service. This is a server-side proxy setting, not a browser URL.
|
||||||
|
|
||||||
|
## Tooling choices
|
||||||
|
|
||||||
|
[Chi](https://github.com/go-chi/chi) keeps route groups and middleware readable while using standard `net/http` handlers. Add related routes under `r.Route(...)`; extract feature handlers as the app grows. A larger framework is unnecessary for the current two endpoints.
|
||||||
|
|
||||||
|
[Vite](https://vite.dev/guide/) provides SolidJS compilation, development hot reload and production builds. Biome handles formatting and linting; TypeScript checks types; Vitest and Solid Testing Library verify rendered success, errors and retry behavior.
|
||||||
|
|
||||||
|
[Task](https://taskfile.dev/docs/reference/schema) provides language-independent commands in YAML, with descriptions via `task --list` and detailed help via `task --summary`. [Compose Watch](https://docs.docker.com/compose/how-tos/file-watch/) manages service startup and reloads underneath `task dev`.
|
||||||
|
|
||||||
|
Alternatives considered: [Just](https://just.systems/man/en/) is a concise command runner with Make-inspired syntax; [mise](https://mise.jdx.dev/tasks/toml-tasks.html) offers TOML tasks alongside runtime and environment management. Task fits the declarative YAML style already used by Compose and leaves the existing asdf runtime setup in place.
|
||||||
|
|
||||||
|
The Docker images use explicit Go and Node major/version selections, frozen pnpm dependencies and a Go checksum file. Runtime base tags receive upstream updates; pin image digests if reproducible deployment images become a requirement.
|
||||||
115
Taskfile.yml
Normal file
115
Taskfile.yml
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# yaml-language-server: $schema=https://taskfile.dev/schema.json
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
DEV_COMPOSE: docker compose -f compose.yaml -f compose.dev.yaml
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
desc: List available commands
|
||||||
|
silent: true
|
||||||
|
cmds:
|
||||||
|
- task --list
|
||||||
|
|
||||||
|
dev:
|
||||||
|
desc: Start both services with hot reload
|
||||||
|
summary: |
|
||||||
|
Build and start the API and frontend with Compose Watch.
|
||||||
|
Frontend: http://localhost:5173; API: http://localhost:8080.
|
||||||
|
Ctrl+C stops the services. Run task dev:down to remove containers.
|
||||||
|
Override occupied ports with API_PORT=18080 DEV_WEB_PORT=15173 task dev.
|
||||||
|
Requires Docker Compose 2.32+; native Go, Node and pnpm are unnecessary.
|
||||||
|
cmds:
|
||||||
|
- '{{.DEV_COMPOSE}} up --build --watch'
|
||||||
|
|
||||||
|
dev:down:
|
||||||
|
desc: Stop and remove development containers
|
||||||
|
cmds:
|
||||||
|
- '{{.DEV_COMPOSE}} down'
|
||||||
|
|
||||||
|
dev:logs:
|
||||||
|
desc: Follow development service logs
|
||||||
|
cmds:
|
||||||
|
- '{{.DEV_COMPOSE}} logs --follow --tail 100'
|
||||||
|
|
||||||
|
dev:status:
|
||||||
|
desc: Show development service status
|
||||||
|
cmds:
|
||||||
|
- '{{.DEV_COMPOSE}} ps'
|
||||||
|
|
||||||
|
install:
|
||||||
|
desc: Download native backend and frontend dependencies
|
||||||
|
cmds:
|
||||||
|
- task: backend:install
|
||||||
|
- task: frontend:install
|
||||||
|
|
||||||
|
check:
|
||||||
|
desc: Check Go code, frontend linting and TypeScript types
|
||||||
|
cmds:
|
||||||
|
- task: backend:check
|
||||||
|
- task: frontend:check
|
||||||
|
|
||||||
|
test:
|
||||||
|
desc: Run backend race tests and frontend component tests
|
||||||
|
cmds:
|
||||||
|
- task: backend:test
|
||||||
|
- task: frontend:test
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Build both production Docker images
|
||||||
|
cmds:
|
||||||
|
- docker compose build
|
||||||
|
|
||||||
|
format:
|
||||||
|
desc: Format Go code and apply safe Biome fixes
|
||||||
|
cmds:
|
||||||
|
- task: backend:format
|
||||||
|
- task: frontend:format
|
||||||
|
|
||||||
|
backend:install:
|
||||||
|
desc: Download Go modules
|
||||||
|
dir: backend
|
||||||
|
cmds:
|
||||||
|
- go mod download
|
||||||
|
|
||||||
|
backend:check:
|
||||||
|
desc: Run Go vet
|
||||||
|
dir: backend
|
||||||
|
cmds:
|
||||||
|
- go vet ./...
|
||||||
|
|
||||||
|
backend:test:
|
||||||
|
desc: Run Go tests with race detection
|
||||||
|
dir: backend
|
||||||
|
cmds:
|
||||||
|
- go test -race ./...
|
||||||
|
|
||||||
|
backend:format:
|
||||||
|
desc: Format backend Go source
|
||||||
|
dir: backend
|
||||||
|
cmds:
|
||||||
|
- go fmt ./...
|
||||||
|
|
||||||
|
frontend:install:
|
||||||
|
desc: Install frontend dependencies from the lockfile
|
||||||
|
dir: frontend
|
||||||
|
cmds:
|
||||||
|
- pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
frontend:check:
|
||||||
|
desc: Run Biome and TypeScript checks
|
||||||
|
dir: frontend
|
||||||
|
cmds:
|
||||||
|
- pnpm check
|
||||||
|
|
||||||
|
frontend:test:
|
||||||
|
desc: Run Vitest component tests
|
||||||
|
dir: frontend
|
||||||
|
cmds:
|
||||||
|
- pnpm test
|
||||||
|
|
||||||
|
frontend:format:
|
||||||
|
desc: Apply Biome formatting and safe fixes
|
||||||
|
dir: frontend
|
||||||
|
cmds:
|
||||||
|
- pnpm format
|
||||||
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
bin/
|
||||||
|
.git/
|
||||||
|
*.test
|
||||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM golang:1.27.2-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -trimpath -o /out/api ./cmd/api
|
||||||
|
|
||||||
|
FROM alpine:3.23 AS runtime
|
||||||
|
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app
|
||||||
|
COPY --from=build /out/api /usr/local/bin/api
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=5 CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
|
||||||
|
CMD ["api"]
|
||||||
50
backend/cmd/api/main.go
Normal file
50
backend/cmd/api/main.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"subalineado/backend/internal/httpapi"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
slog.Error("server stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
addr := os.Getenv("HTTP_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8080"
|
||||||
|
}
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: addr, Handler: httpapi.NewRouter(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
failures := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
slog.Info("API listening", "address", addr)
|
||||||
|
failures <- server.ListenAndServe()
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-failures:
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return server.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
backend/go.mod
Normal file
5
backend/go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module subalineado/backend
|
||||||
|
|
||||||
|
go 1.27.2
|
||||||
|
|
||||||
|
require github.com/go-chi/chi/v5 v5.4.0
|
||||||
2
backend/go.sum
Normal file
2
backend/go.sum
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
github.com/go-chi/chi/v5 v5.4.0 h1:+ufzvFTGKyin26Or5DLrlIDq4ygIDBVcj7oVNeeaYFE=
|
||||||
|
github.com/go-chi/chi/v5 v5.4.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM=
|
||||||
31
backend/internal/httpapi/router.go
Normal file
31
backend/internal/httpapi/router.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRouter registers the public HTTP interface independently of server startup.
|
||||||
|
func NewRouter() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
|
||||||
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]string{"message": "API connection successful"})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, value any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||||
|
slog.Error("write response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
backend/internal/httpapi/router_test.go
Normal file
49
backend/internal/httpapi/router_test.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEndpoints(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ path, key, value string }{
|
||||||
|
{"/health", "status", "ok"},
|
||||||
|
{"/api/test", "message", "API connection successful"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
NewRouter().ServeHTTP(response, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", response.Code)
|
||||||
|
}
|
||||||
|
if got := response.Header().Get("Content-Type"); got != "application/json" {
|
||||||
|
t.Fatalf("content type = %q", got)
|
||||||
|
}
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body[tc.key] != tc.value {
|
||||||
|
t.Fatalf("body = %v", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingErrors(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{http.MethodPost, "/api/test", http.StatusMethodNotAllowed},
|
||||||
|
{http.MethodGet, "/missing", http.StatusNotFound},
|
||||||
|
} {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
NewRouter().ServeHTTP(response, httptest.NewRequest(tc.method, tc.path, nil))
|
||||||
|
if response.Code != tc.status {
|
||||||
|
t.Fatalf("%s %s: status = %d", tc.method, tc.path, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
compose.dev.yaml
Normal file
36
compose.dev.yaml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: rebuild
|
||||||
|
path: ./backend
|
||||||
|
ignore:
|
||||||
|
- bin/
|
||||||
|
- "**/*_test.go"
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
target: dev
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${DEV_WEB_PORT:-5173}:5173"
|
||||||
|
environment:
|
||||||
|
API_PROXY_TARGET: http://backend:8080
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5173').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: ./frontend/src
|
||||||
|
target: /app/src
|
||||||
|
initial_sync: true
|
||||||
|
- action: sync
|
||||||
|
path: ./frontend/index.html
|
||||||
|
target: /app/index.html
|
||||||
|
initial_sync: true
|
||||||
|
- action: rebuild
|
||||||
|
path: ./frontend/package.json
|
||||||
|
- action: rebuild
|
||||||
|
path: ./frontend/pnpm-lock.yaml
|
||||||
|
- action: rebuild
|
||||||
|
path: ./frontend/vite.config.ts
|
||||||
|
- action: rebuild
|
||||||
|
path: ./frontend/tsconfig.json
|
||||||
22
compose.yaml
Normal file
22
compose.yaml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
name: subalineado
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: runtime
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${API_PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
HTTP_ADDR: ":8080"
|
||||||
|
init: true
|
||||||
|
stop_grace_period: 15s
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
target: runtime
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${WEB_PORT:-3000}:8080"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
init: true
|
||||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.git/
|
||||||
23
frontend/Dockerfile
Normal file
23
frontend/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:24-alpine AS dependencies
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN npm install --global pnpm@10.28.2
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM dependencies AS dev
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["pnpm", "dev"]
|
||||||
|
|
||||||
|
FROM dependencies AS build
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM nginxinc/nginx-unprivileged:stable-alpine AS runtime
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=5 CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
|
||||||
24
frontend/biome.json
Normal file
24
frontend/biome.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": ["**", "!dist", "!coverage", "!node_modules", "!pnpm-lock.yaml"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"preset": "recommended"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/index.html
Normal file
5
frontend/index.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Subalineado</title></head>
|
||||||
|
<body><div id="root"></div><script type="module" src="/src/index.tsx"></script></body>
|
||||||
|
</html>
|
||||||
18
frontend/nginx.conf
Normal file
18
frontend/nginx.conf
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "subalineado-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.28.2",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24 <25",
|
||||||
|
"pnpm": ">=10 <11"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "biome check . && tsc --noEmit",
|
||||||
|
"format": "biome check --write .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"solid-js": "^1.9.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.5.15",
|
||||||
|
"@solidjs/testing-library": "^0.8.10",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@types/node": "^26.6.5",
|
||||||
|
"jsdom": "^30.1.2",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.4",
|
||||||
|
"vite-plugin-solid": "^2.11.14",
|
||||||
|
"vitest": "^5.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1946
frontend/pnpm-lock.yaml
generated
Normal file
1946
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
frontend/src/App.test.tsx
Normal file
55
frontend/src/App.test.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
describe("API connection page", () => {
|
||||||
|
it("shows success after validating the API response", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ message: "API connection successful" })),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
render(() => <App />);
|
||||||
|
expect(
|
||||||
|
await screen.findByText("API connection successful"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/test",
|
||||||
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
["HTTP failure", () => new Response(null, { status: 500 })],
|
||||||
|
["wrong payload", () => new Response(JSON.stringify({ message: "wrong" }))],
|
||||||
|
["invalid JSON", () => new Response("not json")],
|
||||||
|
])("shows an error for %s", async (_, response) => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response()));
|
||||||
|
render(() => <App />);
|
||||||
|
expect(await screen.findByText(/Connection failed:/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("API connection successful"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("can retry after a network failure", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("Network unavailable"))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ message: "API connection successful" }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(() => <App />);
|
||||||
|
expect(await screen.findByText(/Network unavailable/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Test connection again" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await screen.findByText("API connection successful"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
frontend/src/App.tsx
Normal file
40
frontend/src/App.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { createResource, Show } from "solid-js";
|
||||||
|
import { testConnection } from "./api";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [message, { refetch }] = createResource(testConnection);
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<p class="eyebrow">SUBALINEADO</p>
|
||||||
|
<h1>Everything starts with a connection.</h1>
|
||||||
|
<p>SolidJS meets Go. Check that the app can reach the API.</p>
|
||||||
|
<section aria-label="API connection">
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
<Show
|
||||||
|
when={!message.loading}
|
||||||
|
fallback={<p>Connecting to the API…</p>}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!message.error}
|
||||||
|
fallback={
|
||||||
|
<p class="error">
|
||||||
|
Connection failed:{" "}
|
||||||
|
{String(message.error?.message ?? "Unknown error")}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p class="success">{message()}</p>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={message.loading}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
Test connection again
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/api.ts
Normal file
15
frontend/src/api.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export async function testConnection(): Promise<string> {
|
||||||
|
const response = await fetch("/api/test", {
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`API returned HTTP ${response.status}`);
|
||||||
|
const body: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
typeof body !== "object" ||
|
||||||
|
body === null ||
|
||||||
|
!("message" in body) ||
|
||||||
|
body.message !== "API connection successful"
|
||||||
|
)
|
||||||
|
throw new Error("API returned an unexpected response");
|
||||||
|
return body.message;
|
||||||
|
}
|
||||||
7
frontend/src/index.tsx
Normal file
7
frontend/src/index.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { render } from "solid-js/web";
|
||||||
|
import App from "./App";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) throw new Error("Root element missing");
|
||||||
|
render(() => <App />, root);
|
||||||
58
frontend/src/style.css
Normal file
58
frontend/src/style.css
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
color: #172a36;
|
||||||
|
background: #f3f6f4;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 12vh auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 24px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #d7e1db;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: #17603d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #a22525;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 12px 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #172a36;
|
||||||
|
color: white;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 3px solid #319a71;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
8
frontend/src/test-setup.ts
Normal file
8
frontend/src/test-setup.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@solidjs/testing-library";
|
||||||
|
import { afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "solid-js",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"types": ["vite/client", "vitest/globals", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
20
frontend/vite.config.ts
Normal file
20
frontend/vite.config.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import solid from "vite-plugin-solid";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [solid()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
"/api": process.env.API_PROXY_TARGET ?? "http://localhost:8080",
|
||||||
|
"/health": process.env.API_PROXY_TARGET ?? "http://localhost:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./src/test-setup.ts"],
|
||||||
|
clearMocks: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user