Compare commits
12 Commits
Features/F
...
Features/B
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c24782c8 | ||
|
|
4ebee9e695 | ||
|
|
a5ca826a6e | ||
|
|
ecd214652a | ||
|
|
99538e30c8 | ||
|
|
90de5ca868 | ||
|
|
354dbc849b | ||
|
|
ddd25b6eb3 | ||
|
|
cc6243d630 | ||
|
|
9bceb2312d | ||
|
|
4c219c0084 | ||
|
|
a75293fce4 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
**/.pnpm-store
|
||||
**/.output
|
||||
**/dist
|
||||
**/node_modules
|
||||
|
||||
Commands
|
||||
Docker
|
||||
Documentation
|
||||
Env
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,3 +21,7 @@ pnpm-debug.log*
|
||||
# OS / editor files
|
||||
.DS_Store
|
||||
.idea/
|
||||
|
||||
# Go build output
|
||||
tmp/
|
||||
bin/
|
||||
|
||||
52
Backend/.air.api.toml
Normal file
52
Backend/.air.api.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp/dev"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/dev/bin/api"
|
||||
cmd = "go build -o ./tmp/dev/bin/api ./cmd/api"
|
||||
delay = 1000
|
||||
exclude_dir = ["tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "sql"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "tmp/dev/logs/build-errors.api.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
keep_scroll = true
|
||||
52
Backend/.air.web.toml
Normal file
52
Backend/.air.web.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp/dev"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/dev/bin/web"
|
||||
cmd = "go build -o ./tmp/dev/bin/web ./cmd/web"
|
||||
delay = 1000
|
||||
exclude_dir = ["tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "sql"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "tmp/dev/logs/build-errors.web.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
keep_scroll = true
|
||||
52
Backend/.air.worker.toml
Normal file
52
Backend/.air.worker.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp/dev"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/dev/bin/worker"
|
||||
cmd = "go build -o ./tmp/dev/bin/worker ./cmd/worker"
|
||||
delay = 1000
|
||||
exclude_dir = ["tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "sql"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "tmp/dev/logs/build-errors.worker.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
keep_scroll = true
|
||||
4
Backend/.dockerignore
Normal file
4
Backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
.gitignore
|
||||
tmp
|
||||
testdata
|
||||
36
Backend/Dockerfile
Normal file
36
Backend/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM golang:1.25.7-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
FROM base AS development
|
||||
|
||||
RUN curl -fsSL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b /usr/local/bin
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["air", "-c", ".air.web.toml"]
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ARG SERVICE_NAME=web
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/${SERVICE_NAME}
|
||||
|
||||
FROM alpine:3.22 AS runtime
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/app /usr/local/bin/app
|
||||
|
||||
CMD ["/usr/local/bin/app"]
|
||||
35
Backend/cmd/api/main.go
Normal file
35
Backend/cmd/api/main.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"moku-backend/internal/bootstrap"
|
||||
"moku-backend/internal/httpx"
|
||||
"moku-backend/internal/process"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app, err := bootstrap.New("api")
|
||||
if err != nil {
|
||||
log.Fatalf("bootstrap api service: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := app.Close(); closeErr != nil {
|
||||
app.Logger.Error("close api service", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
handler := httpx.NewRouter(httpx.RouterConfig{
|
||||
ServiceName: app.ServiceName,
|
||||
Config: app.Config,
|
||||
Logger: app.Logger,
|
||||
BuildInfo: app.BuildInfo,
|
||||
Database: app.Database,
|
||||
Cache: app.Cache,
|
||||
})
|
||||
|
||||
if err := process.RunHTTPServer(app.ServiceName, app.Config.Address(app.ServiceName), handler, app.Logger, app.Config.ShutdownTimeout); err != nil {
|
||||
app.Logger.Error("api service stopped", "error", err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
49
Backend/cmd/migrate/main.go
Normal file
49
Backend/cmd/migrate/main.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"moku-backend/internal/config"
|
||||
"moku-backend/internal/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
db, err := database.NewPostgres(cfg.PostgresURL)
|
||||
if err != nil {
|
||||
log.Fatalf("connect database for migrations: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
command := "up"
|
||||
if len(os.Args) > 1 {
|
||||
command = os.Args[1]
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "up":
|
||||
if err := db.MigrateUp(); err != nil {
|
||||
log.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
fmt.Println("migrations applied")
|
||||
case "down":
|
||||
if err := db.MigrateDown(); err != nil {
|
||||
log.Fatalf("roll back migration: %v", err)
|
||||
}
|
||||
fmt.Println("latest migration rolled back")
|
||||
case "reset":
|
||||
if err := db.MigrateReset(); err != nil {
|
||||
log.Fatalf("reset migrations: %v", err)
|
||||
}
|
||||
fmt.Println("migrations reset")
|
||||
case "status":
|
||||
if err := db.MigrateStatus(); err != nil {
|
||||
log.Fatalf("show migration status: %v", err)
|
||||
}
|
||||
default:
|
||||
log.Fatalf("unsupported migrate command %q (supported: up, down, reset, status)", command)
|
||||
}
|
||||
}
|
||||
35
Backend/cmd/web/main.go
Normal file
35
Backend/cmd/web/main.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"moku-backend/internal/bootstrap"
|
||||
"moku-backend/internal/httpx"
|
||||
"moku-backend/internal/process"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app, err := bootstrap.New("web")
|
||||
if err != nil {
|
||||
log.Fatalf("bootstrap web service: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := app.Close(); closeErr != nil {
|
||||
app.Logger.Error("close web service", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
handler := httpx.NewRouter(httpx.RouterConfig{
|
||||
ServiceName: app.ServiceName,
|
||||
Config: app.Config,
|
||||
Logger: app.Logger,
|
||||
BuildInfo: app.BuildInfo,
|
||||
Database: app.Database,
|
||||
Cache: app.Cache,
|
||||
})
|
||||
|
||||
if err := process.RunHTTPServer(app.ServiceName, app.Config.Address(app.ServiceName), handler, app.Logger, app.Config.ShutdownTimeout); err != nil {
|
||||
app.Logger.Error("web service stopped", "error", err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
27
Backend/cmd/worker/main.go
Normal file
27
Backend/cmd/worker/main.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"moku-backend/internal/bootstrap"
|
||||
"moku-backend/internal/process"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app, err := bootstrap.New("worker")
|
||||
if err != nil {
|
||||
log.Fatalf("bootstrap worker service: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := app.Close(); closeErr != nil {
|
||||
app.Logger.Error("close worker service", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment)
|
||||
|
||||
if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil {
|
||||
app.Logger.Error("worker stopped", "error", err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
6
Backend/db/embed.go
Normal file
6
Backend/db/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package db
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var Migrations embed.FS
|
||||
26
Backend/db/migrations/000001_init.sql
Normal file
26
Backend/db/migrations/000001_init.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- +goose Up
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (organization_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_organization_id ON workspaces (organization_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS workspaces;
|
||||
DROP TABLE IF EXISTS organizations;
|
||||
15
Backend/db/queries/organizations.sql
Normal file
15
Backend/db/queries/organizations.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- name: ListOrganizations :many
|
||||
SELECT id, name, slug, created_at, updated_at
|
||||
FROM organizations
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: CreateOrganization :one
|
||||
INSERT INTO organizations (name, slug)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, slug, created_at, updated_at;
|
||||
|
||||
-- name: ListWorkspacesByOrganization :many
|
||||
SELECT id, organization_id, name, slug, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE organization_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
36
Backend/docker-bake.hcl
Normal file
36
Backend/docker-bake.hcl
Normal file
@@ -0,0 +1,36 @@
|
||||
variable "REGISTRY" {
|
||||
default = "registry.example.com"
|
||||
}
|
||||
|
||||
variable "TAG" {
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
target "_app" {
|
||||
context = "."
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
target "dev" {
|
||||
inherits = ["_app"]
|
||||
target = "development"
|
||||
tags = ["moku/work-backend:dev"]
|
||||
}
|
||||
|
||||
target "dev-image" {
|
||||
inherits = ["_app"]
|
||||
target = "development"
|
||||
tags = ["${REGISTRY}/moku/work-backend:dev-${TAG}"]
|
||||
}
|
||||
|
||||
group "local" {
|
||||
targets = ["dev"]
|
||||
}
|
||||
|
||||
group "registry" {
|
||||
targets = ["dev-image"]
|
||||
}
|
||||
|
||||
group "default" {
|
||||
targets = ["dev"]
|
||||
}
|
||||
24
Backend/go.mod
Normal file
24
Backend/go.mod
Normal file
@@ -0,0 +1,24 @@
|
||||
module moku-backend
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
)
|
||||
70
Backend/go.sum
Normal file
70
Backend/go.sum
Normal file
@@ -0,0 +1,70 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
85
Backend/internal/bootstrap/bootstrap.go
Normal file
85
Backend/internal/bootstrap/bootstrap.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Path: Backend/internal/bootstrap/bootstrap.go
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"moku-backend/internal/buildinfo"
|
||||
"moku-backend/internal/cache"
|
||||
"moku-backend/internal/config"
|
||||
"moku-backend/internal/database"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
ServiceName string
|
||||
Config *config.Config
|
||||
Logger *slog.Logger
|
||||
BuildInfo buildinfo.Info
|
||||
Database *database.DB
|
||||
Cache *cache.Client
|
||||
}
|
||||
|
||||
func New(serviceName string) (*App, error) {
|
||||
cfg := config.Load()
|
||||
logger := newLogger(cfg)
|
||||
|
||||
db, err := database.NewPostgres(cfg.PostgresURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
valkey, err := cache.NewValkey(cfg.ValkeyURL)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &App{
|
||||
ServiceName: serviceName,
|
||||
Config: cfg,
|
||||
Logger: logger.With("service", serviceName),
|
||||
BuildInfo: buildinfo.Current(),
|
||||
Database: db,
|
||||
Cache: valkey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
var closeErr error
|
||||
|
||||
if a.Cache != nil {
|
||||
if err := a.Cache.Close(); err != nil {
|
||||
closeErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if a.Database != nil {
|
||||
a.Database.Close()
|
||||
}
|
||||
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func newLogger(cfg *config.Config) *slog.Logger {
|
||||
level := slog.LevelInfo
|
||||
|
||||
switch strings.ToLower(cfg.LogLevel) {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
|
||||
options := &slog.HandlerOptions{Level: level}
|
||||
|
||||
if cfg.IsDevelopment() {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, options))
|
||||
}
|
||||
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, options))
|
||||
}
|
||||
23
Backend/internal/buildinfo/buildinfo.go
Normal file
23
Backend/internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Path: Backend/internal/buildinfo/buildinfo.go
|
||||
|
||||
package buildinfo
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
BuiltAt = "unknown"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuiltAt string `json:"builtAt"`
|
||||
}
|
||||
|
||||
func Current() Info {
|
||||
return Info{
|
||||
Version: Version,
|
||||
Commit: Commit,
|
||||
BuiltAt: BuiltAt,
|
||||
}
|
||||
}
|
||||
40
Backend/internal/cache/valkey.go
vendored
Normal file
40
Backend/internal/cache/valkey.go
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
// Path: Backend/internal/cache/valkey.go
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Redis *redis.Client
|
||||
}
|
||||
|
||||
func NewValkey(valkeyURL string) (*Client, error) {
|
||||
options, err := redis.ParseURL(valkeyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := redis.NewClient(options)
|
||||
|
||||
return &Client{Redis: client}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Health(ctx context.Context) error {
|
||||
if c == nil || c.Redis == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.Redis.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
if c == nil || c.Redis == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.Redis.Close()
|
||||
}
|
||||
79
Backend/internal/config/config.go
Normal file
79
Backend/internal/config/config.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Path: Backend/internal/config/config.go
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
Environment string
|
||||
LogLevel string
|
||||
WebPort string
|
||||
APIPort string
|
||||
WorkerPort string
|
||||
PostgresURL string
|
||||
ValkeyURL string
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
AppName: getEnv("APP_NAME", "moku"),
|
||||
Environment: getEnv("GO_ENV", "development"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "debug"),
|
||||
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
|
||||
APIPort: getEnv("BACKEND_API_PORT", "8081"),
|
||||
WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"),
|
||||
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
|
||||
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
|
||||
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Address(serviceName string) string {
|
||||
var port string
|
||||
|
||||
switch strings.ToLower(serviceName) {
|
||||
case "web":
|
||||
port = c.WebPort
|
||||
case "api":
|
||||
port = c.APIPort
|
||||
case "worker":
|
||||
port = c.WorkerPort
|
||||
default:
|
||||
port = c.WebPort
|
||||
}
|
||||
|
||||
return fmt.Sprintf(":%s", port)
|
||||
}
|
||||
|
||||
func (c *Config) IsDevelopment() bool {
|
||||
return strings.EqualFold(c.Environment, "development")
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getDurationEnv(key string, fallback time.Duration) time.Duration {
|
||||
value, exists := os.LookupEnv(key)
|
||||
if !exists {
|
||||
return fallback
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
95
Backend/internal/database/postgres.go
Normal file
95
Backend/internal/database/postgres.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Path: Backend/internal/database/postgres.go
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"moku-backend/db"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostgres(databaseURL string) (*DB, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.MaxConns = 25
|
||||
config.MinConns = 5
|
||||
config.MaxConnLifetime = time.Hour
|
||||
config.MaxConnIdleTime = 30 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DB{Pool: pool}, nil
|
||||
}
|
||||
|
||||
func (d *DB) MigrateUp() error {
|
||||
return d.runGoose(goose.Up)
|
||||
}
|
||||
|
||||
func (d *DB) MigrateDown() error {
|
||||
return d.runGoose(goose.Down)
|
||||
}
|
||||
|
||||
func (d *DB) MigrateReset() error {
|
||||
return d.runGoose(goose.Reset)
|
||||
}
|
||||
|
||||
func (d *DB) MigrateStatus() error {
|
||||
return d.runGoose(goose.Status)
|
||||
}
|
||||
|
||||
func (d *DB) runGoose(command func(*sql.DB, string, ...goose.OptionsFunc) error) error {
|
||||
if d == nil || d.Pool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDB := stdlib.OpenDBFromPool(d.Pool)
|
||||
defer sqlDB.Close()
|
||||
|
||||
goose.SetBaseFS(db.Migrations)
|
||||
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return command(sqlDB, "migrations")
|
||||
}
|
||||
|
||||
func (d *DB) Health(ctx context.Context) error {
|
||||
if d == nil || d.Pool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.Pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func (d *DB) Close() {
|
||||
if d == nil || d.Pool == nil {
|
||||
return
|
||||
}
|
||||
|
||||
d.Pool.Close()
|
||||
}
|
||||
53
Backend/internal/httpx/api_routes.go
Normal file
53
Backend/internal/httpx/api_routes.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Path: Backend/internal/httpx/api_routes.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type apiRoutes struct {
|
||||
cfg RouterConfig
|
||||
}
|
||||
|
||||
func newAPIRoutes(cfg RouterConfig) routeRegistrar {
|
||||
return apiRoutes{cfg: cfg}
|
||||
}
|
||||
|
||||
func (routes apiRoutes) Register(router chi.Router) {
|
||||
router.Route("/v1", func(apiRouter chi.Router) {
|
||||
apiRouter.Get("/", routes.handleIndex)
|
||||
apiRouter.Get("/organizations", routes.handleOrganizations)
|
||||
apiRouter.Get("/workspaces", routes.handleWorkspaces)
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"version": "v1",
|
||||
"status": "scaffolded",
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) handleOrganizations(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"data": []any{},
|
||||
"meta": map[string]any{
|
||||
"resource": "organizations",
|
||||
"count": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) handleWorkspaces(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"data": []any{},
|
||||
"meta": map[string]any{
|
||||
"resource": "workspaces",
|
||||
"count": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
80
Backend/internal/httpx/middleware.go
Normal file
80
Backend/internal/httpx/middleware.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Path: Backend/internal/httpx/middleware.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const requestIDContextKey contextKey = "requestID"
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(statusCode int) {
|
||||
r.statusCode = statusCode
|
||||
r.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := uuid.NewString()
|
||||
ctx := context.WithValue(r.Context(), requestIDContextKey, requestID)
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
requestID := RequestIDFromContext(r.Context())
|
||||
logger.Error("panic recovered", "request_id", requestID, "panic", recovered, "path", r.URL.Path)
|
||||
WriteError(w, http.StatusInternalServerError, requestID, "internal_error", "An unexpected error occurred.")
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(recorder, r)
|
||||
|
||||
logger.Info(
|
||||
"http request",
|
||||
"request_id", RequestIDFromContext(r.Context()),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", recorder.statusCode,
|
||||
"duration", time.Since(startedAt).String(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
requestID, ok := ctx.Value(requestIDContextKey).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return requestID
|
||||
}
|
||||
33
Backend/internal/httpx/response.go
Normal file
33
Backend/internal/httpx/response.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Path: Backend/internal/httpx/response.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, status int, requestID, code, message string) {
|
||||
WriteJSON(w, status, ErrorResponse{
|
||||
Error: code,
|
||||
Message: message,
|
||||
RequestID: requestID,
|
||||
})
|
||||
}
|
||||
70
Backend/internal/httpx/router.go
Normal file
70
Backend/internal/httpx/router.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Path: Backend/internal/httpx/router.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"moku-backend/internal/buildinfo"
|
||||
"moku-backend/internal/cache"
|
||||
"moku-backend/internal/config"
|
||||
"moku-backend/internal/database"
|
||||
)
|
||||
|
||||
type RouterConfig struct {
|
||||
ServiceName string
|
||||
Config *config.Config
|
||||
Logger *slog.Logger
|
||||
BuildInfo buildinfo.Info
|
||||
Database *database.DB
|
||||
Cache *cache.Client
|
||||
}
|
||||
|
||||
type routeRegistrar interface {
|
||||
Register(chi.Router)
|
||||
}
|
||||
|
||||
func NewRouter(cfg RouterConfig) http.Handler {
|
||||
router := chi.NewRouter()
|
||||
|
||||
registerMiddleware(router, cfg)
|
||||
registerRoutes(router, routesForService(cfg)...)
|
||||
router.NotFound(notFoundHandler)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func registerMiddleware(router chi.Router, cfg RouterConfig) {
|
||||
router.Use(chimiddleware.RealIP)
|
||||
router.Use(RequestID)
|
||||
router.Use(Recoverer(cfg.Logger))
|
||||
router.Use(RequestLogger(cfg.Logger))
|
||||
}
|
||||
|
||||
func registerRoutes(router chi.Router, registrars ...routeRegistrar) {
|
||||
for _, registrar := range registrars {
|
||||
registrar.Register(router)
|
||||
}
|
||||
}
|
||||
|
||||
func routesForService(cfg RouterConfig) []routeRegistrar {
|
||||
registrars := []routeRegistrar{newSharedRoutes(cfg)}
|
||||
|
||||
switch strings.ToLower(cfg.ServiceName) {
|
||||
case "web":
|
||||
registrars = append(registrars, newWebRoutes(cfg))
|
||||
case "api":
|
||||
registrars = append(registrars, newAPIRoutes(cfg))
|
||||
}
|
||||
|
||||
return registrars
|
||||
}
|
||||
|
||||
func notFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.")
|
||||
}
|
||||
69
Backend/internal/httpx/shared_routes.go
Normal file
69
Backend/internal/httpx/shared_routes.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Path: Backend/internal/httpx/shared_routes.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type sharedRoutes struct {
|
||||
cfg RouterConfig
|
||||
}
|
||||
|
||||
func newSharedRoutes(cfg RouterConfig) routeRegistrar {
|
||||
return sharedRoutes{cfg: cfg}
|
||||
}
|
||||
|
||||
func (routes sharedRoutes) Register(router chi.Router) {
|
||||
router.Get("/", routes.handleIndex)
|
||||
router.Get("/health", routes.handleHealth)
|
||||
router.Get("/version", routes.handleVersion)
|
||||
}
|
||||
|
||||
func (routes sharedRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func (routes sharedRoutes) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
databaseStatus := "ok"
|
||||
if err := routes.cfg.Database.Health(ctx); err != nil {
|
||||
databaseStatus = err.Error()
|
||||
}
|
||||
|
||||
cacheStatus := "ok"
|
||||
if err := routes.cfg.Cache.Health(ctx); err != nil {
|
||||
cacheStatus = err.Error()
|
||||
}
|
||||
|
||||
statusCode := http.StatusOK
|
||||
if databaseStatus != "ok" || cacheStatus != "ok" {
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
WriteJSON(w, statusCode, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"status": map[string]string{
|
||||
"database": databaseStatus,
|
||||
"cache": cacheStatus,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes sharedRoutes) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"app": routes.cfg.Config.AppName,
|
||||
"environment": routes.cfg.Config.Environment,
|
||||
"build": routes.cfg.BuildInfo,
|
||||
})
|
||||
}
|
||||
51
Backend/internal/httpx/web_routes.go
Normal file
51
Backend/internal/httpx/web_routes.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Path: Backend/internal/httpx/web_routes.go
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type webRoutes struct {
|
||||
cfg RouterConfig
|
||||
}
|
||||
|
||||
func newWebRoutes(cfg RouterConfig) routeRegistrar {
|
||||
return webRoutes{cfg: cfg}
|
||||
}
|
||||
|
||||
func (routes webRoutes) Register(router chi.Router) {
|
||||
router.Get("/session", routes.handleSession)
|
||||
router.Get("/bootstrap", routes.handleBootstrap)
|
||||
router.Get("/me", routes.handleCurrentUser)
|
||||
}
|
||||
|
||||
func (routes webRoutes) handleSession(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"session": map[string]any{
|
||||
"authenticated": false,
|
||||
"mode": "cookie",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes webRoutes) handleBootstrap(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"service": routes.cfg.ServiceName,
|
||||
"app": map[string]any{
|
||||
"name": routes.cfg.Config.AppName,
|
||||
"environment": routes.cfg.Config.Environment,
|
||||
},
|
||||
"features": map[string]bool{
|
||||
"auth": false,
|
||||
"workspaces": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes webRoutes) handleCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusNotImplemented, RequestIDFromContext(r.Context()), "not_implemented", "The current user endpoint is scaffolded but not implemented yet.")
|
||||
}
|
||||
65
Backend/internal/process/process.go
Normal file
65
Backend/internal/process/process.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Path: Backend/internal/process/process.go
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RunHTTPServer(serviceName, address string, handler http.Handler, logger *slog.Logger, shutdownTimeout time.Duration) error {
|
||||
server := &http.Server{
|
||||
Addr: address,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
logger.Info("http server starting", "service", serviceName, "address", address)
|
||||
serverErr <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case <-signalCtx.Done():
|
||||
logger.Info("shutdown requested", "service", serviceName)
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown %s server: %w", serviceName, err)
|
||||
}
|
||||
|
||||
logger.Info("http server stopped", "service", serviceName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WaitForShutdown(serviceName string, logger *slog.Logger) error {
|
||||
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger.Info("process running", "service", serviceName)
|
||||
<-signalCtx.Done()
|
||||
logger.Info("shutdown requested", "service", serviceName)
|
||||
|
||||
return nil
|
||||
}
|
||||
12
Backend/sqlc.yaml
Normal file
12
Backend/sqlc.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "db/migrations"
|
||||
queries: "db/queries"
|
||||
gen:
|
||||
go:
|
||||
package: "sqlc"
|
||||
out: "internal/sqlc"
|
||||
sql_package: "pgx/v5"
|
||||
emit_json_tags: true
|
||||
emit_empty_slices: true
|
||||
26
Commands/Local/Dev/backend.just
Normal file
26
Commands/Local/Dev/backend.just
Normal file
@@ -0,0 +1,26 @@
|
||||
project_root := justfile_directory()
|
||||
backend_dir := project_root + "/Backend"
|
||||
|
||||
# Apply embedded database migrations.
|
||||
migrate-up:
|
||||
cd '{{backend_dir}}' && go run ./cmd/migrate up
|
||||
|
||||
# Roll back the most recent embedded database migration.
|
||||
migrate-down:
|
||||
cd '{{backend_dir}}' && go run ./cmd/migrate down
|
||||
|
||||
# Reset all embedded database migrations and reapply from scratch.
|
||||
migrate-reset:
|
||||
cd '{{backend_dir}}' && go run ./cmd/migrate reset
|
||||
|
||||
# Show the embedded database migration status.
|
||||
migrate-status:
|
||||
cd '{{backend_dir}}' && go run ./cmd/migrate status
|
||||
|
||||
# Format backend Go source files.
|
||||
fmt:
|
||||
cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal
|
||||
|
||||
# Run backend test suite.
|
||||
test:
|
||||
cd '{{backend_dir}}' && go test ./...
|
||||
14
Commands/Local/Dev/frontend.just
Normal file
14
Commands/Local/Dev/frontend.just
Normal file
@@ -0,0 +1,14 @@
|
||||
project_root := justfile_directory()
|
||||
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
|
||||
frontend_dir := project_root + "/Frontend"
|
||||
node_modules_volume := "moku_work_frontend_node_modules"
|
||||
|
||||
# Recreate the frontend node_modules Docker volume.
|
||||
node_modules:
|
||||
docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true
|
||||
docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true
|
||||
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend
|
||||
|
||||
# Run the frontend TypeScript check.
|
||||
tsc:
|
||||
cd '{{frontend_dir}}' && pnpm typecheck
|
||||
39
Commands/Local/Dev/mod.just
Normal file
39
Commands/Local/Dev/mod.just
Normal file
@@ -0,0 +1,39 @@
|
||||
stack_runner := justfile_directory() + "/Commands/Local/Dev/scripts/dev-stack.sh"
|
||||
|
||||
mod frontend
|
||||
mod backend
|
||||
|
||||
# Build the combined local development stack assets.
|
||||
build:
|
||||
bash '{{stack_runner}}' build
|
||||
|
||||
# Start the full local development stack in the background.
|
||||
up:
|
||||
bash '{{stack_runner}}' up
|
||||
|
||||
# Build first, then start the full local development stack in the background.
|
||||
start:
|
||||
bash '{{stack_runner}}' start
|
||||
|
||||
# Alias for the main full local development flow.
|
||||
dev: up
|
||||
|
||||
# Stop and remove the local development stack.
|
||||
down:
|
||||
bash '{{stack_runner}}' down
|
||||
|
||||
# Rebuild the full local development stack.
|
||||
rebuild:
|
||||
bash '{{stack_runner}}' rebuild
|
||||
|
||||
# Follow logs for the full local development stack.
|
||||
logs:
|
||||
bash '{{stack_runner}}' logs
|
||||
|
||||
# Restart the full local development stack.
|
||||
restart:
|
||||
bash '{{stack_runner}}' restart
|
||||
|
||||
# Stop the local development stack and remove local images, volumes, and backend dev state.
|
||||
clean:
|
||||
bash '{{stack_runner}}' clean
|
||||
91
Commands/Local/Dev/scripts/backend-stack.sh
Normal file
91
Commands/Local/Dev/scripts/backend-stack.sh
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Path: Commands/Local/Dev/scripts/backend-stack.sh
|
||||
set -euo pipefail
|
||||
|
||||
action=${1:-up}
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
project_root=$(cd -- "$script_dir/../../../.." && pwd)
|
||||
backend_dir="$project_root/Backend"
|
||||
backend_bake="$backend_dir/docker-bake.hcl"
|
||||
env_dir="$project_root/Env"
|
||||
compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
|
||||
runtime_dir="$backend_dir/tmp/dev"
|
||||
backend_image="moku/work-backend:dev"
|
||||
backend_go_pkg_volume="moku_work_backend_go_pkg"
|
||||
backend_go_build_volume="moku_work_backend_go_build"
|
||||
|
||||
services=(web api worker)
|
||||
|
||||
source "$script_dir/docker.sh"
|
||||
source "$script_dir/env.sh"
|
||||
|
||||
build_backend() {
|
||||
cd "$backend_dir"
|
||||
docker buildx bake -f "$backend_bake" dev
|
||||
}
|
||||
|
||||
up_backend() {
|
||||
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}"
|
||||
}
|
||||
|
||||
down_backend() {
|
||||
docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true
|
||||
docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
restart_backend() {
|
||||
docker compose -f "$compose_file" restart "${services[@]}"
|
||||
}
|
||||
|
||||
follow_logs() {
|
||||
docker compose -f "$compose_file" logs -f "${services[@]}"
|
||||
}
|
||||
|
||||
clean_runtime() {
|
||||
rm -rf "$runtime_dir"
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
check)
|
||||
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
|
||||
;;
|
||||
build)
|
||||
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
|
||||
build_backend
|
||||
;;
|
||||
up)
|
||||
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
up_backend
|
||||
;;
|
||||
down)
|
||||
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
down_backend
|
||||
;;
|
||||
restart)
|
||||
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
restart_backend
|
||||
;;
|
||||
logs)
|
||||
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
follow_logs
|
||||
;;
|
||||
clean)
|
||||
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
down_backend
|
||||
remove_docker_image_if_present "$backend_image"
|
||||
remove_docker_volume_if_present "$backend_go_pkg_volume"
|
||||
remove_docker_volume_if_present "$backend_go_build_volume"
|
||||
clean_runtime
|
||||
;;
|
||||
*)
|
||||
printf 'Unsupported backend stack action: %s\n' "$action" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
102
Commands/Local/Dev/scripts/dev-stack.sh
Normal file
102
Commands/Local/Dev/scripts/dev-stack.sh
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Path: Commands/Local/Dev/scripts/dev-stack.sh
|
||||
set -euo pipefail
|
||||
|
||||
action=${1:-up}
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
project_root=$(cd -- "$script_dir/../../../.." && pwd)
|
||||
frontend_dir="$project_root/Frontend"
|
||||
frontend_bake="$frontend_dir/docker-bake.hcl"
|
||||
backend_dir="$project_root/Backend"
|
||||
backend_bake="$backend_dir/docker-bake.hcl"
|
||||
env_dir="$project_root/Env"
|
||||
compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
|
||||
frontend_image="moku/work-frontend:dev"
|
||||
backend_image="moku/work-backend:dev"
|
||||
frontend_volume="moku_work_frontend_node_modules"
|
||||
backend_go_pkg_volume="moku_work_backend_go_pkg"
|
||||
backend_go_build_volume="moku_work_backend_go_build"
|
||||
backend_runtime_dir="$backend_dir/tmp/dev"
|
||||
|
||||
source "$script_dir/docker.sh"
|
||||
source "$script_dir/env.sh"
|
||||
|
||||
build_frontend() {
|
||||
cd "$frontend_dir"
|
||||
docker buildx bake -f "$frontend_bake" dev
|
||||
}
|
||||
|
||||
build_backend() {
|
||||
cd "$backend_dir"
|
||||
docker buildx bake -f "$backend_bake" dev
|
||||
}
|
||||
|
||||
build_images() {
|
||||
build_frontend
|
||||
build_backend
|
||||
}
|
||||
|
||||
up_stack() {
|
||||
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate
|
||||
}
|
||||
|
||||
down_stack() {
|
||||
docker compose -f "$compose_file" down --remove-orphans --volumes
|
||||
}
|
||||
|
||||
follow_logs() {
|
||||
docker compose -f "$compose_file" logs -f
|
||||
}
|
||||
|
||||
clean_stack() {
|
||||
docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true
|
||||
remove_docker_image_if_present "$frontend_image"
|
||||
remove_docker_image_if_present "$backend_image"
|
||||
remove_docker_volume_if_present "$frontend_volume"
|
||||
remove_docker_volume_if_present "$backend_go_pkg_volume"
|
||||
remove_docker_volume_if_present "$backend_go_build_volume"
|
||||
rm -rf "$backend_runtime_dir"
|
||||
}
|
||||
|
||||
start_stack() {
|
||||
build_images
|
||||
up_stack
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
build)
|
||||
ensure_docker 'docker is required for the local development stack. Install Docker first.'
|
||||
build_images
|
||||
;;
|
||||
up|start|rebuild)
|
||||
ensure_docker 'docker is required for the local development stack. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
start_stack
|
||||
;;
|
||||
down)
|
||||
ensure_docker 'docker is required for the local development stack. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
down_stack
|
||||
;;
|
||||
restart)
|
||||
ensure_docker 'docker is required for the local development stack. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
docker compose -f "$compose_file" restart
|
||||
;;
|
||||
logs)
|
||||
ensure_docker 'docker is required for the local development stack. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
follow_logs
|
||||
;;
|
||||
clean)
|
||||
ensure_docker 'docker is required for the local development stack. Install Docker first.'
|
||||
ensure_local_env_file "$env_dir"
|
||||
clean_stack
|
||||
;;
|
||||
*)
|
||||
printf 'Unsupported dev stack action: %s\n' "$action" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
20
Commands/Local/Dev/scripts/docker.sh
Normal file
20
Commands/Local/Dev/scripts/docker.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ensure_docker() {
|
||||
local error_message=${1:-docker is required. Install Docker first.}
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
printf '%s\n' "$error_message" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
remove_docker_image_if_present() {
|
||||
docker image rm -f "$1" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
remove_docker_volume_if_present() {
|
||||
docker volume rm -f "$1" >/dev/null 2>&1 || true
|
||||
}
|
||||
21
Commands/Local/Dev/scripts/env.sh
Normal file
21
Commands/Local/Dev/scripts/env.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ensure_local_env_file() {
|
||||
local env_dir=$1
|
||||
local example_env_file="$env_dir/.env.example"
|
||||
local local_env_file="$env_dir/.env.local"
|
||||
|
||||
if [[ -f "$local_env_file" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ ! -f "$example_env_file" ]]; then
|
||||
printf 'Missing env template: %s\n' "$example_env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$example_env_file" "$local_env_file"
|
||||
printf 'Created %s from %s\n' "$local_env_file" "$example_env_file"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
project_root := justfile_directory()
|
||||
frontend_dir := project_root + "/Frontend"
|
||||
frontend_bake := project_root + "/Frontend/docker-bake.hcl"
|
||||
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
|
||||
|
||||
# Build the Frontend development image.
|
||||
build:
|
||||
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' dev
|
||||
|
||||
# Start the local development stack in the background using the current image.
|
||||
up:
|
||||
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
|
||||
|
||||
# Build first, then start the local development stack in the background.
|
||||
start: build
|
||||
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
|
||||
|
||||
# Alias for the main local development flow.
|
||||
dev: start
|
||||
|
||||
# Stop and remove the local development stack.
|
||||
down:
|
||||
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
|
||||
|
||||
# Rebuild the Frontend development image and recreate the stack.
|
||||
rebuild:
|
||||
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' dev
|
||||
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
|
||||
|
||||
# Follow logs for the local development stack.
|
||||
logs:
|
||||
docker compose -f '{{local_compose}}' logs -f
|
||||
|
||||
# Restart the local development stack.
|
||||
restart:
|
||||
docker compose -f '{{local_compose}}' restart
|
||||
@@ -1,2 +1,2 @@
|
||||
mod dev
|
||||
mod dev "Dev"
|
||||
mod prod
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
project_root := justfile_directory()
|
||||
frontend_dir := project_root + "/Frontend"
|
||||
frontend_bake := project_root + "/Frontend/docker-bake.hcl"
|
||||
proxy_bake := project_root + "/Proxy/docker-bake.hcl"
|
||||
local_compose := project_root + "/Docker/docker-compose.local.prod.yaml"
|
||||
proxy_image := "moku/work-proxy:local-prod"
|
||||
|
||||
# Build the Frontend production image locally.
|
||||
# Build the local production proxy image locally.
|
||||
build:
|
||||
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' prod
|
||||
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
|
||||
|
||||
# Rebuild the Frontend production image locally.
|
||||
# Start the local production stack in the background using the current image.
|
||||
up:
|
||||
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
|
||||
|
||||
# Build first, then start the local production stack in the background.
|
||||
start: build up
|
||||
|
||||
# Rebuild the local production proxy image locally.
|
||||
rebuild:
|
||||
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' --set '*.no-cache=true' prod
|
||||
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod
|
||||
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
|
||||
|
||||
# Stop and remove the local production stack.
|
||||
down:
|
||||
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
|
||||
|
||||
# Follow logs for the local production stack.
|
||||
logs:
|
||||
docker compose -f '{{local_compose}}' logs -f
|
||||
|
||||
# Restart the local production stack.
|
||||
restart:
|
||||
docker compose -f '{{local_compose}}' restart
|
||||
|
||||
# Stop the local production stack and remove local images.
|
||||
clean:
|
||||
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
|
||||
docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true
|
||||
|
||||
@@ -1,15 +1,95 @@
|
||||
x-backend-service: &backend-service
|
||||
image: moku/work-backend:dev
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ../Env/.env.local
|
||||
environment:
|
||||
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
|
||||
VALKEY_URL: redis://valkey:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ../Backend:/app
|
||||
- moku_work_backend_go_pkg:/go/pkg/mod
|
||||
- moku_work_backend_go_build:/root/.cache/go-build
|
||||
|
||||
services:
|
||||
frontend:
|
||||
image: moku/work-frontend:dev
|
||||
container_name: moku-work-frontend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ../Env/.env.local
|
||||
ports:
|
||||
- "3333:3333"
|
||||
volumes:
|
||||
- ../Frontend:/app
|
||||
- moku_work_frontend_node_modules:/app/node_modules
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: moku-work-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: moku
|
||||
POSTGRES_USER: moku
|
||||
POSTGRES_PASSWORD: moku_dev_password
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- moku_work_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U moku -d moku"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: moku-work-valkey
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- moku_work_valkey_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
frontend:
|
||||
image: moku/work-frontend:dev
|
||||
container_name: moku-work-frontend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ../Env/.env.local
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3333:3333"
|
||||
volumes:
|
||||
- ../Frontend:/app
|
||||
- moku_work_frontend_node_modules:/app/node_modules
|
||||
|
||||
web:
|
||||
<<: *backend-service
|
||||
container_name: moku-work-backend-web
|
||||
command: ["air", "-c", ".air.web.toml"]
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
api:
|
||||
<<: *backend-service
|
||||
container_name: moku-work-backend-api
|
||||
command: ["air", "-c", ".air.api.toml"]
|
||||
ports:
|
||||
- "8081:8081"
|
||||
|
||||
worker:
|
||||
<<: *backend-service
|
||||
container_name: moku-work-backend-worker
|
||||
command: ["air", "-c", ".air.worker.toml"]
|
||||
|
||||
volumes:
|
||||
moku_work_frontend_node_modules:
|
||||
moku_work_postgres_data:
|
||||
moku_work_valkey_data:
|
||||
moku_work_frontend_node_modules:
|
||||
moku_work_backend_go_pkg:
|
||||
moku_work_backend_go_build:
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
# Reserved for a future local production compose stack.
|
||||
services:
|
||||
proxy:
|
||||
image: moku/work-proxy:local-prod
|
||||
container_name: moku-work-proxy-local
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
@@ -9,8 +9,8 @@ This project is still in an early scaffold stage, so the goal is to keep changes
|
||||
### Project structure
|
||||
|
||||
- `Frontend/` — SolidStart frontend workspace
|
||||
- `Backend/` — backend placeholder
|
||||
- `Proxy/` — proxy placeholder
|
||||
- `Backend/` — Go backend services (`web`, `api`, `worker`)
|
||||
- `Proxy/` — local production proxy/runtime assets
|
||||
- `Docker/` — local Docker Compose files
|
||||
- `Env/` — local environment files
|
||||
- `Commands/` — Just command modules and entrypoints
|
||||
@@ -39,7 +39,8 @@ Main local development flow:
|
||||
just local dev
|
||||
```
|
||||
|
||||
This command builds the frontend development image and starts the local development stack.
|
||||
This command builds the frontend and backend development images, then starts the
|
||||
local development stack for Postgres, Valkey, frontend, and backend services.
|
||||
|
||||
### Local environment
|
||||
|
||||
@@ -49,7 +50,16 @@ Local development uses:
|
||||
Env/.env.local
|
||||
```
|
||||
|
||||
If local environment values are missing, create or update that file before starting the stack.
|
||||
The template lives at:
|
||||
|
||||
```bash
|
||||
Env/.env.example
|
||||
```
|
||||
|
||||
When you start the local Docker stack, the dev scripts will create `Env/.env.local`
|
||||
from `Env/.env.example` automatically if it does not already exist.
|
||||
|
||||
If you need custom local values, edit `Env/.env.local` after it is created.
|
||||
|
||||
## Commit Naming Convention
|
||||
|
||||
|
||||
@@ -11,7 +11,19 @@
|
||||
- [ ] Project-Structure
|
||||
- [ ] Stack-Decisions
|
||||
- [ ] Proxy
|
||||
- [ ] Local-Prod-NGINX-Proxy
|
||||
- [ ] Static-Frontend-Serving
|
||||
- [ ] First-Request-Web-Loader
|
||||
- [ ] Bootstrap-Document
|
||||
- [ ] Route-Intent-Handoff
|
||||
- [ ] Tiny-First-Paint-Budget
|
||||
- [ ] Dev-and-Prod-Builds
|
||||
- [x] Local-Dev-Just-Commands
|
||||
- [x] Local-Dev-Docker-Compose
|
||||
- [ ] Local-Prod-Just-Commands
|
||||
- [ ] Local-Prod-Docker-Compose
|
||||
- [ ] Frontend-Production-Dockerfile
|
||||
- [ ] Frontend-docker-bake
|
||||
|
||||
#### Backend
|
||||
|
||||
@@ -54,6 +66,14 @@
|
||||
- [ ] Table
|
||||
- [ ] CVA
|
||||
- [ ] Storyboard
|
||||
- [ ] Theme-System
|
||||
- [ ] Theme-Registry
|
||||
- [ ] Built-In-Theme-Presets
|
||||
- [ ] Active-Theme-Persistence
|
||||
- [ ] Theme-Switcher
|
||||
- [ ] Theme-JSON-Upload
|
||||
- [ ] Theme-JSON-Import-Validation
|
||||
- [ ] Community-Theme-Readiness
|
||||
|
||||
### Version 0.3.0
|
||||
|
||||
|
||||
13
Env/.env.example
Normal file
13
Env/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
ALLOWED_HOSTS=
|
||||
|
||||
APP_NAME=moku
|
||||
GO_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
BACKEND_WEB_PORT=8080
|
||||
BACKEND_API_PORT=8081
|
||||
BACKEND_WORKER_PORT=8082
|
||||
BACKEND_SHUTDOWN_TIMEOUT=10s
|
||||
|
||||
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
|
||||
VALKEY_URL=redis://localhost:6379/0
|
||||
@@ -1,5 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Frontend development image only.
|
||||
# Production static serving is owned by Proxy/Local/Dockerfile.
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
@@ -16,27 +19,11 @@ FROM dependencies AS development
|
||||
ENV NODE_ENV=development
|
||||
ENV PORT=3333
|
||||
|
||||
ARG ALLOWED_HOSTS
|
||||
ENV ALLOWED_HOSTS=${ALLOWED_HOSTS}
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3333
|
||||
|
||||
CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "3333"]
|
||||
|
||||
FROM dependencies AS build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS production
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3333
|
||||
|
||||
COPY --from=build /app /app
|
||||
|
||||
EXPOSE 3333
|
||||
|
||||
CMD ["pnpm", "start", "--host", "0.0.0.0", "--port", "3333"]
|
||||
|
||||
@@ -17,30 +17,18 @@ target "dev" {
|
||||
tags = ["moku/work-frontend:dev"]
|
||||
}
|
||||
|
||||
target "prod" {
|
||||
inherits = ["_app"]
|
||||
target = "production"
|
||||
tags = ["moku/work-frontend:prod"]
|
||||
}
|
||||
|
||||
target "dev-image" {
|
||||
inherits = ["_app"]
|
||||
target = "development"
|
||||
tags = ["${REGISTRY}/moku/work-frontend:dev-${TAG}"]
|
||||
}
|
||||
|
||||
target "prod-image" {
|
||||
inherits = ["_app"]
|
||||
target = "production"
|
||||
tags = ["${REGISTRY}/moku/work-frontend:prod-${TAG}"]
|
||||
}
|
||||
|
||||
group "local" {
|
||||
targets = ["dev", "prod"]
|
||||
targets = ["dev"]
|
||||
}
|
||||
|
||||
group "registry" {
|
||||
targets = ["dev-image", "prod-image"]
|
||||
targets = ["dev-image"]
|
||||
}
|
||||
|
||||
group "default" {
|
||||
|
||||
@@ -11,19 +11,22 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "vite start",
|
||||
"build:static": "pnpm build && node ./scripts/render-static-index.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "vite preview",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-solid": "^0.542.0",
|
||||
"@solidjs/start": "2.0.0-alpha.2",
|
||||
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
|
||||
"lucide-solid": "^0.542.0",
|
||||
"postcss": "^8.5.15",
|
||||
"sass": "^1.101.0",
|
||||
"solid-js": "^1.9.5",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"cssnano": "^8.0.2",
|
||||
"postcss-preset-env": "^11.3.0",
|
||||
|
||||
54
Frontend/pnpm-lock.yaml
generated
54
Frontend/pnpm-lock.yaml
generated
@@ -10,10 +10,10 @@ importers:
|
||||
dependencies:
|
||||
'@solidjs/start':
|
||||
specifier: 2.0.0-alpha.2
|
||||
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
'@solidjs/vite-plugin-nitro-2':
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
version: 0.1.0(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
lucide-solid:
|
||||
specifier: ^0.542.0
|
||||
version: 0.542.0(solid-js@1.9.11)
|
||||
@@ -28,8 +28,11 @@ importers:
|
||||
version: 1.9.11
|
||||
vite:
|
||||
specifier: ^7.0.0
|
||||
version: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
version: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.9.3
|
||||
version: 25.9.3
|
||||
autoprefixer:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(postcss@8.5.15)
|
||||
@@ -1262,6 +1265,9 @@ packages:
|
||||
'@types/micromatch@4.0.10':
|
||||
resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==}
|
||||
|
||||
'@types/node@25.9.3':
|
||||
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
|
||||
|
||||
'@types/resolve@1.20.2':
|
||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||
|
||||
@@ -3217,6 +3223,9 @@ packages:
|
||||
unctx@2.5.0:
|
||||
resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==}
|
||||
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||
|
||||
@@ -4476,13 +4485,13 @@ snapshots:
|
||||
dependencies:
|
||||
solid-js: 1.9.11
|
||||
|
||||
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
|
||||
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
'@solidjs/meta': 0.29.4(solid-js@1.9.11)
|
||||
'@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
'@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
'@types/babel__traverse': 7.28.0
|
||||
'@types/micromatch': 4.0.10
|
||||
cookie-es: 2.0.0
|
||||
@@ -4504,17 +4513,17 @@ snapshots:
|
||||
source-map-js: 1.2.1
|
||||
srvx: 0.9.8
|
||||
terracotta: 1.1.0(solid-js@1.9.11)
|
||||
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
transitivePeerDependencies:
|
||||
- '@testing-library/jest-dom'
|
||||
- crossws
|
||||
- supports-color
|
||||
|
||||
'@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
|
||||
'@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
|
||||
dependencies:
|
||||
nitropack: 2.13.1
|
||||
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
@@ -4549,7 +4558,7 @@ snapshots:
|
||||
|
||||
'@speed-highlight/core@1.2.14': {}
|
||||
|
||||
'@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
|
||||
'@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/core': 7.29.0
|
||||
@@ -4559,7 +4568,7 @@ snapshots:
|
||||
babel-dead-code-elimination: 1.0.12
|
||||
pathe: 2.0.3
|
||||
tiny-invariant: 1.3.3
|
||||
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4576,7 +4585,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
|
||||
'@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/core': 7.29.0
|
||||
@@ -4585,7 +4594,7 @@ snapshots:
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
'@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
'@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
babel-dead-code-elimination: 1.0.12
|
||||
tiny-invariant: 1.3.3
|
||||
transitivePeerDependencies:
|
||||
@@ -4629,6 +4638,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/braces': 3.0.5
|
||||
|
||||
'@types/node@25.9.3':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/resolve@1.20.2': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@@ -6753,6 +6766,8 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
unplugin: 2.3.11
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
@@ -6876,7 +6891,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
|
||||
vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@types/babel__core': 7.20.5
|
||||
@@ -6884,12 +6899,12 @@ snapshots:
|
||||
merge-anything: 5.1.7
|
||||
solid-js: 1.9.11
|
||||
solid-refresh: 0.6.3(solid-js@1.9.11)
|
||||
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
vitefu: 1.1.2(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
vitefu: 1.1.2(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0):
|
||||
vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -6898,15 +6913,16 @@ snapshots:
|
||||
rollup: 4.59.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.3
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
sass: 1.101.0
|
||||
sass-embedded: 1.100.0
|
||||
terser: 5.46.0
|
||||
|
||||
vitefu@1.1.2(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
|
||||
vitefu@1.1.2(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
|
||||
170
Frontend/public/themes/moku-default.json
Normal file
170
Frontend/public/themes/moku-default.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"id": "moku-default",
|
||||
"name": "Moku Default",
|
||||
"description": "Baseline Moku shell tokens for built-in light and dark modes.",
|
||||
"tokens": {
|
||||
"shared": {
|
||||
"palette": {
|
||||
"gray": {
|
||||
"0": "hsl(210 20% 99%)",
|
||||
"50": "hsl(220 20% 97%)",
|
||||
"100": "hsl(220 16% 93%)",
|
||||
"200": "hsl(220 13% 87%)",
|
||||
"300": "hsl(220 11% 75%)",
|
||||
"400": "hsl(220 9% 58%)",
|
||||
"500": "hsl(220 10% 45%)",
|
||||
"600": "hsl(220 14% 34%)",
|
||||
"700": "hsl(220 18% 24%)",
|
||||
"800": "hsl(220 22% 16%)",
|
||||
"900": "hsl(220 28% 10%)"
|
||||
},
|
||||
"blue": {
|
||||
"400": "hsl(218 88% 61%)",
|
||||
"500": "hsl(221 83% 53%)",
|
||||
"600": "hsl(224 76% 48%)"
|
||||
},
|
||||
"green": {
|
||||
"500": "hsl(154 60% 40%)"
|
||||
},
|
||||
"red": {
|
||||
"500": "hsl(0 72% 54%)"
|
||||
},
|
||||
"amber": {
|
||||
"500": "hsl(36 100% 50%)"
|
||||
}
|
||||
},
|
||||
"space": {
|
||||
"1": "0.25rem",
|
||||
"2": "0.5rem",
|
||||
"3": "0.75rem",
|
||||
"4": "1rem",
|
||||
"5": "1.25rem",
|
||||
"6": "1.5rem",
|
||||
"8": "2rem",
|
||||
"10": "2.5rem",
|
||||
"12": "3rem"
|
||||
},
|
||||
"radius": {
|
||||
"sm": "0.375rem",
|
||||
"md": "0.625rem",
|
||||
"lg": "0.875rem",
|
||||
"xl": "1.25rem",
|
||||
"pill": "999px"
|
||||
},
|
||||
"size": {
|
||||
"controlMd": "2.25rem",
|
||||
"controlLg": "2.5rem",
|
||||
"contentWidthWide": "72rem",
|
||||
"blurOverlay": "18px"
|
||||
},
|
||||
"shadow": {
|
||||
"soft": "0 12px 32px hsl(220 30% 10% / 0.08)",
|
||||
"strong": "0 20px 48px hsl(220 30% 10% / 0.16)"
|
||||
},
|
||||
"zIndex": {
|
||||
"base": "1",
|
||||
"dropdown": "100",
|
||||
"sticky": "200",
|
||||
"overlay": "400",
|
||||
"modal": "500",
|
||||
"toast": "600"
|
||||
},
|
||||
"motion": {
|
||||
"durationFast": "140ms",
|
||||
"durationBase": "220ms",
|
||||
"durationSlow": "320ms",
|
||||
"easeStandard": "cubic-bezier(0.2, 0.8, 0.2, 1)"
|
||||
},
|
||||
"typography": {
|
||||
"fontFamily": {
|
||||
"sans": "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
|
||||
"heading": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
|
||||
"display": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
|
||||
"serif": "ui-serif, Georgia, Cambria, \"Times New Roman\", Times, serif",
|
||||
"mono": "ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace"
|
||||
},
|
||||
"fontSize": {
|
||||
"caption": "0.75rem",
|
||||
"label": "0.875rem",
|
||||
"body": "1rem",
|
||||
"title": "clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem)",
|
||||
"heading": "clamp(1.5rem, 1.2rem + 1vw, 2.125rem)",
|
||||
"display": "clamp(2.25rem, 1.7rem + 2.2vw, 3.75rem)"
|
||||
},
|
||||
"lineHeight": {
|
||||
"caption": "1.4",
|
||||
"label": "1.35",
|
||||
"body": "1.55",
|
||||
"title": "1.3",
|
||||
"heading": "1.15",
|
||||
"display": "1.05"
|
||||
},
|
||||
"fontWeight": {
|
||||
"caption": "500",
|
||||
"label": "600",
|
||||
"body": "400",
|
||||
"title": "600",
|
||||
"heading": "600",
|
||||
"display": "700"
|
||||
},
|
||||
"letterSpacing": {
|
||||
"caption": "0.01em",
|
||||
"label": "0.005em",
|
||||
"body": "0",
|
||||
"title": "-0.01em",
|
||||
"heading": "-0.02em",
|
||||
"display": "-0.03em"
|
||||
}
|
||||
}
|
||||
},
|
||||
"modes": {
|
||||
"light": {
|
||||
"colorScheme": "light",
|
||||
"colors": {
|
||||
"canvas": "var(--gray-50)",
|
||||
"surface": "hsl(0 0% 100% / 0.9)",
|
||||
"surfaceMuted": "var(--gray-0)",
|
||||
"surfaceHover": "var(--gray-100)",
|
||||
"border": "hsl(220 15% 85% / 0.9)",
|
||||
"borderStrong": "hsl(220 12% 70% / 0.9)",
|
||||
"text": "var(--gray-800)",
|
||||
"textMuted": "var(--gray-500)",
|
||||
"accent": "var(--blue-500)",
|
||||
"accentStrong": "var(--blue-600)",
|
||||
"accentSoft": "hsl(218 88% 61% / 0.12)",
|
||||
"accentContrast": "hsl(0 0% 100%)",
|
||||
"success": "var(--green-500)",
|
||||
"danger": "var(--red-500)",
|
||||
"warning": "var(--amber-500)",
|
||||
"focusRing": "hsl(221 83% 53% / 0.55)"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"colorScheme": "dark",
|
||||
"colors": {
|
||||
"canvas": "var(--gray-900)",
|
||||
"surface": "hsl(220 23% 14% / 0.92)",
|
||||
"surfaceMuted": "hsl(220 22% 12% / 0.96)",
|
||||
"surfaceHover": "hsl(220 18% 20% / 0.96)",
|
||||
"border": "hsl(220 12% 26% / 0.9)",
|
||||
"borderStrong": "hsl(220 12% 38% / 0.9)",
|
||||
"text": "hsl(210 20% 96%)",
|
||||
"textMuted": "hsl(220 12% 70%)",
|
||||
"accent": "hsl(217 91% 67%)",
|
||||
"accentStrong": "hsl(218 88% 61%)",
|
||||
"accentSoft": "hsl(217 91% 67% / 0.18)",
|
||||
"accentContrast": "hsl(220 28% 10%)",
|
||||
"success": "hsl(154 55% 48%)",
|
||||
"danger": "hsl(0 72% 62%)",
|
||||
"warning": "hsl(36 100% 60%)",
|
||||
"focusRing": "hsl(217 91% 67% / 0.65)"
|
||||
},
|
||||
"shadow": {
|
||||
"soft": "0 16px 40px hsl(220 40% 3% / 0.45)",
|
||||
"strong": "0 24px 60px hsl(220 40% 3% / 0.55)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
Frontend/scripts/render-static-index.mjs
Normal file
49
Frontend/scripts/render-static-index.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const manifestPath = resolve("dist/client/.vite/manifest.json");
|
||||
const outputPath = resolve("dist/client/index.html");
|
||||
|
||||
const themeBootstrapScript = `
|
||||
(() => {
|
||||
try {
|
||||
const storageKey = "theme";
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
const theme = stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
} catch {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
const entry = manifest["src/entry-client.tsx"];
|
||||
|
||||
if (!entry?.file) {
|
||||
throw new Error("Could not find src/entry-client.tsx in the client manifest.");
|
||||
}
|
||||
|
||||
const cssLinks = Array.isArray(entry.css) ? entry.css.map((href) => ` <link rel="stylesheet" href="/${href}">`).join("\n") : "";
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<script>${themeBootstrapScript}</script>
|
||||
${cssLinks}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/${entry.file}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, html, "utf8");
|
||||
@@ -1,7 +1,7 @@
|
||||
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
|
||||
|
||||
import { createSignal, onMount, type JSX } from "solid-js";
|
||||
import { getDocumentTheme, setTheme, type Theme } from "../../../helpers/theme";
|
||||
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
|
||||
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
|
||||
import { LeftRail } from "../LeftRail/LeftRail";
|
||||
import { ProfileDock } from "../ProfileDock/ProfileDock";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx
|
||||
|
||||
import { For, type JSX } from "solid-js";
|
||||
import type { Theme } from "../../../helpers/theme";
|
||||
import { ChevronDown } from "../../../lib/icons";
|
||||
import type { Theme } from "../../../theme/runtime";
|
||||
import { topBarActions } from "../data/shell.data";
|
||||
import styles from "./TopBar.module.scss";
|
||||
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
// Path: Frontend/src/entry-client.tsx
|
||||
|
||||
// @refresh reload
|
||||
import { mount, StartClient } from "@solidjs/start/client";
|
||||
import type { JSX } from "solid-js";
|
||||
import { initializeThemeRuntime } from "./theme/runtime";
|
||||
|
||||
mount(() => <StartClient />, document.getElementById("app")!);
|
||||
const getAppRoot = (): HTMLElement => {
|
||||
const appRoot = document.getElementById("app");
|
||||
|
||||
if (!(appRoot instanceof HTMLElement)) {
|
||||
throw new Error("App root element '#app' was not found.");
|
||||
}
|
||||
|
||||
return appRoot;
|
||||
};
|
||||
|
||||
const mountApp = (): void => {
|
||||
mount((): JSX.Element => <StartClient />, getAppRoot());
|
||||
};
|
||||
|
||||
void initializeThemeRuntime();
|
||||
|
||||
mountApp();
|
||||
|
||||
@@ -1,40 +1,53 @@
|
||||
// Path: Frontend/src/entry-server.tsx
|
||||
|
||||
// @refresh reload
|
||||
import type { JSX } from "solid-js";
|
||||
import { createHandler, StartServer } from "@solidjs/start/server";
|
||||
import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./theme/runtime";
|
||||
import { THEME_MODE_NAMES } from "./theme/schema";
|
||||
|
||||
const themeBootstrapScript = `
|
||||
(() => {
|
||||
try {
|
||||
const storageKey = "theme";
|
||||
const storageKey = ${JSON.stringify(THEME_STORAGE_KEY)};
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
const theme = stored === "light" || stored === "dark"
|
||||
const theme = stored === ${JSON.stringify(THEME_MODE_NAMES[0])} || stored === ${JSON.stringify(THEME_MODE_NAMES[1])}
|
||||
? stored
|
||||
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? ${JSON.stringify(THEME_MODE_NAMES[1])} : ${JSON.stringify(DEFAULT_THEME)});
|
||||
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
} catch {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
document.documentElement.setAttribute("data-theme", ${JSON.stringify(DEFAULT_THEME)});
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default createHandler(() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script innerHTML={themeBootstrapScript} />
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
type DocumentRenderProps = {
|
||||
assets?: JSX.Element;
|
||||
children?: JSX.Element;
|
||||
scripts?: JSX.Element;
|
||||
};
|
||||
|
||||
const renderDocument = ({ assets, children, scripts }: DocumentRenderProps): JSX.Element => {
|
||||
return (
|
||||
<html lang="en" data-theme={DEFAULT_THEME}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script innerHTML={themeBootstrapScript} />
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
const serverHandler = createHandler((): JSX.Element => {
|
||||
return <StartServer document={renderDocument} />;
|
||||
});
|
||||
|
||||
export default serverHandler;
|
||||
|
||||
2
Frontend/src/global.d.ts
vendored
2
Frontend/src/global.d.ts
vendored
@@ -1 +1,3 @@
|
||||
// Path: Frontend/src/global.d.ts
|
||||
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// Path: Frontend/src/helpers/theme.ts
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
export const THEME_STORAGE_KEY = "theme";
|
||||
|
||||
export const resolvePreferredTheme = (): Theme => {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
|
||||
if (stored === "light" || stored === "dark") {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
};
|
||||
|
||||
export const getDocumentTheme = (): Theme => (document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light");
|
||||
|
||||
export const setTheme = (theme: Theme) => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
// Path: Frontend/src/lib/icons/index.ts
|
||||
|
||||
export { Bell, Check, ChevronDown, ChevronLeft, ChevronRight, Folder, FolderOpen, Home, LayoutGrid, MoreHorizontal, Plus, Search, Settings, User, X } from "lucide-solid";
|
||||
export { default as Bell } from "lucide-solid/icons/bell";
|
||||
export { default as ChevronDown } from "lucide-solid/icons/chevron-down";
|
||||
export { default as Folder } from "lucide-solid/icons/folder";
|
||||
export { default as Home } from "lucide-solid/icons/house";
|
||||
export { default as LayoutGrid } from "lucide-solid/icons/layout-grid";
|
||||
export { default as Plus } from "lucide-solid/icons/plus";
|
||||
export { default as Search } from "lucide-solid/icons/search";
|
||||
export { default as Settings } from "lucide-solid/icons/settings";
|
||||
export { default as User } from "lucide-solid/icons/user";
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/* Path: Frontend/src/styles/tools/_functions.scss */
|
||||
|
||||
@function rem($pixels, $base: 16) {
|
||||
@return ($pixels / $base) * 1rem;
|
||||
}
|
||||
11
Frontend/src/theme/presets.ts
Normal file
11
Frontend/src/theme/presets.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Path: Frontend/src/theme/presets.ts
|
||||
|
||||
import type { ThemeDefinition } from "./schema";
|
||||
|
||||
export const defaultThemePresetPath = "/themes/moku-default.json";
|
||||
|
||||
export const defaultThemePresetMeta = {
|
||||
id: "moku-default",
|
||||
name: "Moku Default",
|
||||
description: "The baseline Moku theme preset, matching the current shell styling tokens.",
|
||||
} satisfies Pick<ThemeDefinition, "id" | "name" | "description">;
|
||||
129
Frontend/src/theme/runtime.ts
Normal file
129
Frontend/src/theme/runtime.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Path: Frontend/src/theme/runtime.ts
|
||||
|
||||
import { defaultThemePresetPath } from "./presets";
|
||||
import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema";
|
||||
|
||||
export type Theme = ThemeModeName;
|
||||
|
||||
export const THEME_STORAGE_KEY = "theme";
|
||||
export const DEFAULT_THEME: Theme = "light";
|
||||
|
||||
let activeThemeDefinition: ThemeDefinition | null = null;
|
||||
let themeInitializationPromise: Promise<ThemeDefinition | null> | null = null;
|
||||
|
||||
const canUseDom = (): boolean => typeof document !== "undefined";
|
||||
|
||||
const canUseStorage = (): boolean => typeof localStorage !== "undefined";
|
||||
|
||||
const canUseMatchMedia = (): boolean => typeof window !== "undefined" && typeof window.matchMedia === "function";
|
||||
|
||||
const getRootElement = (): HTMLElement | null => {
|
||||
return canUseDom() ? document.documentElement : null;
|
||||
};
|
||||
|
||||
const setDocumentThemeMode = (theme: Theme): void => {
|
||||
const rootElement = getRootElement();
|
||||
|
||||
rootElement?.setAttribute("data-theme", theme);
|
||||
|
||||
if (rootElement) {
|
||||
rootElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
if (canUseStorage()) {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
};
|
||||
|
||||
const applyThemeVariables = (themeDefinition: ThemeDefinition, theme: Theme): void => {
|
||||
const rootElement = getRootElement();
|
||||
|
||||
if (!rootElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variableMap = createCssVariableMap(themeDefinition, theme);
|
||||
|
||||
for (const [name, value] of Object.entries(variableMap)) {
|
||||
rootElement.style.setProperty(name, value);
|
||||
}
|
||||
};
|
||||
|
||||
export const resolvePreferredTheme = (): Theme => {
|
||||
const stored = canUseStorage() ? localStorage.getItem(THEME_STORAGE_KEY) : null;
|
||||
|
||||
if (isThemeModeName(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (canUseMatchMedia()) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
return DEFAULT_THEME;
|
||||
};
|
||||
|
||||
export const getDocumentTheme = (): Theme => {
|
||||
const theme = getRootElement()?.getAttribute("data-theme");
|
||||
|
||||
return isThemeModeName(theme) ? theme : resolvePreferredTheme();
|
||||
};
|
||||
|
||||
export const applyThemeDefinition = (themeDefinition: ThemeDefinition, theme: Theme): void => {
|
||||
activeThemeDefinition = themeDefinition;
|
||||
setDocumentThemeMode(theme);
|
||||
applyThemeVariables(themeDefinition, theme);
|
||||
};
|
||||
|
||||
export const initializeThemeRuntime = async (): Promise<ThemeDefinition | null> => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeThemeDefinition) {
|
||||
applyThemeDefinition(activeThemeDefinition, getDocumentTheme());
|
||||
return activeThemeDefinition;
|
||||
}
|
||||
|
||||
if (!themeInitializationPromise) {
|
||||
themeInitializationPromise = (async (): Promise<ThemeDefinition | null> => {
|
||||
try {
|
||||
const response = await fetch(defaultThemePresetPath, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Theme preset request failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
const candidate = (await response.json()) as unknown;
|
||||
const result = validateThemeDefinition(candidate);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.errors.join(" "));
|
||||
}
|
||||
|
||||
applyThemeDefinition(result.data, getDocumentTheme());
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize theme runtime.", error);
|
||||
return null;
|
||||
} finally {
|
||||
themeInitializationPromise = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return themeInitializationPromise;
|
||||
};
|
||||
|
||||
export const setTheme = (theme: Theme): void => {
|
||||
setDocumentThemeMode(theme);
|
||||
|
||||
if (activeThemeDefinition) {
|
||||
applyThemeVariables(activeThemeDefinition, theme);
|
||||
}
|
||||
};
|
||||
342
Frontend/src/theme/schema.ts
Normal file
342
Frontend/src/theme/schema.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
// Path: Frontend/src/theme/schema.ts
|
||||
|
||||
export const THEME_SCHEMA_VERSION = "1.0.0";
|
||||
export const THEME_MODE_NAMES = ["light", "dark"] as const;
|
||||
export const THEME_PALETTE_KEYS = {
|
||||
gray: ["0", "50", "100", "200", "300", "400", "500", "600", "700", "800", "900"],
|
||||
blue: ["400", "500", "600"],
|
||||
green: ["500"],
|
||||
red: ["500"],
|
||||
amber: ["500"],
|
||||
} as const;
|
||||
export const THEME_SPACE_KEYS = ["1", "2", "3", "4", "5", "6", "8", "10", "12"] as const;
|
||||
export const THEME_RADIUS_KEYS = ["sm", "md", "lg", "xl", "pill"] as const;
|
||||
export const THEME_SIZE_KEYS = ["controlMd", "controlLg", "contentWidthWide", "blurOverlay"] as const;
|
||||
export const THEME_SHADOW_KEYS = ["soft", "strong"] as const;
|
||||
export const THEME_Z_INDEX_KEYS = ["base", "dropdown", "sticky", "overlay", "modal", "toast"] as const;
|
||||
export const THEME_MOTION_KEYS = ["durationFast", "durationBase", "durationSlow", "easeStandard"] as const;
|
||||
export const THEME_TYPE_SCALE_KEYS = ["caption", "label", "body", "title", "heading", "display"] as const;
|
||||
export const THEME_FONT_FAMILY_KEYS = ["sans", "heading", "display", "serif", "mono"] as const;
|
||||
export const THEME_MODE_COLOR_KEYS = ["canvas", "surface", "surfaceMuted", "surfaceHover", "border", "borderStrong", "text", "textMuted", "accent", "accentStrong", "accentSoft", "accentContrast", "success", "danger", "warning", "focusRing"] as const;
|
||||
|
||||
export type ThemeModeName = (typeof THEME_MODE_NAMES)[number];
|
||||
|
||||
type ThemeTokenMap<TKey extends string> = Record<TKey, string>;
|
||||
|
||||
export type ThemeSharedTokens = {
|
||||
palette: {
|
||||
gray: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.gray)[number]>;
|
||||
blue: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.blue)[number]>;
|
||||
green: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.green)[number]>;
|
||||
red: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.red)[number]>;
|
||||
amber: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.amber)[number]>;
|
||||
};
|
||||
space: ThemeTokenMap<(typeof THEME_SPACE_KEYS)[number]>;
|
||||
radius: ThemeTokenMap<(typeof THEME_RADIUS_KEYS)[number]>;
|
||||
size: ThemeTokenMap<(typeof THEME_SIZE_KEYS)[number]>;
|
||||
shadow: ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>;
|
||||
zIndex: ThemeTokenMap<(typeof THEME_Z_INDEX_KEYS)[number]>;
|
||||
motion: ThemeTokenMap<(typeof THEME_MOTION_KEYS)[number]>;
|
||||
typography: {
|
||||
fontFamily: ThemeTokenMap<(typeof THEME_FONT_FAMILY_KEYS)[number]>;
|
||||
fontSize: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
|
||||
lineHeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
|
||||
fontWeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
|
||||
letterSpacing: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ThemeModeTokens = {
|
||||
colorScheme: ThemeModeName;
|
||||
colors: ThemeTokenMap<(typeof THEME_MODE_COLOR_KEYS)[number]>;
|
||||
shadow?: Partial<ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>>;
|
||||
};
|
||||
|
||||
export type ThemeDefinition = {
|
||||
schemaVersion: typeof THEME_SCHEMA_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
tokens: {
|
||||
shared: ThemeSharedTokens;
|
||||
modes: Record<ThemeModeName, ThemeModeTokens>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ThemeValidationResult =
|
||||
| {
|
||||
success: true;
|
||||
data: ThemeDefinition;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
const getNestedRecord = (value: unknown, path: string, errors: string[]): Record<string, unknown> | null => {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${path} must be an object.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const validateRequiredStringMap = (record: Record<string, unknown>, path: string, requiredKeys: readonly string[], errors: string[]): void => {
|
||||
for (const key of requiredKeys) {
|
||||
if (!isNonEmptyString(record[key])) {
|
||||
errors.push(`${path}.${key} must be a non-empty string token value.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isThemeModeName = (value: unknown): value is ThemeModeName => typeof value === "string" && THEME_MODE_NAMES.includes(value as ThemeModeName);
|
||||
|
||||
export const validateThemeDefinition = (candidate: unknown): ThemeValidationResult => {
|
||||
const errors: string[] = [];
|
||||
const root = getNestedRecord(candidate, "theme", errors);
|
||||
|
||||
if (!root) {
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
if (root.schemaVersion !== THEME_SCHEMA_VERSION) {
|
||||
errors.push(`theme.schemaVersion must be ${THEME_SCHEMA_VERSION}.`);
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(root.id)) {
|
||||
errors.push("theme.id must be a non-empty string.");
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(root.name)) {
|
||||
errors.push("theme.name must be a non-empty string.");
|
||||
}
|
||||
|
||||
if (root.description !== undefined && !isNonEmptyString(root.description)) {
|
||||
errors.push("theme.description must be a non-empty string when provided.");
|
||||
}
|
||||
|
||||
if (root.author !== undefined && !isNonEmptyString(root.author)) {
|
||||
errors.push("theme.author must be a non-empty string when provided.");
|
||||
}
|
||||
|
||||
const tokens = getNestedRecord(root.tokens, "theme.tokens", errors);
|
||||
const shared = tokens ? getNestedRecord(tokens.shared, "theme.tokens.shared", errors) : null;
|
||||
const modes = tokens ? getNestedRecord(tokens.modes, "theme.tokens.modes", errors) : null;
|
||||
|
||||
if (shared) {
|
||||
const palette = getNestedRecord(shared.palette, "theme.tokens.shared.palette", errors);
|
||||
const space = getNestedRecord(shared.space, "theme.tokens.shared.space", errors);
|
||||
const radius = getNestedRecord(shared.radius, "theme.tokens.shared.radius", errors);
|
||||
const size = getNestedRecord(shared.size, "theme.tokens.shared.size", errors);
|
||||
const shadow = getNestedRecord(shared.shadow, "theme.tokens.shared.shadow", errors);
|
||||
const zIndex = getNestedRecord(shared.zIndex, "theme.tokens.shared.zIndex", errors);
|
||||
const motion = getNestedRecord(shared.motion, "theme.tokens.shared.motion", errors);
|
||||
const typography = getNestedRecord(shared.typography, "theme.tokens.shared.typography", errors);
|
||||
|
||||
if (palette) {
|
||||
for (const [paletteName, keys] of Object.entries(THEME_PALETTE_KEYS)) {
|
||||
const paletteScale = getNestedRecord(palette[paletteName], `theme.tokens.shared.palette.${paletteName}`, errors);
|
||||
|
||||
if (paletteScale) {
|
||||
validateRequiredStringMap(paletteScale, `theme.tokens.shared.palette.${paletteName}`, keys, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (space) {
|
||||
validateRequiredStringMap(space, "theme.tokens.shared.space", THEME_SPACE_KEYS, errors);
|
||||
}
|
||||
|
||||
if (radius) {
|
||||
validateRequiredStringMap(radius, "theme.tokens.shared.radius", THEME_RADIUS_KEYS, errors);
|
||||
}
|
||||
|
||||
if (size) {
|
||||
validateRequiredStringMap(size, "theme.tokens.shared.size", THEME_SIZE_KEYS, errors);
|
||||
}
|
||||
|
||||
if (shadow) {
|
||||
validateRequiredStringMap(shadow, "theme.tokens.shared.shadow", THEME_SHADOW_KEYS, errors);
|
||||
}
|
||||
|
||||
if (zIndex) {
|
||||
validateRequiredStringMap(zIndex, "theme.tokens.shared.zIndex", THEME_Z_INDEX_KEYS, errors);
|
||||
}
|
||||
|
||||
if (motion) {
|
||||
validateRequiredStringMap(motion, "theme.tokens.shared.motion", THEME_MOTION_KEYS, errors);
|
||||
}
|
||||
|
||||
if (typography) {
|
||||
const fontFamily = getNestedRecord(typography.fontFamily, "theme.tokens.shared.typography.fontFamily", errors);
|
||||
const fontSize = getNestedRecord(typography.fontSize, "theme.tokens.shared.typography.fontSize", errors);
|
||||
const lineHeight = getNestedRecord(typography.lineHeight, "theme.tokens.shared.typography.lineHeight", errors);
|
||||
const fontWeight = getNestedRecord(typography.fontWeight, "theme.tokens.shared.typography.fontWeight", errors);
|
||||
const letterSpacing = getNestedRecord(typography.letterSpacing, "theme.tokens.shared.typography.letterSpacing", errors);
|
||||
|
||||
if (fontFamily) {
|
||||
validateRequiredStringMap(fontFamily, "theme.tokens.shared.typography.fontFamily", THEME_FONT_FAMILY_KEYS, errors);
|
||||
}
|
||||
|
||||
if (fontSize) {
|
||||
validateRequiredStringMap(fontSize, "theme.tokens.shared.typography.fontSize", THEME_TYPE_SCALE_KEYS, errors);
|
||||
}
|
||||
|
||||
if (lineHeight) {
|
||||
validateRequiredStringMap(lineHeight, "theme.tokens.shared.typography.lineHeight", THEME_TYPE_SCALE_KEYS, errors);
|
||||
}
|
||||
|
||||
if (fontWeight) {
|
||||
validateRequiredStringMap(fontWeight, "theme.tokens.shared.typography.fontWeight", THEME_TYPE_SCALE_KEYS, errors);
|
||||
}
|
||||
|
||||
if (letterSpacing) {
|
||||
validateRequiredStringMap(letterSpacing, "theme.tokens.shared.typography.letterSpacing", THEME_TYPE_SCALE_KEYS, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modes) {
|
||||
for (const modeName of THEME_MODE_NAMES) {
|
||||
const mode = getNestedRecord(modes[modeName], `theme.tokens.modes.${modeName}`, errors);
|
||||
|
||||
if (!mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode.colorScheme !== modeName) {
|
||||
errors.push(`theme.tokens.modes.${modeName}.colorScheme must be ${modeName}.`);
|
||||
}
|
||||
|
||||
const colors = getNestedRecord(mode.colors, `theme.tokens.modes.${modeName}.colors`, errors);
|
||||
|
||||
if (colors) {
|
||||
validateRequiredStringMap(colors, `theme.tokens.modes.${modeName}.colors`, THEME_MODE_COLOR_KEYS, errors);
|
||||
}
|
||||
|
||||
if (mode.shadow !== undefined) {
|
||||
const modeShadow = getNestedRecord(mode.shadow, `theme.tokens.modes.${modeName}.shadow`, errors);
|
||||
|
||||
if (modeShadow) {
|
||||
for (const key of THEME_SHADOW_KEYS) {
|
||||
if (modeShadow[key] !== undefined && !isNonEmptyString(modeShadow[key])) {
|
||||
errors.push(`theme.tokens.modes.${modeName}.shadow.${key} must be a non-empty string token value when provided.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
return { success: true, data: candidate as ThemeDefinition };
|
||||
};
|
||||
|
||||
export const createCssVariableMap = (theme: ThemeDefinition, mode: ThemeModeName): Record<string, string> => {
|
||||
const shared = theme.tokens.shared;
|
||||
const modeTokens = theme.tokens.modes[mode];
|
||||
|
||||
return {
|
||||
"--gray-0": shared.palette.gray["0"],
|
||||
"--gray-50": shared.palette.gray["50"],
|
||||
"--gray-100": shared.palette.gray["100"],
|
||||
"--gray-200": shared.palette.gray["200"],
|
||||
"--gray-300": shared.palette.gray["300"],
|
||||
"--gray-400": shared.palette.gray["400"],
|
||||
"--gray-500": shared.palette.gray["500"],
|
||||
"--gray-600": shared.palette.gray["600"],
|
||||
"--gray-700": shared.palette.gray["700"],
|
||||
"--gray-800": shared.palette.gray["800"],
|
||||
"--gray-900": shared.palette.gray["900"],
|
||||
"--blue-400": shared.palette.blue["400"],
|
||||
"--blue-500": shared.palette.blue["500"],
|
||||
"--blue-600": shared.palette.blue["600"],
|
||||
"--green-500": shared.palette.green["500"],
|
||||
"--red-500": shared.palette.red["500"],
|
||||
"--amber-500": shared.palette.amber["500"],
|
||||
"--space-1": shared.space["1"],
|
||||
"--space-2": shared.space["2"],
|
||||
"--space-3": shared.space["3"],
|
||||
"--space-4": shared.space["4"],
|
||||
"--space-5": shared.space["5"],
|
||||
"--space-6": shared.space["6"],
|
||||
"--space-8": shared.space["8"],
|
||||
"--space-10": shared.space["10"],
|
||||
"--space-12": shared.space["12"],
|
||||
"--radius-sm": shared.radius.sm,
|
||||
"--radius-md": shared.radius.md,
|
||||
"--radius-lg": shared.radius.lg,
|
||||
"--radius-xl": shared.radius.xl,
|
||||
"--radius-pill": shared.radius.pill,
|
||||
"--control-size-md": shared.size.controlMd,
|
||||
"--control-size-lg": shared.size.controlLg,
|
||||
"--content-width-wide": shared.size.contentWidthWide,
|
||||
"--blur-overlay": shared.size.blurOverlay,
|
||||
"--shadow-soft": modeTokens.shadow?.soft ?? shared.shadow.soft,
|
||||
"--shadow-strong": modeTokens.shadow?.strong ?? shared.shadow.strong,
|
||||
"--z-base": shared.zIndex.base,
|
||||
"--z-dropdown": shared.zIndex.dropdown,
|
||||
"--z-sticky": shared.zIndex.sticky,
|
||||
"--z-overlay": shared.zIndex.overlay,
|
||||
"--z-modal": shared.zIndex.modal,
|
||||
"--z-toast": shared.zIndex.toast,
|
||||
"--motion-duration-fast": shared.motion.durationFast,
|
||||
"--motion-duration-base": shared.motion.durationBase,
|
||||
"--motion-duration-slow": shared.motion.durationSlow,
|
||||
"--motion-ease-standard": shared.motion.easeStandard,
|
||||
"--font-family-sans": shared.typography.fontFamily.sans,
|
||||
"--font-family-heading": shared.typography.fontFamily.heading,
|
||||
"--font-family-display": shared.typography.fontFamily.display,
|
||||
"--font-family-serif": shared.typography.fontFamily.serif,
|
||||
"--font-family-mono": shared.typography.fontFamily.mono,
|
||||
"--font-size-caption": shared.typography.fontSize.caption,
|
||||
"--font-size-label": shared.typography.fontSize.label,
|
||||
"--font-size-body": shared.typography.fontSize.body,
|
||||
"--font-size-title": shared.typography.fontSize.title,
|
||||
"--font-size-heading": shared.typography.fontSize.heading,
|
||||
"--font-size-display": shared.typography.fontSize.display,
|
||||
"--line-height-caption": shared.typography.lineHeight.caption,
|
||||
"--line-height-label": shared.typography.lineHeight.label,
|
||||
"--line-height-body": shared.typography.lineHeight.body,
|
||||
"--line-height-title": shared.typography.lineHeight.title,
|
||||
"--line-height-heading": shared.typography.lineHeight.heading,
|
||||
"--line-height-display": shared.typography.lineHeight.display,
|
||||
"--font-weight-caption": shared.typography.fontWeight.caption,
|
||||
"--font-weight-label": shared.typography.fontWeight.label,
|
||||
"--font-weight-body": shared.typography.fontWeight.body,
|
||||
"--font-weight-title": shared.typography.fontWeight.title,
|
||||
"--font-weight-heading": shared.typography.fontWeight.heading,
|
||||
"--font-weight-display": shared.typography.fontWeight.display,
|
||||
"--letter-spacing-caption": shared.typography.letterSpacing.caption,
|
||||
"--letter-spacing-label": shared.typography.letterSpacing.label,
|
||||
"--letter-spacing-body": shared.typography.letterSpacing.body,
|
||||
"--letter-spacing-title": shared.typography.letterSpacing.title,
|
||||
"--letter-spacing-heading": shared.typography.letterSpacing.heading,
|
||||
"--letter-spacing-display": shared.typography.letterSpacing.display,
|
||||
"--color-canvas": modeTokens.colors.canvas,
|
||||
"--color-surface": modeTokens.colors.surface,
|
||||
"--color-surface-muted": modeTokens.colors.surfaceMuted,
|
||||
"--color-surface-hover": modeTokens.colors.surfaceHover,
|
||||
"--color-border": modeTokens.colors.border,
|
||||
"--color-border-strong": modeTokens.colors.borderStrong,
|
||||
"--color-text": modeTokens.colors.text,
|
||||
"--color-text-muted": modeTokens.colors.textMuted,
|
||||
"--color-accent": modeTokens.colors.accent,
|
||||
"--color-accent-strong": modeTokens.colors.accentStrong,
|
||||
"--color-accent-soft": modeTokens.colors.accentSoft,
|
||||
"--color-accent-contrast": modeTokens.colors.accentContrast,
|
||||
"--color-success": modeTokens.colors.success,
|
||||
"--color-danger": modeTokens.colors.danger,
|
||||
"--color-warning": modeTokens.colors.warning,
|
||||
"--color-focus-ring": modeTokens.colors.focusRing,
|
||||
};
|
||||
};
|
||||
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "node"],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
// Path: Frontend/vite.config.ts
|
||||
|
||||
import { nitroV2Plugin as nitro } from "@solidjs/vite-plugin-nitro-2";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
import { solidStart } from "@solidjs/start/config";
|
||||
|
||||
const extraAllowedHosts = (process.env.ALLOWED_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((host) => host.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solidStart({ ssr: false })],
|
||||
server: {
|
||||
allowedHosts: ["localhost", ...extraAllowedHosts],
|
||||
},
|
||||
preview: {
|
||||
allowedHosts: ["localhost", ...extraAllowedHosts],
|
||||
},
|
||||
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `@use "/src/styles/tools/functions" as *;\n@use "/src/styles/tools/breakpoints" as *;\n@use "/src/styles/tools/mixins" as *;\n`,
|
||||
additionalData: `@use "/src/styles/tools/breakpoints" as *;\n@use "/src/styles/tools/mixins" as *;\n`,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [solidStart(), nitro()],
|
||||
});
|
||||
|
||||
2
Justfile
2
Justfile
@@ -4,4 +4,4 @@ mod local "Commands/Local"
|
||||
|
||||
[default]
|
||||
help:
|
||||
just --list --list-submodules
|
||||
just --list --list-submodules
|
||||
|
||||
35
Loader/README.md
Normal file
35
Loader/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Loader
|
||||
|
||||
Tiny first-request bootstrap document for Moku.
|
||||
|
||||
## Purpose
|
||||
|
||||
The loader is intended to be the smallest possible first paint shown before the
|
||||
real application is handed off.
|
||||
|
||||
Long term responsibilities:
|
||||
|
||||
- capture original route intent
|
||||
- collect lightweight client capability hints
|
||||
- make a tiny boot decision
|
||||
- hand off to login or app
|
||||
|
||||
## Route intent contract
|
||||
|
||||
The loader writes bootstrap state to `sessionStorage`:
|
||||
|
||||
- `moku.loader.intent` — the intended route path
|
||||
- `moku.loader.meta` — JSON metadata for the current bootstrap event
|
||||
|
||||
The loader also writes a short-lived cookie:
|
||||
|
||||
- `moku_loader_seen=1`
|
||||
|
||||
The proxy uses that cookie to decide whether to keep serving the loader or to
|
||||
serve the real app document on the next request.
|
||||
|
||||
## Current handoff behavior
|
||||
|
||||
For now, the loader simply hands off to the originally requested route. That
|
||||
keeps the first implementation tiny while leaving a clear place to add auth or
|
||||
perf-tier decisions later.
|
||||
44
Loader/index.html
Normal file
44
Loader/index.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Moku Loader</title>
|
||||
<link rel="stylesheet" href="/__loader/styles.css" />
|
||||
<script type="module" src="/__loader/scripts/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="loader-shell" aria-busy="true" aria-live="polite">
|
||||
<section class="loader-panel" aria-label="Application bootstrap">
|
||||
<p class="loader-eyebrow">Moku bootstrap</p>
|
||||
<h1 class="loader-title">Starting Moku</h1>
|
||||
<p class="loader-copy">Capturing route intent and preparing the lightest possible handoff.</p>
|
||||
|
||||
<div class="loader-indicators" aria-hidden="true">
|
||||
<span class="loader-dot"></span>
|
||||
<span class="loader-dot"></span>
|
||||
<span class="loader-dot"></span>
|
||||
</div>
|
||||
|
||||
<dl class="loader-meta">
|
||||
<div class="loader-meta-row">
|
||||
<dt>Status</dt>
|
||||
<dd id="loader-status">Initializing loader</dd>
|
||||
</div>
|
||||
<div class="loader-meta-row">
|
||||
<dt>Intent</dt>
|
||||
<dd id="loader-intent">Waiting for capture</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<noscript>
|
||||
<section class="loader-noscript">
|
||||
<strong>JavaScript is required.</strong>
|
||||
<span>Moku's bootstrap loader needs JavaScript to hand off to the app.</span>
|
||||
</section>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
9
Loader/scripts/config.js
Normal file
9
Loader/scripts/config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Path: Loader/scripts/config.js
|
||||
|
||||
export const LOADER_INTENT_STORAGE_KEY = "moku.loader.intent";
|
||||
export const LOADER_META_STORAGE_KEY = "moku.loader.meta";
|
||||
export const LOADER_SEEN_COOKIE_NAME = "moku_loader_seen";
|
||||
export const LOADER_DEFAULT_INTENT = "/";
|
||||
export const LOADER_ROOT_PREFIX = "/__loader";
|
||||
export const LOADER_HANDOFF_DELAY_MS = 720;
|
||||
export const LOADER_SEEN_COOKIE_MAX_AGE_SECONDS = 1800;
|
||||
25
Loader/scripts/dom.js
Normal file
25
Loader/scripts/dom.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Path: Loader/scripts/dom.js
|
||||
|
||||
export const getStatusElement = () => {
|
||||
return document.getElementById("loader-status");
|
||||
};
|
||||
|
||||
export const getIntentElement = () => {
|
||||
return document.getElementById("loader-intent");
|
||||
};
|
||||
|
||||
export const setStatus = (value) => {
|
||||
const statusElement = getStatusElement();
|
||||
|
||||
if (statusElement instanceof HTMLElement) {
|
||||
statusElement.textContent = value;
|
||||
}
|
||||
};
|
||||
|
||||
export const setIntentPreview = (value) => {
|
||||
const intentElement = getIntentElement();
|
||||
|
||||
if (intentElement instanceof HTMLElement) {
|
||||
intentElement.textContent = value;
|
||||
}
|
||||
};
|
||||
13
Loader/scripts/handoff.js
Normal file
13
Loader/scripts/handoff.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Path: Loader/scripts/handoff.js
|
||||
|
||||
import { LOADER_HANDOFF_DELAY_MS } from "./config.js";
|
||||
|
||||
export const scheduleHandoff = (target, onBeforeHandoff) => {
|
||||
window.setTimeout(() => {
|
||||
if (typeof onBeforeHandoff === "function") {
|
||||
onBeforeHandoff();
|
||||
}
|
||||
|
||||
window.location.assign(target);
|
||||
}, LOADER_HANDOFF_DELAY_MS);
|
||||
};
|
||||
19
Loader/scripts/hints.js
Normal file
19
Loader/scripts/hints.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Path: Loader/scripts/hints.js
|
||||
|
||||
export const collectClientHints = () => {
|
||||
const navigatorConnection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
language: navigator.language,
|
||||
languages: Array.isArray(navigator.languages) ? navigator.languages : [],
|
||||
hardwareConcurrency: navigator.hardwareConcurrency ?? null,
|
||||
deviceMemory: navigator.deviceMemory ?? null,
|
||||
networkType: navigatorConnection?.effectiveType ?? null,
|
||||
saveData: navigatorConnection?.saveData ?? null,
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
},
|
||||
};
|
||||
};
|
||||
32
Loader/scripts/intent.js
Normal file
32
Loader/scripts/intent.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Path: Loader/scripts/intent.js
|
||||
|
||||
import { LOADER_DEFAULT_INTENT, LOADER_ROOT_PREFIX } from "./config.js";
|
||||
|
||||
export const getExplicitIntent = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const next = params.get("next");
|
||||
|
||||
if (typeof next === "string" && next.startsWith("/")) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCurrentPathIntent = () => {
|
||||
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
if (current === "/" || current === LOADER_ROOT_PREFIX || current === `${LOADER_ROOT_PREFIX}/` || current.startsWith(`${LOADER_ROOT_PREFIX}/`)) {
|
||||
return LOADER_DEFAULT_INTENT;
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
export const resolveIntent = () => {
|
||||
return getExplicitIntent() ?? getCurrentPathIntent();
|
||||
};
|
||||
|
||||
export const resolveHandoffTarget = (intent) => {
|
||||
return intent || LOADER_DEFAULT_INTENT;
|
||||
};
|
||||
26
Loader/scripts/main.js
Normal file
26
Loader/scripts/main.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Path: Loader/scripts/main.js
|
||||
|
||||
import { setIntentPreview, setStatus } from "./dom.js";
|
||||
import { scheduleHandoff } from "./handoff.js";
|
||||
import { resolveHandoffTarget, resolveIntent } from "./intent.js";
|
||||
import { markLoaderSeen, persistLoaderState } from "./storage.js";
|
||||
|
||||
const bootLoader = () => {
|
||||
const intent = resolveIntent();
|
||||
const handoffTarget = resolveHandoffTarget(intent);
|
||||
|
||||
setStatus("Capturing route intent");
|
||||
setIntentPreview(intent);
|
||||
persistLoaderState(intent);
|
||||
|
||||
window.setTimeout(() => {
|
||||
setStatus("Preparing handoff");
|
||||
}, 180);
|
||||
|
||||
scheduleHandoff(handoffTarget, () => {
|
||||
setStatus("Handing off to application");
|
||||
markLoaderSeen();
|
||||
});
|
||||
};
|
||||
|
||||
bootLoader();
|
||||
24
Loader/scripts/storage.js
Normal file
24
Loader/scripts/storage.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Path: Loader/scripts/storage.js
|
||||
|
||||
import { LOADER_INTENT_STORAGE_KEY, LOADER_META_STORAGE_KEY, LOADER_SEEN_COOKIE_MAX_AGE_SECONDS, LOADER_SEEN_COOKIE_NAME } from "./config.js";
|
||||
import { collectClientHints } from "./hints.js";
|
||||
|
||||
export const persistLoaderState = (intent) => {
|
||||
const payload = {
|
||||
intent,
|
||||
hints: collectClientHints(),
|
||||
};
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(LOADER_INTENT_STORAGE_KEY, intent);
|
||||
window.sessionStorage.setItem(LOADER_META_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.warn("[Loader] Unable to persist bootstrap state.", error);
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const markLoaderSeen = () => {
|
||||
document.cookie = `${LOADER_SEEN_COOKIE_NAME}=1; Path=/; Max-Age=${LOADER_SEEN_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
||||
};
|
||||
177
Loader/styles.css
Normal file
177
Loader/styles.css
Normal file
@@ -0,0 +1,177 @@
|
||||
/* Path: Loader/styles.css */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--loader-bg: #07111f;
|
||||
--loader-panel: rgba(15, 23, 42, 0.92);
|
||||
--loader-panel-border: rgba(148, 163, 184, 0.18);
|
||||
--loader-panel-highlight: rgba(255, 255, 255, 0.04);
|
||||
--loader-text: #f8fafc;
|
||||
--loader-text-muted: #94a3b8;
|
||||
--loader-accent: #60a5fa;
|
||||
--loader-accent-soft: rgba(96, 165, 250, 0.16);
|
||||
--loader-shadow: 0 24px 64px rgba(2, 6, 23, 0.42);
|
||||
--loader-radius: 1.25rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
background: radial-gradient(circle at top, rgba(37, 99, 235, 0.18), transparent 36%), linear-gradient(180deg, #091528 0%, var(--loader-bg) 100%);
|
||||
color: var(--loader-text);
|
||||
}
|
||||
|
||||
.loader-shell {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.loader-panel {
|
||||
width: min(100%, 30rem);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--loader-panel-border);
|
||||
border-radius: var(--loader-radius);
|
||||
background: linear-gradient(180deg, var(--loader-panel-highlight), transparent 16%), var(--loader-panel);
|
||||
box-shadow: var(--loader-shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.loader-eyebrow {
|
||||
margin: 0 0 0.625rem;
|
||||
color: var(--loader-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.loader-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 5vw, 2.25rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.loader-copy {
|
||||
margin: 0.875rem 0 0;
|
||||
max-width: 30ch;
|
||||
color: var(--loader-text-muted);
|
||||
font-size: 0.975rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.loader-indicators {
|
||||
margin-top: 1.25rem;
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.loader-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 999px;
|
||||
background: var(--loader-accent-soft);
|
||||
animation: loader-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader-dot:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.loader-dot:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
.loader-meta {
|
||||
margin: 1.5rem 0 0;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.loader-meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.loader-meta-row + .loader-meta-row {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.loader-meta dt {
|
||||
color: var(--loader-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.loader-meta dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
color: var(--loader-text);
|
||||
font-size: 0.925rem;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.loader-noscript {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid rgba(248, 113, 113, 0.28);
|
||||
border-radius: 0.875rem;
|
||||
background: rgba(69, 10, 10, 0.92);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
@keyframes loader-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
background: var(--loader-accent-soft);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-0.125rem);
|
||||
background: var(--loader-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.99rem) {
|
||||
.loader-shell {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.loader-panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.loader-meta-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
99
Proxy/Local/Dockerfile
Normal file
99
Proxy/Local/Dockerfile
Normal file
@@ -0,0 +1,99 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
## Frontend
|
||||
## =========================
|
||||
|
||||
ARG NGINX_VERSION=1.29.8
|
||||
|
||||
FROM node:22-alpine AS frontend-dependencies
|
||||
|
||||
WORKDIR /workspace/Frontend
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
||||
|
||||
COPY Frontend/package.json Frontend/pnpm-lock.yaml Frontend/tsconfig.json Frontend/vite.config.ts ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM frontend-dependencies AS frontend-build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY Frontend/ ./
|
||||
|
||||
RUN pnpm build:static
|
||||
|
||||
## Proxy
|
||||
## =========================
|
||||
|
||||
FROM alpine:3.22 AS precompressed-assets
|
||||
|
||||
RUN apk add --no-cache brotli gzip
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY Loader ./html/__loader
|
||||
COPY --from=frontend-build /workspace/Frontend/dist/client ./html
|
||||
|
||||
RUN find ./html -type f \( \
|
||||
-name '*.html' -o \
|
||||
-name '*.css' -o \
|
||||
-name '*.js' -o \
|
||||
-name '*.json' -o \
|
||||
-name '*.svg' -o \
|
||||
-name '*.txt' -o \
|
||||
-name '*.xml' -o \
|
||||
-name '*.webmanifest' \
|
||||
\) -exec gzip -kf -9 {} \; \
|
||||
&& find ./html -type f \( \
|
||||
-name '*.html' -o \
|
||||
-name '*.css' -o \
|
||||
-name '*.js' -o \
|
||||
-name '*.json' -o \
|
||||
-name '*.svg' -o \
|
||||
-name '*.txt' -o \
|
||||
-name '*.xml' -o \
|
||||
-name '*.webmanifest' \
|
||||
\) -exec brotli -kf -Z {} \;
|
||||
|
||||
FROM alpine:3.22 AS brotli-modules
|
||||
|
||||
ARG NGINX_VERSION
|
||||
|
||||
RUN apk add --no-cache \
|
||||
alpine-sdk \
|
||||
cmake \
|
||||
git \
|
||||
linux-headers \
|
||||
openssl-dev \
|
||||
pcre2-dev \
|
||||
wget \
|
||||
zlib-dev
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
RUN wget -O "nginx-${NGINX_VERSION}.tar.gz" "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" \
|
||||
&& tar -xzf "nginx-${NGINX_VERSION}.tar.gz" \
|
||||
&& git clone --recurse-submodules https://github.com/google/ngx_brotli.git
|
||||
|
||||
WORKDIR /tmp/nginx-${NGINX_VERSION}
|
||||
|
||||
RUN cd /tmp/ngx_brotli/deps/brotli \
|
||||
&& mkdir -p out \
|
||||
&& cd out \
|
||||
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DCMAKE_C_FLAGS="-fPIC" -DCMAKE_CXX_FLAGS="-fPIC" .. \
|
||||
&& cmake --build . --config Release -j"$(getconf _NPROCESSORS_ONLN)" \
|
||||
&& cd /tmp/nginx-${NGINX_VERSION} \
|
||||
&& ./configure --with-compat --add-dynamic-module=/tmp/ngx_brotli \
|
||||
&& make modules
|
||||
|
||||
FROM nginx:${NGINX_VERSION}-alpine AS production
|
||||
|
||||
COPY Proxy/Local/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY Proxy/Local/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=brotli-modules /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_brotli_filter_module.so /usr/lib/nginx/modules/
|
||||
COPY --from=brotli-modules /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_brotli_static_module.so /usr/lib/nginx/modules/
|
||||
COPY --from=precompressed-assets /workspace/html /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
39
Proxy/Local/default.conf
Normal file
39
Proxy/Local/default.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
map $http_cookie $moku_bootstrap_document {
|
||||
default /__loader/index.html;
|
||||
"~*(^|; )moku_loader_seen=1($|;)" /index.html;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = / {
|
||||
add_header Cache-Control "no-store";
|
||||
rewrite ^ $moku_bootstrap_document last;
|
||||
}
|
||||
|
||||
location ^~ /__loader/ {
|
||||
access_log off;
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ $moku_bootstrap_document;
|
||||
}
|
||||
|
||||
location /favicon.ico {
|
||||
try_files $uri =404;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location /_build/ {
|
||||
access_log off;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
60
Proxy/Local/nginx.conf
Normal file
60
Proxy/Local/nginx.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
load_module /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so;
|
||||
load_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
gzip on;
|
||||
gzip_static on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/xml
|
||||
application/rss+xml
|
||||
image/svg+xml;
|
||||
|
||||
brotli on;
|
||||
brotli_static on;
|
||||
brotli_comp_level 5;
|
||||
brotli_min_length 1024;
|
||||
brotli_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/xml
|
||||
application/rss+xml
|
||||
image/svg+xml;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
36
Proxy/docker-bake.hcl
Normal file
36
Proxy/docker-bake.hcl
Normal file
@@ -0,0 +1,36 @@
|
||||
variable "REGISTRY" {
|
||||
default = "registry.example.com"
|
||||
}
|
||||
|
||||
variable "TAG" {
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
target "_proxy" {
|
||||
context = "."
|
||||
dockerfile = "Proxy/Local/Dockerfile"
|
||||
}
|
||||
|
||||
target "prod" {
|
||||
inherits = ["_proxy"]
|
||||
target = "production"
|
||||
tags = ["moku/work-proxy:local-prod"]
|
||||
}
|
||||
|
||||
target "prod-image" {
|
||||
inherits = ["_proxy"]
|
||||
target = "production"
|
||||
tags = ["${REGISTRY}/moku/work-proxy:prod-${TAG}"]
|
||||
}
|
||||
|
||||
group "local" {
|
||||
targets = ["prod"]
|
||||
}
|
||||
|
||||
group "registry" {
|
||||
targets = ["prod-image"]
|
||||
}
|
||||
|
||||
group "default" {
|
||||
targets = ["prod"]
|
||||
}
|
||||
Reference in New Issue
Block a user