feat: initial commit

scaffolding
This commit is contained in:
calvogenerico 2026-10-12 10:55:18 -03:00
commit 8143ba02b0
No known key found for this signature in database
GPG Key ID: BE83635D98A8D359
28 changed files with 2749 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
backend/bin/
.env
.env.*
!.env.example
.task/

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
nodejs 24.16.0
golang 1.27.2

137
README.md Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
bin/
.git/
*.test

15
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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=

View 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)
}
}

View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
.git/

23
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

55
frontend/src/App.test.tsx Normal file
View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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
View 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,
},
});