This commit is contained in:
Imbus 2024-02-28 10:36:36 +01:00
commit 523a211933
24 changed files with 490 additions and 55 deletions

View file

@ -1,4 +1,5 @@
**/target **/target
**/node_modules **/node_modules
**/dist **/dist
**/.sqlite3 **/bin
**/*.sqlite3

3
.gitignore vendored
View file

@ -48,5 +48,6 @@ dist/
*.7z *.7z
*.bak *.bak
backend/backups
config.toml

View file

@ -2,7 +2,7 @@
The build is fairly simple, and I intend to keep it that way. The project is split into two main parts: the backend and the frontend. The build is fairly simple, and I intend to keep it that way. The project is split into two main parts: the backend and the frontend.
Making all of these parts work properly under linux/macOS is fairly simple, but Windows is untested territory. You can always contact [Billy G™®©](https://support.microsoft.com) for help. (If there is demand for Windows support, I will make it work) Making all of these parts work properly under linux/macOS is fairly simple, but Windows will require [WSL](https://learn.microsoft.com/en-us/windows/wsl/).
When working with these tools, keep in mind that make is sensitive to working directory. If you're in the wrong directory, make will not work as expected. Always make sure you're in the right directory when running make commands. When working with these tools, keep in mind that make is sensitive to working directory. If you're in the wrong directory, make will not work as expected. Always make sure you're in the right directory when running make commands.

View file

@ -28,4 +28,9 @@ clean: remove-podman-containers
rm -rf frontend/node_modules rm -rf frontend/node_modules
rm -f ttime-server.tar.gz rm -f ttime-server.tar.gz
cd backend && make clean cd backend && make clean
@echo "Cleaned up!" @echo "Cleaned up!"
# Cleans up everything related to podman, not just the project. Make sure you understand what this means.
[confirm]
podman-clean:
podman system reset --force

View file

@ -19,6 +19,9 @@ build:
run: build run: build
./bin/server ./bin/server
watch: build
watchexec -w . -r make run
# Clean target # Clean target
clean: clean:
$(GOCLEAN) $(GOCLEAN)
@ -26,8 +29,8 @@ clean:
rm -f db.sqlite3 rm -f db.sqlite3
# Test target # Test target
test: test: db.sqlite3
$(GOTEST) ./... $(GOTEST) ./... -count=1 -v
# Get dependencies target # Get dependencies target
deps: deps:
@ -46,9 +49,19 @@ migrate:
sqlite3 $(DB_FILE) < $$file; \ sqlite3 $(DB_FILE) < $$file; \
done done
# Target added primarily for CI/CD to ensure that the database is created before running tests
db.sqlite3:
make migrate
backup:
mkdir -p backups
sqlite3 $(DB_FILE) .dump | gzip -9 > ./backups/BACKUP_$(DB_FILE)_$(shell date +"%Y-%m-%d_%H:%M:%S").sql.gz
# Restore with:
# gzip -cd BACKUP_FILE.sql.gz | sqlite3 $(DB_FILE)
# Format # Format
fmt: fmt:
$(GOCMD) fmt ./... $(GOCMD) fmt ./...
# Default target # Default target
default: build default: build

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"ttime/internal/config"
"ttime/internal/database" "ttime/internal/database"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@ -12,15 +13,18 @@ import (
// The button state as represented in memory // The button state as represented in memory
type ButtonState struct { type ButtonState struct {
pressCount int PressCount int `json:"pressCount"`
} }
// This is what a handler with a receiver looks like // This is what a handler with a receiver looks like
// Keep in mind that concurrent state access is not (usually) safe // Keep in mind that concurrent state access is not (usually) safe
// And will in pracice be guarded by a mutex // And will in practice be guarded by a mutex
func (b *ButtonState) pressHandler(w http.ResponseWriter, r *http.Request) { func (b *ButtonState) pressHandler(w http.ResponseWriter, r *http.Request) {
b.pressCount++ if r.Method == "POST" {
response, err := json.Marshal(b.pressCount) b.PressCount++
}
response, err := json.Marshal(b)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -37,21 +41,33 @@ func handler(w http.ResponseWriter, r *http.Request) {
} }
func main() { func main() {
database.DbConnect() conf, err := config.ReadConfigFromFile("config.toml")
b := &ButtonState{pressCount: 0} if err != nil {
conf = config.NewConfig()
conf.WriteConfigToFile("config.toml")
}
// Pretty print the current config
str, _ := json.MarshalIndent(conf, "", " ")
fmt.Println(string(str))
database.DbConnect(conf.DbPath)
b := &ButtonState{PressCount: 0}
// Mounting the handlers // Mounting the handlers
fs := http.FileServer(http.Dir("static")) fs := http.FileServer(http.Dir("static"))
http.Handle("/", fs) http.Handle("/", fs)
http.HandleFunc("/hello", handler) http.HandleFunc("/hello", handler)
http.HandleFunc("/button", b.pressHandler) http.HandleFunc("/api/button", b.pressHandler)
// Start the server on port 8080 // Construct a server URL
println("Currently listening on http://localhost:8080") server_url := fmt.Sprintf(":%d", conf.Port)
println("Visit http://localhost:8080/hello to see the hello handler in action")
println("Visit http://localhost:8080/button to see the button handler in action") println("Server running on port", conf.Port)
println("Visit http://localhost" + server_url)
println("Press Ctrl+C to stop the server") println("Press Ctrl+C to stop the server")
err := http.ListenAndServe(":8080", nil)
err = http.ListenAndServe(server_url, nil)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View file

@ -5,4 +5,5 @@ go 1.21.1
require ( require (
github.com/jmoiron/sqlx v1.3.5 github.com/jmoiron/sqlx v1.3.5
github.com/mattn/go-sqlite3 v1.14.22 github.com/mattn/go-sqlite3 v1.14.22
github.com/BurntSushi/toml v1.3.2
) )

View file

@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=

View file

@ -0,0 +1,55 @@
package config
import (
"os"
"github.com/BurntSushi/toml"
)
// Config is the configuration for the application
// It defines how the TOML file should be structured
type Config struct {
// The port to listen on
Port int `toml:"port"`
// The path to the SQLite database
DbPath string `toml:"db_path"`
// The username to use for the database
DbUser string `toml:"db_user"`
// The password to use for the database
DbPass string `toml:"db_pass"`
// The name of the database
DbName string `toml:"db_name"`
}
// WriteConfigToFile writes a Config to a file
func (c *Config) WriteConfigToFile(filename string) error {
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
return toml.NewEncoder(f).Encode(c)
}
// ReadConfigFromFile reads a Config from a file
func ReadConfigFromFile(filename string) (*Config, error) {
c := NewConfig()
_, err := toml.DecodeFile(filename, c)
if err != nil {
return nil, err
}
return c, nil
}
// NewConfig returns a new Config
func NewConfig() *Config {
return &Config{
Port: 8080,
DbPath: "./db.sqlite3",
DbUser: "username",
DbPass: "password",
DbName: "ttime",
}
}

View file

@ -0,0 +1,68 @@
package config
import (
"os"
"testing"
)
func TestNewConfig(t *testing.T) {
c := NewConfig()
if c.Port != 8080 {
t.Errorf("Expected port to be 8080, got %d", c.Port)
}
if c.DbPath != "./db.sqlite3" {
t.Errorf("Expected db path to be ./db.sqlite3, got %s", c.DbPath)
}
if c.DbUser != "username" {
t.Errorf("Expected db user to be username, got %s", c.DbUser)
}
if c.DbPass != "password" {
t.Errorf("Expected db pass to be password, got %s", c.DbPass)
}
if c.DbName != "ttime" {
t.Errorf("Expected db name to be ttime, got %s", c.DbName)
}
}
func TestWriteConfig(t *testing.T) {
c := NewConfig()
err := c.WriteConfigToFile("test.toml")
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
// Remove the file after the test
_ = os.Remove("test.toml")
}
func TestReadConfig(t *testing.T) {
c := NewConfig()
err := c.WriteConfigToFile("test.toml")
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
c2, err := ReadConfigFromFile("test.toml")
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
if c.Port != c2.Port {
t.Errorf("Expected port to be %d, got %d", c.Port, c2.Port)
}
if c.DbPath != c2.DbPath {
t.Errorf("Expected db path to be %s, got %s", c.DbPath, c2.DbPath)
}
if c.DbUser != c2.DbUser {
t.Errorf("Expected db user to be %s, got %s", c.DbUser, c2.DbUser)
}
if c.DbPass != c2.DbPass {
t.Errorf("Expected db pass to be %s, got %s", c.DbPass, c2.DbPass)
}
if c.DbName != c2.DbName {
t.Errorf("Expected db name to be %s, got %s", c.DbName, c2.DbName)
}
// Remove the file after the test
_ = os.Remove("test.toml")
}

View file

@ -1,32 +1,100 @@
package database package database
import ( import (
"embed"
"log"
"os" "os"
"path/filepath"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
func DbConnect() *sqlx.DB { // This struct is a wrapper type that holds the database connection
// Check for the environment variable // Internally DB holds a connection pool, so it's safe for concurrent use
dbpath := os.Getenv("SQLITE_DB_PATH") type Db struct {
*sqlx.DB
}
// Default to something reasonable //go:embed migrations
if dbpath == "" { var scripts embed.FS
dbpath = "./db.sqlite3"
}
const userInsert = "INSERT INTO users (username, password) VALUES (?, ?)"
const projectInsert = "INSERT INTO projects (name, description, user_id) SELECT ?, ?, id FROM users WHERE username = ?"
// DbConnect connects to the database
func DbConnect(dbpath string) *Db {
// Open the database // Open the database
// db, err := sqlx.Connect("sqlite3", ":memory:")
db, err := sqlx.Connect("sqlite3", dbpath) db, err := sqlx.Connect("sqlite3", dbpath)
if err != nil { if err != nil {
panic(err) panic(err)
} }
// Ping forces the connection to be established
err = db.Ping() err = db.Ping()
if err != nil { if err != nil {
panic(err) panic(err)
} }
return db return &Db{db}
}
// AddUser adds a user to the database
func (d *Db) AddUser(username string, password string) error {
_, err := d.Exec(userInsert, username, password)
return err
}
// Removes a user from the database
func (d *Db) RemoveUser(username string) error {
_, err := d.Exec("DELETE FROM users WHERE username = ?", username)
return err
}
func (d *Db) GetUserId(username string) (int, error) {
var id int
err := d.Get(&id, "SELECT id FROM users WHERE username = ?", username)
return id, err
}
// Creates a new project in the database, associated with a user
func (d *Db) AddProject(name string, description string, username string) error {
_, err := d.Exec(projectInsert, name, description, username)
return err
}
// Reads a directory of migration files and applies them to the database.
// This will eventually be used on an embedded directory
func (d *Db) Migrate(dirname string) error {
// Read the embedded scripts directory
files, err := scripts.ReadDir("migrations")
if err != nil {
return err
}
tr := d.MustBegin()
// Iterate over each SQL file and execute it
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".sql" {
continue
}
// This is perhaps not the most elegant way to do this
sqlFile := filepath.Join("migrations", file.Name())
sqlBytes, err := os.ReadFile(sqlFile)
if err != nil {
return err
}
sqlQuery := string(sqlBytes)
_, err = tr.Exec(sqlQuery)
if err != nil {
return err
}
log.Println("Executed SQL file:", file.Name())
}
tr.Commit()
return nil
} }

View file

@ -4,7 +4,71 @@ import (
"testing" "testing"
) )
// Tests are not guaranteed to be sequential
func setupState() (*Db, error) {
db := DbConnect(":memory:")
err := db.Migrate("../../migrations")
if err != nil {
return nil, err
}
return db, nil
}
func TestDbConnect(t *testing.T) { func TestDbConnect(t *testing.T) {
db := DbConnect() db := DbConnect(":memory:")
_ = db _ = db
} }
func TestDbAddUser(t *testing.T) {
db, err := setupState()
if err != nil {
t.Error("setupState failed:", err)
}
err = db.AddUser("test", "password")
if err != nil {
t.Error("AddUser failed:", err)
}
}
func TestDbGetUserId(t *testing.T) {
db, err := setupState()
if err != nil {
t.Error("setupState failed:", err)
}
db.AddUser("test", "password")
var id int
id, err = db.GetUserId("test")
if err != nil {
t.Error("GetUserId failed:", err)
}
if id != 1 {
t.Error("GetUserId failed: expected 1, got", id)
}
}
func TestDbAddProject(t *testing.T) {
db, err := setupState()
if err != nil {
t.Error("setupState failed:", err)
}
err = db.AddProject("test", "description", "test")
if err != nil {
t.Error("AddProject failed:", err)
}
}
func TestDbRemoveUser(t *testing.T) {
db, err := setupState()
if err != nil {
t.Error("setupState failed:", err)
}
err = db.RemoveUser("test")
if err != nil {
t.Error("RemoveUser failed:", err)
}
}

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
userId TEXT DEFAULT (HEX(RANDOMBLOB(4))) NOT NULL UNIQUE,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL
);
CREATE INDEX IF NOT EXISTS users_username_index ON users (username);
CREATE INDEX IF NOT EXISTS users_userId_index ON users (userId);

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY,
projectId TEXT DEFAULT (HEX(RANDOMBLOB(4))) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
user_id INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE INDEX IF NOT EXISTS projects_projectId_index ON projects (projectId);
CREATE INDEX IF NOT EXISTS projects_user_id_index ON projects (user_id);

View file

@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS time_reports (
id INTEGER PRIMARY KEY,
reportId TEXT DEFAULT (HEX(RANDOMBLOB(6))) NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
start DATETIME NOT NULL,
end DATETIME NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
);
CREATE TRIGGER IF NOT EXISTS time_reports_start_before_end
BEFORE INSERT ON time_reports
FOR EACH ROW
BEGIN
SELECT
CASE
WHEN NEW.start >= NEW.end THEN
RAISE (ABORT, 'start must be before end')
END;
END;

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS report_collection (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
date DATE NOT NULL,
signed_by INTEGER, -- NULL if not signed
FOREIGN KEY (owner_id) REFERENCES users (id)
FOREIGN KEY (signed_by) REFERENCES users (id)
);

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
role STRING NOT NULL, -- 'admin' or 'member'
FOREIGN KEY (user_id) REFERENCES users (id)
FOREIGN KEY (project_id) REFERENCES projects (id)
);

View file

@ -0,0 +1,14 @@
# Database migrations
This directory contains all the database migrations for the backend.
[!WARNING]
Keep in mind that these migrations are **not yet stable**.
## Running migrations
In the root of the backend directory, run:
```bash
make migrate
```

View file

@ -1,7 +0,0 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);

View file

@ -4,7 +4,7 @@
# make it available in the public directory. # make it available in the public directory.
FROM docker.io/node:alpine as client FROM docker.io/node:alpine as client
WORKDIR /build WORKDIR /build
ADD frontend /build ADD frontend ./
RUN npm install RUN npm install
RUN npm run build RUN npm run build
@ -13,31 +13,45 @@ FROM docker.io/golang:alpine as go
RUN apk add gcompat RUN apk add gcompat
RUN apk add gcc RUN apk add gcc
RUN apk add musl-dev RUN apk add musl-dev
ADD backend /build RUN apk add make
RUN apk add sqlite
WORKDIR /build WORKDIR /build
ADD backend/go.mod backend/go.sum ./
# Get the dependencies # Get the dependencies
RUN go mod download RUN go mod download
# Add the source code
ADD backend .
RUN make migrate
# RUN go build -o server # RUN go build -o server
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o ./server ./cmd/ RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o ./server ./cmd/
# Strip the binary for a smaller image
RUN strip ./server RUN strip ./server
# The final stage for building a minimal image # The final stage for building a minimal image
FROM docker.io/alpine:latest as runner FROM docker.io/alpine:latest as runner
RUN adduser -D nonroot
RUN addgroup nonroot nonroot
WORKDIR /app WORKDIR /app
# Copy the frontend SPA build into public # Copy the frontend SPA build into public
COPY --from=client /build/dist /app/static COPY --from=client /build/dist static
# Copy the server binary # Copy the server binary
COPY --from=go /build/server /app/server COPY --from=go /build/server server
# Copy the migration scripts # Copy the database
COPY --from=go /build/migrations /app/migrations COPY --from=go /build/db.sqlite3 db.sqlite3
# Expose port 8080 # Expose port 8080
EXPOSE 8080 EXPOSE 8080
# Set the user to nonroot
USER nonroot:nonroot
# Run the server # Run the server
CMD ["./server"] CMD ["./server"]

View file

@ -3,10 +3,30 @@
This is a React + TypeScript + Vite project. It is a single-page application (SPA) that communicates with the backend via a REST API. This is a React + TypeScript + Vite project. It is a single-page application (SPA) that communicates with the backend via a REST API.
Most of this looks very complex, but it's mostly just a lot of boilerplate due to the ugodly amount of abstraction that comes with TypeScript and React, and just modern web development in general. Most of this looks very complex, but it's mostly just a lot of boilerplate due to the ugodly amount of abstraction that comes with TypeScript and React, and just modern web development in general.
**Keep in mind** that the tooling for the frontend is configured to be extra strict (see the [eslint config](.eslintrc.cjs)). This is to make it hard to write bad code, by encouraging actual typing and not just using [any](https://www.typescriptlang.org/docs/handbook/basic-types.html#any) everywhere. This can be a bit of a struggle to work with at first, and I might have to backpedal on this if it becomes too much of a hassle. (Those familiar with Java will be more confused by the lack of types than the presence of them.)
## Build Instructions ## Build Instructions
Our build tool, npm, is the only dependency required to build this project. It handles fetching the dependencies and placing them in the famously huge `node_modules` directory, also known as the heaviest object in the known universe. It also handles the build process, which is just a matter of running `npm run build`. Our build tool, npm, is the only dependency required to build this project. It handles fetching the dependencies and placing them in the famously huge `node_modules` directory, also known as the heaviest object in the known universe.
The build target results in a `dist` directory, which contains the static assets that can be served by a web server. This is the directory that will be served by the backend. Think of this like compiling a program, the .exe/elf/binary is the `dist` directory. ### Development
For a development session, in the `frontend` directory, run:
```sh
npm install
npm run dev
```
For development, you use `npm run dev` to start a development server. This server will automatically rebuild the project when you make changes to the source code, and it will also automatically reload the page in your browser. The issue is that the development server will run without the backend, **so you will need to run the backend separately during development** if you want any meaningful data to be displayed. For development, you use `npm run dev` to start a development server. This server will automatically rebuild the project when you make changes to the source code, and it will also automatically reload the page in your browser. The issue is that the development server will run without the backend, **so you will need to run the backend separately during development** if you want any meaningful data to be displayed.
### Release
The release build is usually automated by the CI/CD pipeline, but you can also build it manually. In the `frontend` directory, run:
```sh
npm install
npm run build
```
The build target results in a `dist` directory, which contains the static assets that can be served by a web server. This is the directory that will be served by the backend. Think of this like compiling a program, the .exe/elf/binary is the `dist` directory.

View file

@ -1,11 +1,9 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg"; import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg"; import viteLogo from "/vite.svg";
import "./App.css"; import "./App.css";
import { CountButton } from "./Components/CountButton";
function App(): JSX.Element { function App(): JSX.Element {
const [count, setCount] = useState(0);
return ( return (
<> <>
<div> <div>
@ -18,13 +16,7 @@ function App(): JSX.Element {
</div> </div>
<h1>Vite + React</h1> <h1>Vite + React</h1>
<div className="card"> <div className="card">
<button <CountButton />
onClick={() => {
setCount((count): number => count + 1);
}}
>
count is {count}
</button>
<p> <p>
Edit <code>src/App.tsx</code> and save to test HMR Edit <code>src/App.tsx</code> and save to test HMR
</p> </p>

View file

@ -0,0 +1,38 @@
import { useState, useEffect } from "react";
// Interface for the response from the server
// This should eventually reside in a dedicated file
interface CountResponse {
pressCount: number;
}
// Some constants for the button
const BUTTON_ENDPOINT = "/api/button";
// A simple button that counts how many times it's been pressed
export function CountButton(): JSX.Element {
const [count, setCount] = useState<number>(NaN);
// useEffect with a [] dependency array runs only once
useEffect(() => {
async function getCount(): Promise<void> {
const response = await fetch(BUTTON_ENDPOINT);
const data = (await response.json()) as CountResponse;
setCount(data.pressCount);
}
void getCount();
}, []);
// This is what runs on every button click
function press(): void {
async function pressPost(): Promise<void> {
const response = await fetch(BUTTON_ENDPOINT, { method: "POST" });
const data = (await response.json()) as CountResponse;
setCount(data.pressCount);
}
void pressPost();
}
// Return some JSX with the button and associated handler
return <button onClick={press}>count is {count}</button>;
}

View file

@ -4,4 +4,19 @@ import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// build: {
// outDir: '../server/public' // Override default outDir('dist')
// },
server: {
port: 3000,
open: true,
proxy: {
"/api": {
target: "http://localhost:8080/api",
changeOrigin: true,
secure: false,
rewrite: (path): string => path.replace(/^\/api/, ""),
},
},
},
}); });