Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
48 changes: 39 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,37 @@ A simple and efficient solution for serving static files.
- **Lightweight**: Minimal dependencies for optimal performance.
- **Configurable**: You can easily configure the server or extend it based on your needs.
- The server serves files from the `static` directory by default, but you can change this by setting the `STATIC_DIR_PATH` environment variable.
- Support all the confgs of the gofr framework - https://gofr.dev
- Support all the configs of the gofr framework - https://gofr.dev

## Config File Hydration

When the `CONFIG_FILE_PATH` environment variable is set, the server replaces any `${VAR}` placeholders in that file at startup using values from the environment (including `.env` files). The file is rewritten in-place before serving begins.

This is useful for injecting runtime configuration into static front-end apps without rebuilding them.

If any placeholders have no matching environment variable, the server still writes the file (substituting empty strings for missing values) and logs an error listing the unresolved variables.

#### Example

Given a `config.json` template:

```json
{
"clientId": "${GOOGLE_CLIENT_ID}",
"apiUrl": "${API_BASE_URL}"
}
```

If `GOOGLE_CLIENT_ID=abc123` and `API_BASE_URL=https://api.example.com` are set, the file becomes:

```json
{
"clientId": "abc123",
"apiUrl": "https://api.example.com"
}
```

> See the [example Dockerfile](#1-build-a-docker-image) below for how to set `CONFIG_FILE_PATH`.

## Usage

Expand All @@ -22,20 +52,20 @@ To deploy the server, you need to build a Docker image using the provided `Docke
```dockerfile
# Use the official static-server image as the base image
# This will pull the prebuilt version of the static-server to run your static website
FROM zopdev/static-server:v0.0.7
FROM zopdev/static-server:v0.0.8

# Copy static files into the container
# The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/website' directory
# The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/static' directory
# which is where the static server expects to find the files to serve
COPY /app/out /static

# Expose the port on which the server will run
# By default, the server listens on port 8000, so we expose that port to allow access from outside the container
EXPOSE 8000
# Set the path to the config file for environment variable hydration at startup
ENV CONFIG_FILE_PATH=/static/config.json

# The server listens on port 8000 by default; set HTTP_PORT to change it

# Define the command to run the server
# The static server is started with the '/main' binary included in the image, which will start serving
# the files from the '/website' directory on port 8000
# The static server is started with the '/main' binary included in the image
CMD ["/main"]
```

Expand Down Expand Up @@ -75,7 +105,7 @@ Your static files will be served, and the root (`/`) will typically display your

## Notes

- The server serves all files in the `website` directory, so make sure to avoid any sensitive files or configuration details in that directory.
- The server serves all files in the `static` directory, so make sure to avoid any sensitive files or configuration details in that directory.

## License

Expand Down
8 changes: 5 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ module zop.dev/static-server

go 1.25.0

require gofr.dev v1.54.3
require (
github.com/stretchr/testify v1.11.1
go.uber.org/mock v0.6.0
gofr.dev v1.54.3
)

require (
cloud.google.com/go v0.123.0 // indirect
Expand Down Expand Up @@ -57,7 +61,6 @@ require (
github.com/redis/go-redis/v9 v9.17.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/kafka-go v0.4.50 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
Expand All @@ -76,7 +79,6 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
Expand Down
71 changes: 71 additions & 0 deletions internal/config/hydrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package config

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"

"gofr.dev/pkg/gofr/config"
"gofr.dev/pkg/gofr/datasource/file"
)

var (
errMissingVars = errors.New("missing config variables")
errReadConfig = errors.New("failed to read config file")
errWriteConfig = errors.New("failed to write config file")

envVarRe = regexp.MustCompile(`\$\{(\w+)\}`)
)

const filePathVar = "CONFIG_FILE_PATH"

func HydrateFile(fs file.FileSystem, cfg config.Config) error {
Copy link
Contributor

Choose a reason for hiding this comment

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

config should not be sent here, we should read in main.go and inject the key value pairs, config.Config should not be the dependency.

Copy link
Contributor

Choose a reason for hiding this comment

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

We can also add a comment of why this is being done is because we are doing something which is not recommended and we also need to make sure we don't follow this elsewhere and it was done here because this is the cleanest way as of now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

configPath := cfg.Get(filePathVar)
if configPath == "" {
return nil
}

configFile, err := fs.Open(filepath.Clean(configPath))
if err != nil {
return fmt.Errorf("%w: %w", errReadConfig, err)
}

content, err := io.ReadAll(configFile)
if err != nil {
return fmt.Errorf("%w: %w", errReadConfig, err)
}

_ = configFile.Close()

// Hydrate with available vars
result := os.Expand(string(content), cfg.Get)

wf, err := fs.OpenFile(configPath, os.O_WRONLY|os.O_TRUNC, 0)
if err != nil {
return fmt.Errorf("%w: %w", errWriteConfig, err)
}

if _, err = wf.Write([]byte(result)); err != nil {
return fmt.Errorf("%w: %w", errWriteConfig, err)
}

// Detect vars that were missing (replaced with empty string)
matches := envVarRe.FindAllStringSubmatch(string(content), -1)

var missing []string

for _, m := range matches {
if cfg.Get(m[1]) == "" {
missing = append(missing, m[1])
}
}

if len(missing) > 0 {
return fmt.Errorf("%w: %v", errMissingVars, missing)
}

return nil
}
121 changes: 121 additions & 0 deletions internal/config/hydrate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package config

import (
"io"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/require"
"gofr.dev/pkg/gofr/config"
"gofr.dev/pkg/gofr/datasource/file"
"gofr.dev/pkg/gofr/logging"
)

func writeTempFile(t *testing.T, fs file.FileSystem, content string) string {
t.Helper()

dir := t.TempDir()
path := filepath.Join(dir, "config.json")

f, err := fs.Create(path)
require.NoError(t, err)

_, err = f.Write([]byte(content))
require.NoError(t, err)

require.NoError(t, f.Close())

return path
}

func TestConfig(t *testing.T) {
tests := []struct {
name string
template string
vars map[string]string
expected string
wantErr error
}{
{
name: "all vars present",
template: `{"a":"${A}","b":"${B}"}`,
vars: map[string]string{"A": "1", "B": "2"},
expected: `{"a":"1","b":"2"}`,
},
{
name: "no config path is a no-op",
vars: map[string]string{},
wantErr: nil,
},
{
name: "extra vars ignored",
template: `{"a":"${A}"}`,
vars: map[string]string{"A": "1", "EXTRA": "x"},
expected: `{"a":"1"}`,
},
{
name: "partial vars missing",
template: `{"a":"${A}","b":"${MISSING}"}`,
vars: map[string]string{"A": "1"},
expected: `{"a":"1","b":""}`,
wantErr: errMissingVars,
},
{
name: "all vars missing",
template: `{"a":"${X}","b":"${Y}"}`,
vars: map[string]string{},
expected: `{"a":"","b":""}`,
wantErr: errMissingVars,
},
{
name: "invalid config path",
vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"},
wantErr: errReadConfig,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := file.New(logging.NewMockLogger(logging.ERROR))

if tt.template != "" {
path := writeTempFile(t, fs, tt.template)
tt.vars[filePathVar] = path
}

err := HydrateFile(fs, config.NewMockConfig(tt.vars))

require.ErrorIs(t, err, tt.wantErr)

if tt.expected != "" {
rf, readErr := fs.Open(tt.vars[filePathVar])
require.NoError(t, readErr)
got, readErr := io.ReadAll(rf)
require.NoError(t, readErr)
require.Equal(t, tt.expected, string(got))
}
})
}
}

func TestHydrateFile_WriteError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod not effective on Windows")
}

fs := file.New(logging.NewMockLogger(logging.ERROR))
path := writeTempFile(t, fs, `{"a":"${A}"}`)

err := os.Chmod(path, 0444)
require.NoError(t, err)

vars := map[string]string{
filePathVar: path,
"A": "1",
}

err = HydrateFile(fs, config.NewMockConfig(vars))
require.ErrorIs(t, err, errWriteConfig)
}
10 changes: 10 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"strings"

"gofr.dev/pkg/gofr"

"zop.dev/static-server/internal/config"
)

const defaultStaticFilePath = `./static`
Expand All @@ -18,6 +20,14 @@ const rootPath = "/"
func main() {
app := gofr.New()

app.OnStart(func(ctx *gofr.Context) error {
if err := config.HydrateFile(ctx.File, app.Config); err != nil {
ctx.Logger.Error(err)
}

return nil
})

staticFilePath := app.Config.GetOrDefault("STATIC_DIR_PATH", defaultStaticFilePath)

app.UseMiddleware(func(h http.Handler) http.Handler {
Expand Down