Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ apps/web/out/

# Go backend
apps/api/bin/
apps/api/server
apps/api/sync-realtime
apps/api/sync-static
*.db
*.db-shm
*.db-wal
*.pmtiles
apps/api/debug_*

# bun (not used, but just in case)
bun.lockb
Expand Down
8 changes: 8 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
PORT=8080
METRA_API_KEY=

# Tile upload (DigitalOcean Spaces)
TILES_S3_ENDPOINT=https://<region>.digitaloceanspaces.com
TILES_S3_BUCKET=
TILES_S3_ACCESS_KEY_ID=
TILES_S3_SECRET_ACCESS_KEY=
TILES_S3_REGION=us-east-1
27 changes: 27 additions & 0 deletions apps/api/Dockerfile.sync-static
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ── Build tippecanoe from source ─────────────────────────────────────────────
FROM debian:bookworm-slim AS tippecanoe-build
RUN apt-get update && apt-get install -y --no-install-recommends \
git build-essential libsqlite3-dev zlib1g-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 https://github.com/felt/tippecanoe.git /tippecanoe \
&& cd /tippecanoe && make -j$(nproc)

# ── Build Go binary ─────────────────────────────────────────────────────────
FROM golang:1.26-bookworm AS go-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 go build -o /sync-static ./cmd/sync-static

# ── Runtime ──────────────────────────────────────────────────────────────────
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libsqlite3-0 zlib1g \
&& rm -rf /var/lib/apt/lists/*

COPY --from=tippecanoe-build /tippecanoe/tippecanoe /usr/local/bin/tippecanoe
COPY --from=go-build /sync-static /usr/local/bin/sync-static

WORKDIR /data
ENTRYPOINT ["sync-static", "--upload"]
25 changes: 0 additions & 25 deletions apps/api/cmd/api/main.go

This file was deleted.

64 changes: 64 additions & 0 deletions apps/api/cmd/functions/sync-realtime/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"context"
"fmt"
"log"

_ "github.com/joho/godotenv/autoload"

"github.com/RailForLess/tracky/api/providers"
"github.com/RailForLess/tracky/api/providers/amtrak"
"github.com/RailForLess/tracky/api/providers/brightline"
"github.com/RailForLess/tracky/api/providers/metra"
"github.com/RailForLess/tracky/api/providers/metrotransit"
"github.com/RailForLess/tracky/api/providers/trirail"
)

func buildRegistry() *providers.Registry {
registry := providers.NewRegistry()
registry.Register(amtrak.New())
registry.Register(brightline.New())
registry.Register(metra.New())
registry.Register(metrotransit.New())
registry.Register(trirail.New())
return registry
}

// main is a stub — the DO Functions runtime provides its own entry point and
// invokes Main directly. This exists only to satisfy the Go compiler.
func main() {}

// Main is the Digital Ocean Functions entry point.
// Optional arg: "provider" (string) — if set, only that provider is synced.
func Main(args map[string]interface{}) map[string]interface{} {
registry := buildRegistry()
ctx := context.Background()

targets := registry.All()
if id, ok := args["provider"].(string); ok && id != "" {
p, ok := registry.Get(id)
if !ok {
return map[string]interface{}{
"error": fmt.Sprintf("unknown provider: %s", id),
}
}
targets = []providers.Provider{p}
}

results := make(map[string]interface{}, len(targets))
for _, p := range targets {
feed, err := p.FetchRealtime(ctx)
if err != nil {
log.Printf("sync-realtime: %s: %v", p.ID(), err)
results[p.ID()] = map[string]interface{}{"error": err.Error()}
continue
}
results[p.ID()] = map[string]interface{}{
"positions": len(feed.Positions),
"stop_times": len(feed.StopTimes),
}
}

return map[string]interface{}{"body": results}
}
58 changes: 58 additions & 0 deletions apps/api/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"log"
"net/http"
"os"

_ "github.com/joho/godotenv/autoload"

"github.com/RailForLess/tracky/api/db"
"github.com/RailForLess/tracky/api/providers"
"github.com/RailForLess/tracky/api/providers/amtrak"
"github.com/RailForLess/tracky/api/providers/brightline"
"github.com/RailForLess/tracky/api/providers/cta"
"github.com/RailForLess/tracky/api/providers/metra"
"github.com/RailForLess/tracky/api/providers/metrotransit"
"github.com/RailForLess/tracky/api/providers/trirail"
"github.com/RailForLess/tracky/api/routes"
)

func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}

dbPath := os.Getenv("DATABASE_PATH")
if dbPath == "" {
dbPath = "tracky.db"
}

database, err := db.Open(dbPath)
if err != nil {
log.Fatal(err)
}
defer database.Close()

registry := providers.NewRegistry()
registry.Register(amtrak.New())
registry.Register(brightline.New())
registry.Register(cta.New())
registry.Register(metra.New())
registry.Register(metrotransit.New())
registry.Register(trirail.New())

mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})

routes.Setup(mux, registry, database)

log.Printf("starting server on :%s", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
Comment on lines +54 to +56
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server uses http.ListenAndServe without setting timeouts. This leaves the process more vulnerable to slowloris-style attacks and can tie up resources under bad clients. Prefer configuring an http.Server with at least ReadHeaderTimeout (and usually ReadTimeout/WriteTimeout/IdleTimeout) and calling server.ListenAndServe().

Copilot uses AI. Check for mistakes.
}
}
150 changes: 150 additions & 0 deletions apps/api/cmd/sync-static/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"os"
"path/filepath"

_ "github.com/joho/godotenv/autoload"

"github.com/RailForLess/tracky/api/db"
"github.com/RailForLess/tracky/api/providers"
"github.com/RailForLess/tracky/api/providers/amtrak"
"github.com/RailForLess/tracky/api/providers/brightline"
"github.com/RailForLess/tracky/api/providers/cta"
"github.com/RailForLess/tracky/api/providers/metra"
"github.com/RailForLess/tracky/api/providers/metrotransit"
"github.com/RailForLess/tracky/api/providers/trirail"
"github.com/RailForLess/tracky/api/tiles"
)

func main() {
dbPath := flag.String("db", "tracky.db", "SQLite database path")
skipDB := flag.Bool("skip-db", false, "skip writing to the database")
skipTiles := flag.Bool("skip-tiles", false, "skip tile generation")
tilesDir := flag.String("tiles-dir", ".", "output directory for PMTiles files")
upload := flag.Bool("upload", false, "upload artifacts to S3 after generation")
providerFilter := flag.String("provider", "", "restrict to a single provider ID")
flag.Parse()

ctx := context.Background()

// ── Provider registry ───────────────────────────────────────────────

registry := providers.NewRegistry()
registry.Register(amtrak.New())
registry.Register(brightline.New())
registry.Register(cta.New())
registry.Register(metra.New())
registry.Register(metrotransit.New())
registry.Register(trirail.New())

var selected []providers.Provider
if *providerFilter != "" {
p, ok := registry.Get(*providerFilter)
if !ok {
log.Fatalf("unknown provider: %s", *providerFilter)
}
selected = []providers.Provider{p}
} else {
selected = registry.All()
}

// ── Open database ───────────────────────────────────────────────────

var database *db.DB
if !*skipDB {
var err error
database, err = db.Open(*dbPath)
if err != nil {
log.Fatalf("open database: %v", err)
}
defer database.Close()
log.Printf("opened database: %s", *dbPath)
}

// ── Process each provider sequentially ──────────────────────────────

for _, p := range selected {
log.Printf("processing %s...", p.ID())

feed, err := p.FetchStatic(ctx)
if err != nil {
log.Printf("warning: %s fetch failed: %v", p.ID(), err)
continue
}
log.Printf("fetched %s: %d routes, %d stops, %d trips, %d stop_times, %d shapes",
p.ID(), len(feed.Routes), len(feed.Stops), len(feed.Trips),
len(feed.StopTimes), len(feed.Shapes))

// Write to database.
if database != nil {
counts, err := database.SaveStaticFeed(ctx, p.ID(), feed)
if err != nil {
log.Printf("warning: %s db write failed: %v", p.ID(), err)
} else {
log.Printf("saved %s to db: %+v", p.ID(), counts)
}
}

// Generate tiles.
if !*skipTiles {
buildTiles(ctx, p.ID(), feed, *tilesDir, *upload)
}
}

log.Printf("done")
}

// buildTiles generates a PMTiles file for a single provider and optionally uploads it.
func buildTiles(ctx context.Context, providerID string, feed *providers.StaticFeed, dir string, doUpload bool) {
if len(feed.Shapes) == 0 {
log.Printf("skipping tiles for %s: no shapes", providerID)
return
}

geojson, err := tiles.BuildGeoJSON(feed.Shapes, feed.Trips, feed.Routes)
if err != nil {
log.Fatalf("%s: building GeoJSON: %v", providerID, err)
}
Comment on lines +109 to +112
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log.Fatalf here aborts the entire sync run, which prevents processing other providers (and is inconsistent with earlier non-fatal error handling). Consider returning an error and continuing, or make fail-fast behavior explicit via a flag.

Copilot uses AI. Check for mistakes.

debugPath := filepath.Join(dir, providerID+".geojson")
if err := os.WriteFile(debugPath, geojson, 0644); err != nil {
log.Printf("warning: failed to write debug GeoJSON: %v", err)
} else {
log.Printf("wrote debug GeoJSON: %s (%.1f MB)", debugPath, float64(len(geojson))/(1024*1024))
}

tmpFile, err := os.CreateTemp("", fmt.Sprintf("tracky-%s-*.geojson", providerID))
if err != nil {
log.Fatalf("%s: creating temp file: %v", providerID, err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)

if _, err := tmpFile.Write(geojson); err != nil {
tmpFile.Close()
log.Fatalf("%s: writing GeoJSON: %v", providerID, err)
}
tmpFile.Close()

output := filepath.Join(dir, providerID+".pmtiles")
log.Printf("running tippecanoe → %s", output)
if err := tiles.GenerateTiles(ctx, tmpPath, output); err != nil {
log.Fatalf("%s: tippecanoe: %v", providerID, err)
}

stat, _ := os.Stat(output)
log.Printf("generated %s (%.1f MB)", output, float64(stat.Size())/(1024*1024))

if doUpload {
objectKey := fmt.Sprintf("%s.pmtiles", providerID)
log.Printf("uploading %s to S3...", objectKey)
if err := tiles.Upload(ctx, output, objectKey); err != nil {
log.Fatalf("%s: upload: %v", providerID, err)
}
}
}
Loading
Loading