Repository: avenga/couper
Pull request #627 (2022-11-17 11:55:39)

add option to inject server data to an spa app index file
solves #626

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>


johakoch: TestHTTPServer_response_bodies test case
```
		{
			"/response/json_body/object/html",
			expectation{
				Body:        `{"foo":"<p>bar</p>"}`,
				ContentType: "application/json",
			},
		},
```
fails because of escaped `<` and `>`.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f952f088..36e4a5c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
   * OAuth2 client authentication methods (`token_endpoint_auth_method` values) `"client_secret_jwt"` and `"private_key_jwt"` including `jwt_signing_profile` block for [`oauth2`](https://docs.couper.io/configuration/block/oauth2_req_auth), [`beta_oauth2`](https://docs.couper.io/configuration/block/oauth2_ac) and [`oidc`](https://docs.couper.io/configuration/block/oidc) blocks ([#599](https://github.com/avenga/couper/pull/599))
   * **mTLS** Support for [`server`](https://docs.couper.io/configuration/block/server_tls) and [`backend`](https://docs.couper.io/configuration/block/backend_tls) blocks ([#615](https://github.com/avenga/couper/pull/615))
   * `beta_roles_map_file` and `beta_permissions_map_file` attributes to [`jwt` block](https://docs.couper.io/configuration/block/jwt) ([#613](https://github.com/avenga/couper/pull/613))
+  * `spa` block option to inject server-data to the applications `bootstrap_file` with [`bootstrap_data`](https://docs.couper.io/configuration/block/spa#bootstrap-data) ([#626](https://github.com/avenga/couper/issues/626))
 
 * **Changed**
   * Replaced the JWT library because the former library was no longer maintained ([#612](https://github.com/avenga/couper/pull/612))
diff --git a/config/runtime/server.go b/config/runtime/server.go
index 44d47af2..c858151a 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -7,6 +7,7 @@ import (
 	"net"
 	"net/http"
 	"path"
+	"path/filepath"
 	"reflect"
 	"strconv"
 	"strings"
@@ -146,12 +147,27 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 		serverBodies = append(serverBodies, srvConf.Remain)
 
 		var spaHandler http.Handler
+		var bootstrapFiles []string
+		spaMountPathSeen := make(map[string]struct{})
 		for _, spaConf := range srvConf.SPAs {
-			spaHandler, err = handler.NewSpa(spaConf.BootstrapFile, serverOptions, []hcl.Body{spaConf.Remain, srvConf.Remain})
+			spaHandler, err = handler.NewSpa(evalContext.HCLContext(), spaConf, serverOptions, []hcl.Body{spaConf.Remain, srvConf.Remain})
 			if err != nil {
 				return nil, err
 			}
 
+			for _, mountPath := range spaConf.Paths {
+				mp := strings.Replace(mountPath, "**", "", 1)
+				dir := filepath.Dir(spaConf.BootstrapFile)
+				if !strings.HasSuffix(dir, mp) {
+					dir = filepath.Join(dir, mp)
+				}
+				bfp := filepath.Join(dir, filepath.Base(spaConf.BootstrapFile))
+				if _, seen := spaMountPathSeen[bfp]; !seen {
+					bootstrapFiles = append(bootstrapFiles, bfp)
+					spaMountPathSeen[bfp] = struct{}{}
+				}
+			}
+
 			epOpts := &handler.EndpointOptions{ErrorTemplate: serverOptions.ServerErrTpl}
 			notAllowedMethodsHandler := epOpts.ErrorTemplate.WithError(errors.MethodNotAllowed)
 			allowedMethodsHandler := middleware.NewAllowedMethodsHandler(nil, middleware.DefaultFileSpaAllowedMethods, spaHandler, notAllowedMethodsHandler)
@@ -182,9 +198,9 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 				append(serverBodies, append(spaBodies, spaConf.Remain)...), spaHandler, "",
 			)
 
-			for _, spaPath := range spaConf.Paths {
-				err = setRoutesFromHosts(serverConfiguration, portsHosts,
-					path.Join(serverOptions.SrvBasePath, spaConf.BasePath, spaPath), spaHandler, spa)
+			for _, p := range spaConf.Paths {
+				spaPath := path.Join(serverOptions.SrvBasePath, spaConf.BasePath, p)
+				err = setRoutesFromHosts(serverConfiguration, portsHosts, spaPath, spaHandler, spa)
 				if err != nil {
 					sbody := spaConf.HCLBody()
 					return nil, hcl.Diagnostics{&hcl.Diagnostic{
@@ -200,6 +216,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 			fileHandler, err = handler.NewFile(
 				filesConf.DocumentRoot,
 				serverOptions.FilesBasePaths[i],
+				handler.NewPreferSpaFn(bootstrapFiles, filesConf.DocumentRoot),
 				serverOptions.FilesErrTpls[i],
 				serverOptions,
 				[]hcl.Body{filesConf.Remain, srvConf.Remain},
diff --git a/config/spa.go b/config/spa.go
index 949f8d26..f13f31c8 100644
--- a/config/spa.go
+++ b/config/spa.go
@@ -17,14 +17,16 @@ type SPAs []*Spa
 
 // Spa represents the <Spa> object.
 type Spa struct {
-	AccessControl        []string `hcl:"access_control,optional" docs:"Sets predefined [access control](../access-control) for {spa} block context."`
-	BasePath             string   `hcl:"base_path,optional" docs:"Configures the path prefix for all requests."`
-	BootstrapFile        string   `hcl:"bootstrap_file" docs:"Location of the bootstrap file."`
-	CORS                 *CORS    `hcl:"cors,block" docs:"Configure [CORS](cors) settings."`
-	DisableAccessControl []string `hcl:"disable_access_control,optional" docs:"Disables access controls by name."`
-	Name                 string   `hcl:"name,label,optional"`
-	Paths                []string `hcl:"paths" docs:"List of SPA paths that need the bootstrap file."`
-	Remain               hcl.Body `hcl:",remain"`
+	AccessControl        []string       `hcl:"access_control,optional" docs:"Sets predefined [access control](../access-control) for {spa} block context."`
+	BasePath             string         `hcl:"base_path,optional" docs:"Configures the path prefix for all requests."`
+	BootStrapDataName    string         `hcl:"bootstrap_data_placeholder,optional" docs:"String which will be replaced with {bootstrap_data}." default:"__BOOTSTRAP_DATA__"`
+	BootstrapData        hcl.Expression `hcl:"bootstrap_data,optional" docs:"JSON object which replaces the placeholder from {bootstrap_file} content."`
+	BootstrapFile        string         `hcl:"bootstrap_file" docs:"Location of the bootstrap file."`
+	CORS                 *CORS          `hcl:"cors,block" docs:"Configure [CORS](cors) settings."`
+	DisableAccessControl []string       `hcl:"disable_access_control,optional" docs:"Disables access controls by name."`
+	Name                 string         `hcl:"name,label,optional"`
+	Paths                []string       `hcl:"paths" docs:"List of SPA paths that need the bootstrap file."`
+	Remain               hcl.Body       `hcl:",remain"`
 }
 
 // HCLBody implements the <Body> interface.
diff --git a/docs/website/content/2.configuration/4.block/backend_tls.md b/docs/website/content/2.configuration/4.block/backend_tls.md
index c5bc6814..bc6a9c31 100644
--- a/docs/website/content/2.configuration/4.block/backend_tls.md
+++ b/docs/website/content/2.configuration/4.block/backend_tls.md
@@ -36,13 +36,13 @@ values: [
   },
   {
     "default": "",
-    "description": "Private part of the client certificate in DER or PEM format. Required to complete a mTLS handshake.",
+    "description": "Private part of the client certificate in DER or PEM format. Required to complete an mTLS handshake.",
     "name": "client_private_key",
     "type": "string"
   },
   {
     "default": "",
-    "description": "Private part of the client certificate file in DER or PEM format. Required to complete a mTLS handshake.",
+    "description": "Private part of the client certificate file in DER or PEM format. Required to complete an mTLS handshake.",
     "name": "client_private_key_file",
     "type": "string"
   },
diff --git a/docs/website/content/2.configuration/4.block/spa.md b/docs/website/content/2.configuration/4.block/spa.md
index 76e7914c..457d13e1 100644
--- a/docs/website/content/2.configuration/4.block/spa.md
+++ b/docs/website/content/2.configuration/4.block/spa.md
@@ -14,6 +14,65 @@ spa {
 }
 ```
 
+## Bootstrap Data
+
+As it could get complicated to configure an SPA based on its environment (urls, clientIDs, ...) Couper can
+inject those environment based values into the `bootstrap_file` before serving it to the client.
+
+The first `bootstrap_data_placeholder` will be replaced with the evaluated value of `bootstrap_data`.
+This happens on startup and is meant to inject `env` values.
+
+### `bootstrap_data` Example
+
+```hcl
+# couper.hcl
+spa {
+    bootstrap_file = "./htdocs/index.html"
+    paths = ["/**"]
+    bootstrap_data = {
+      url: env.MY_API_URL,
+      prop: "value",
+    }
+}
+```
+
+```html
+<!-- ./htdocs/index.html -->
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <script>
+      try {
+        window.AppConfig = __BOOTSTRAP_DATA__;
+      } catch(e) {
+        console.warn('DEVELOPMENT MODE: ', e)
+        window.AppConfig = {} // fallback for local development
+      }
+    </script>
+  </head>
+  <body>App</body>
+</html>
+```
+
+The result would be the following snippet. Just place this `<script>` tag before all other script references in the head tag.
+Then the application can access the `window.AppConfig` object to bootstrap its configuration.
+
+```html
+<!-- ... -->
+  <script>
+    try {
+      window.AppConfig = {
+        "url": "https://docs.couper.io/",
+        "prop": "value",
+      };
+    } catch(e) {
+      console.warn('DEVELOPMENT MODE: ', e)
+      window.AppConfig = {} // fallback for local development
+    }
+  </script>
+<!-- ... -->
+```
+
 ::attributes
 ---
 values: [
@@ -35,6 +94,18 @@ values: [
     "name": "base_path",
     "type": "string"
   },
+  {
+    "default": "",
+    "description": "JSON object which replaces the placeholder from `bootstrap_file` content.",
+    "name": "bootstrap_data",
+    "type": "object"
+  },
+  {
+    "default": "\"__BOOTSTRAP_DATA__\"",
+    "description": "String which will be replaced with `bootstrap_data`.",
+    "name": "bootstrap_data_placeholder",
+    "type": "string"
+  },
   {
     "default": "",
     "description": "Location of the bootstrap file.",
diff --git a/handler/file.go b/handler/file.go
index c74543a0..e647466c 100644
--- a/handler/file.go
+++ b/handler/file.go
@@ -28,11 +28,12 @@ type File struct {
 	basePath   string
 	errorTpl   *errors.Template
 	modifier   []hcl.Body
+	preferSPA  PreferSPAfn
 	rootDir    http.Dir
 	srvOptions *server.Options
 }
 
-func NewFile(docRoot, basePath string, errorTpl *errors.Template, srvOpts *server.Options, modifier []hcl.Body) (*File, error) {
+func NewFile(docRoot, basePath string, preferFn PreferSPAfn, errorTpl *errors.Template, srvOpts *server.Options, modifier []hcl.Body) (*File, error) {
 	dir, err := filepath.Abs(docRoot)
 	if err != nil {
 		return nil, err
@@ -58,6 +59,7 @@ func NewFile(docRoot, basePath string, errorTpl *errors.Template, srvOpts *serve
 		basePath:   basePath,
 		errorTpl:   errorTpl,
 		modifier:   modifier,
+		preferSPA:  preferFn,
 		srvOptions: srvOpts,
 		rootDir:    http.Dir(dir),
 	}
@@ -132,9 +134,9 @@ func (f *File) HasResponse(req *http.Request) bool {
 	file.Close()
 
 	if info.IsDir() {
-		reqPath = path.Join(reqPath, "/", dirIndexFile)
+		filePath := path.Join(reqPath, "/", dirIndexFile)
 
-		file, info, err = f.openDocRootFile(reqPath)
+		file, info, err = f.openDocRootFile(filePath)
 		if err != nil {
 			return false
 		}
@@ -143,9 +145,12 @@ func (f *File) HasResponse(req *http.Request) bool {
 		if info.IsDir() {
 			return false
 		}
+
+		return !f.preferSPA(reqPath)
 	}
 
-	return true
+	// TODO improve performance for this range call
+	return !f.preferSPA(reqPath)
 }
 
 func (f *File) openDocRootFile(name string) (http.File, os.FileInfo, error) {
@@ -188,3 +193,20 @@ func (f *File) Options() *server.Options {
 func (f *File) String() string {
 	return "file"
 }
+
+type PreferSPAfn func(string) bool
+
+func NewPreferSpaFn(bootstrapFiles []string, docRoot string) PreferSPAfn {
+	absDocRoot, _ := filepath.Abs(docRoot)
+	files := bootstrapFiles[:]
+	return func(subPath string) bool {
+		fileHandlerPath := filepath.Join(absDocRoot, subPath)
+		for _, f := range files {
+			if filepath.Dir(f) == fileHandlerPath || // baseDir index case
+				f == fileHandlerPath { // direct spa file call
+				return true
+			}
+		}
+		return false
+	}
+}
diff --git a/handler/spa.go b/handler/spa.go
index c222066b..6f4ece4e 100644
--- a/handler/spa.go
+++ b/handler/spa.go
@@ -1,12 +1,20 @@
 package handler
 
 import (
+	"bytes"
+	"encoding/json"
+	"io"
 	"net/http"
 	"os"
 	"path/filepath"
+	"strings"
+	"sync"
+	"time"
 
 	"github.com/hashicorp/hcl/v2"
+	ctyjson "github.com/zclconf/go-cty/cty/json"
 
+	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/runtime/server"
 	"github.com/avenga/couper/errors"
 	"github.com/avenga/couper/eval"
@@ -19,48 +27,132 @@ var (
 )
 
 type Spa struct {
-	file       string
-	modifier   []hcl.Body
-	srvOptions *server.Options
+	bootstrapContent []byte
+	bootstrapModTime time.Time
+	bootstrapOnce    sync.Once
+	config           *config.Spa
+	modifier         []hcl.Body
+	srvOptions       *server.Options
 }
 
-func NewSpa(bootstrapFile string, srvOpts *server.Options, modifier []hcl.Body) (*Spa, error) {
-	absPath, err := filepath.Abs(bootstrapFile)
-	if err != nil {
+func NewSpa(ctx *hcl.EvalContext, config *config.Spa, srvOpts *server.Options, modifier []hcl.Body) (*Spa, error) {
+	var err error
+	if config.BootstrapFile, err = filepath.Abs(config.BootstrapFile); err != nil {
 		return nil, err
 	}
-	return &Spa{
-		file:       absPath,
+
+	spa := &Spa{
+		config:     config,
 		modifier:   modifier,
 		srvOptions: srvOpts,
-	}, nil
+	}
+
+	file, err := os.Open(config.BootstrapFile)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	fileInfo, err := file.Stat()
+	if err != nil {
+		return nil, err
+	}
+	spa.bootstrapModTime = fileInfo.ModTime()
+
+	if config.BootstrapData == nil {
+		return spa, nil
+	} else if v, diags := config.BootstrapData.Value(ctx); v.IsNull() || diags.HasErrors() {
+		if diags.HasErrors() {
+			return nil, diags
+		}
+		return spa, nil
+	}
+
+	err = spa.replaceBootstrapData(ctx, file)
+
+	return spa, err
 }
 
 func (s *Spa) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
-	file, err := os.Open(s.file)
-	if err != nil {
-		if _, ok := err.(*os.PathError); ok {
-			s.srvOptions.ServerErrTpl.WithError(errors.RouteNotFound).ServeHTTP(rw, req)
+	var content io.ReadSeeker
+	var modTime time.Time
+
+	if len(s.bootstrapContent) > 0 {
+		content = strings.NewReader(string(s.bootstrapContent))
+		modTime = s.bootstrapModTime
+	} else {
+		file, err := os.Open(s.config.BootstrapFile)
+		if err != nil {
+			if _, ok := err.(*os.PathError); ok {
+				s.srvOptions.ServerErrTpl.WithError(errors.RouteNotFound).ServeHTTP(rw, req)
+				return
+			}
+
+			s.srvOptions.ServerErrTpl.WithError(errors.Configuration).ServeHTTP(rw, req)
 			return
 		}
+		content = file
+		defer file.Close()
 
-		s.srvOptions.ServerErrTpl.WithError(errors.Configuration).ServeHTTP(rw, req)
-		return
-	}
-	defer file.Close()
+		fileInfo, err := file.Stat()
+		if err != nil || fileInfo.IsDir() {
+			s.srvOptions.ServerErrTpl.WithError(errors.Configuration).ServeHTTP(rw, req)
+			return
+		}
 
-	fileInfo, err := file.Stat()
-	if err != nil || fileInfo.IsDir() {
-		s.srvOptions.ServerErrTpl.WithError(errors.Configuration).ServeHTTP(rw, req)
-		return
+		modTime = fileInfo.ModTime()
 	}
 
+	evalContext := eval.ContextFromRequest(req)
+
 	if r, ok := rw.(*writer.Response); ok {
-		evalContext := eval.ContextFromRequest(req)
 		r.AddModifier(evalContext.HCLContext(), s.modifier...)
 	}
 
-	http.ServeContent(rw, req, s.file, fileInfo.ModTime(), file)
+	http.ServeContent(rw, req, s.config.BootstrapFile, modTime, content)
+}
+
+func (s *Spa) replaceBootstrapData(ctx *hcl.EvalContext, reader io.ReadCloser) error {
+	if s.config.BootstrapData == nil {
+		return nil
+	}
+
+	b, err := io.ReadAll(reader)
+	if err != nil {
+		return err
+	}
+
+	val, diags := s.config.BootstrapData.Value(ctx)
+	if diags.HasErrors() {
+		return diags
+	}
+
+	if !val.Type().IsObjectType() {
+		r := s.config.BootstrapData.Range()
+		return &hcl.Diagnostic{
+			Detail:   "bootstrap_data must be an object type",
+			Severity: hcl.DiagError,
+			Subject:  &r,
+			Summary:  "configuration error",
+		}
+	}
+
+	data, err := ctyjson.Marshal(val, val.Type())
+	if err != nil {
+		return err
+	}
+
+	escapedData := &bytes.Buffer{}
+	json.HTMLEscape(escapedData, data)
+
+	const defaultName = "__BOOTSTRAP_DATA__"
+	bootstrapName := s.config.BootStrapDataName
+	if bootstrapName == "" {
+		bootstrapName = defaultName
+	}
+	s.bootstrapContent = bytes.Replace(b, []byte(bootstrapName), escapedData.Bytes(), 1)
+
+	return nil
 }
 
 func (s *Spa) Options() *server.Options {


Test patch:
diff --git a/config/runtime/access_control_test.go b/config/runtime/access_control_test.go
index 6d8715f4..b9611e28 100644
--- a/config/runtime/access_control_test.go
+++ b/config/runtime/access_control_test.go
@@ -160,7 +160,7 @@ func TestDuplicateEndpoint(t *testing.T) {
 			"spa + api: catch-all",
 			`spa {
 			   base_path = "/public"
-			   bootstrap_file = "foo"
+			   bootstrap_file = "access_control_test.go"
 			   paths = []
 			 }
 			 api {
@@ -172,7 +172,7 @@ func TestDuplicateEndpoint(t *testing.T) {
 			"spa + api, same base path: no catch-all",
 			`spa {
 			    base_path = "/path"
-				bootstrap_file = "foo"
+				bootstrap_file = "access_control_test.go"
 				paths = []
 			 }
 			 api {
@@ -186,7 +186,7 @@ func TestDuplicateEndpoint(t *testing.T) {
 			"files + api, same base path: no catch-all",
 			`spa {
 			   base_path = "/p"
-			   bootstrap_file = "foo"
+			   bootstrap_file = "access_control_test.go"
 			   paths = []
 			 }
 			 api {
diff --git a/handler/file_test.go b/handler/file_test.go
index a2f6c34c..f139719b 100644
--- a/handler/file_test.go
+++ b/handler/file_test.go
@@ -44,7 +44,9 @@ func TestFile_ServeHTTP(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
-			f, err := NewFile(path.Join(wd, tt.fields.docRootDir), tt.fields.basePath, errors.DefaultHTML, srvOpts, nil)
+			f, err := NewFile(path.Join(wd, tt.fields.docRootDir), tt.fields.basePath, func(s string) bool {
+				return false
+			}, errors.DefaultHTML, srvOpts, nil)
 			if err != nil {
 				subT.Fatal(err)
 			}
diff --git a/handler/spa_test.go b/handler/spa_test.go
index 359ee275..bd81f823 100644
--- a/handler/spa_test.go
+++ b/handler/spa_test.go
@@ -1,39 +1,98 @@
 package handler_test
 
 import (
+	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"os"
 	"path"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
+	"github.com/hashicorp/hcl/v2"
+	"github.com/zclconf/go-cty/cty"
+
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/runtime/server"
-
+	"github.com/avenga/couper/eval"
 	"github.com/avenga/couper/handler"
 )
 
 func TestSpa_ServeHTTP(t *testing.T) {
-	wd, err := os.Getwd()
-	if err != nil {
-		t.Error(err)
+	wd, werr := os.Getwd()
+	if werr != nil {
+		t.Error(werr)
+	}
+
+	appHtmlContent, ferr := os.ReadFile(path.Join(wd, "testdata/spa/app.html"))
+	if ferr != nil {
+		t.Fatal(ferr)
 	}
 
 	tests := []struct {
-		name         string
-		filePath     string
-		req          *http.Request
-		expectedCode int
+		cfg             *config.Spa
+		req             *http.Request
+		expectedContent []byte
+		expectedCode    int
+		expectedErr     string
 	}{
-		{"serve bootstrap file", "testdata/spa/app.html", httptest.NewRequest(http.MethodGet, "/", nil), http.StatusOK},
-		{"serve no bootstrap file", "testdata/spa/not_exist.html", httptest.NewRequest(http.MethodGet, "/", nil), http.StatusNotFound},
-		{"serve bootstrap dir", "testdata/spa", httptest.NewRequest(http.MethodGet, "/", nil), http.StatusInternalServerError},
+		{&config.Spa{Name: "serve bootstrap file", BootstrapFile: path.Join(wd, "testdata/spa/app.html")}, httptest.NewRequest(http.MethodGet, "/", nil), appHtmlContent, http.StatusOK, ""},
+		{&config.Spa{Name: "serve no bootstrap file", BootstrapFile: path.Join(wd, "testdata/spa/not_exist.html")}, httptest.NewRequest(http.MethodGet, "/", nil), nil, http.StatusNotFound, fmt.Sprintf("open %s/testdata/spa/not_exist.html: no such file or directory", wd)},
+		{&config.Spa{Name: "serve bootstrap dir", BootstrapFile: path.Join(wd, "testdata/spa")}, httptest.NewRequest(http.MethodGet, "/", nil), nil, http.StatusInternalServerError, ""},
+		{&config.Spa{Name: "serve bootstrap file /w simple-data", BootstrapFile: path.Join(wd, "testdata/spa/app_bs_data.html"),
+			BootstrapData: hcl.StaticExpr(cty.StringVal("no-object"), hcl.Range{})}, httptest.NewRequest(http.MethodGet, "/", nil),
+			nil, http.StatusInternalServerError, ":0,0-0: configuration error; bootstrap_data must be an object type"},
+		{&config.Spa{Name: "serve bootstrap file /w obj-data", BootstrapFile: path.Join(wd, "testdata/spa/app_bs_data.html"),
+			BootstrapData: hcl.StaticExpr(cty.ObjectVal(map[string]cty.Value{"prop": cty.StringVal("value")}), hcl.Range{})}, httptest.NewRequest(http.MethodGet, "/", nil),
+			[]byte(`<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>App</title>
+    <script>const conf = {"prop":"value"};</script>
+</head>
+<body>
+App with __BOOTSTRAP_DATA__.
+</body>
+</html>
+`), http.StatusOK, ""},
+		{&config.Spa{Name: "serve bootstrap file /w obj-data /custom placeholder", BootStrapDataName: "__SERVER_DATA__", BootstrapFile: path.Join(wd, "testdata/spa/app_bs_data_custom.html"),
+			BootstrapData: hcl.StaticExpr(cty.ObjectVal(map[string]cty.Value{"prop": cty.StringVal("value")}), hcl.Range{})}, httptest.NewRequest(http.MethodGet, "/", nil),
+			[]byte(`<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>App</title>
+    <script>const conf = {"prop":"value"};</script>
+</head>
+<body>
+App with __SERVER_DATA__.
+</body>
+</html>
+`), http.StatusOK, ""},
+		{&config.Spa{Name: "serve bootstrap file /w html obj-data", BootstrapFile: path.Join(wd, "testdata/spa/app_bs_data.html"),
+			BootstrapData: hcl.StaticExpr(cty.ObjectVal(map[string]cty.Value{"prop": cty.StringVal("</script>")}), hcl.Range{})}, httptest.NewRequest(http.MethodGet, "/", nil),
+			[]byte(`<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>App</title>
+    <script>const conf = {"prop":"\u003c/script\u003e"};</script>
+</head>
+<body>
+App with __BOOTSTRAP_DATA__.
+</body>
+</html>
+`), http.StatusOK, ""},
 	}
 	for _, tt := range tests {
-		t.Run(tt.name, func(subT *testing.T) {
+		t.Run(tt.cfg.Name, func(subT *testing.T) {
 			opts, _ := server.NewServerOptions(&config.Server{}, nil)
-			s, err := handler.NewSpa(path.Join(wd, tt.filePath), opts, nil)
+			s, err := handler.NewSpa(eval.NewDefaultContext().HCLContext(), tt.cfg, opts, nil)
 			if err != nil {
+				if tt.expectedErr != "" && err.Error() == tt.expectedErr {
+					return
+				}
 				subT.Fatal(err)
 			}
 
@@ -47,6 +106,12 @@ func TestSpa_ServeHTTP(t *testing.T) {
 			if res.Code != tt.expectedCode {
 				subT.Errorf("Expected status code %d, got: %d", tt.expectedCode, res.Code)
 			}
+
+			if tt.expectedContent != nil {
+				if diff := cmp.Diff(tt.expectedContent, []byte(res.Body.String())); diff != "" {
+					t.Error(diff)
+				}
+			}
 		})
 	}
 }
diff --git a/handler/testdata/spa/app_bs_data.html b/handler/testdata/spa/app_bs_data.html
new file mode 100644
index 00000000..835caef0
--- /dev/null
+++ b/handler/testdata/spa/app_bs_data.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>App</title>
+    <script>const conf = __BOOTSTRAP_DATA__;</script>
+</head>
+<body>
+App with __BOOTSTRAP_DATA__.
+</body>
+</html>
diff --git a/handler/testdata/spa/app_bs_data_custom.html b/handler/testdata/spa/app_bs_data_custom.html
new file mode 100644
index 00000000..43fe8d65
--- /dev/null
+++ b/handler/testdata/spa/app_bs_data_custom.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>App</title>
+    <script>const conf = __SERVER_DATA__;</script>
+</head>
+<body>
+App with __SERVER_DATA__.
+</body>
+</html>
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index 3fb45b7d..7fc3854b 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -271,11 +271,11 @@ func TestHTTPServer_ServeHTTP(t *testing.T) {
 		{"files_spa_api/01_couper.hcl", []requestCase{
 			{
 				testRequest{http.MethodGet, "http://anyserver:8080/"},
-				expectation{http.StatusOK, []byte(`<html><body><title>FS</title></body></html>`), nil, "file"},
+				expectation{http.StatusOK, []byte("<html><body><title>SPA_01</title>{\"default\":\"true\"}</body></html>\n"), nil, "spa"},
 			},
 			{
 				testRequest{http.MethodGet, "http://anyserver:8080/foo"},
-				expectation{http.StatusOK, []byte("<html><body><title>SPA_01</title></body></html>\n"), nil, "spa"},
+				expectation{http.StatusOK, []byte("<html><body><title>SPA_01</title>{\"default\":\"true\"}</body></html>\n"), nil, "spa"},
 			},
 		}},
 		{"api/01_couper.hcl", []requestCase{
diff --git a/server/http_test.go b/server/http_test.go
index cd860215..4399ee8f 100644
--- a/server/http_test.go
+++ b/server/http_test.go
@@ -222,9 +222,12 @@ func TestHTTPServer_ServeHTTP_Files2(t *testing.T) {
 		{"/dir:", error404Content, 404},
 		{"/dir.txt", error404Content, 404},
 		// dir w/ index in files
-		{"/dir", nil, 302},
+		{"/another_dir", nil, http.StatusFound},
+		{"/another_dir/", []byte("<html>this is another_dir/index.html</html>\n"), http.StatusOK},
+		// dir w/ index in files but also a spa mount path
+		{"/dir", spaContent, http.StatusOK},
 		// dir/ w/ index in files
-		{"/dir/", []byte("<html>this is dir/index.html</html>\n"), 200},
+		{"/dir/", spaContent, 200},
 		// dir w/o index in files
 		{"/assets/noindex", error404Content, 404},
 		{"/assets/noindex/", error404Content, 404},
@@ -242,8 +245,9 @@ func TestHTTPServer_ServeHTTP_Files2(t *testing.T) {
 		{"/app/bla", spaContent, 200},
 		{"/app/bla/foo", spaContent, 200},
 		{"/api/foo/bar", []byte("/bar"), 200},
-		//FIXME:
-		//{"/api", content500.Bytes(), 500},
+		// spa > file
+		{"/my_app", []byte(`<html><body><h1>{"framework":"react.js"}</h1></body></html>`), http.StatusOK},
+		{"/my_app/spa.html", []byte(`<html><body><h1>{"framework":"react.js"}</h1></body></html>`), http.StatusOK},
 	} {
 		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://%s%s", couper.Addr(), testCase.path), nil)
 		helper.Must(err)
@@ -253,7 +257,7 @@ func TestHTTPServer_ServeHTTP_Files2(t *testing.T) {
 		helper.Must(err)
 
 		if res.StatusCode != testCase.expectedStatus {
-			t.Fatalf("%.2d: expected status for path %q %d, got %d", i+1, testCase.path, testCase.expectedStatus, res.StatusCode)
+			t.Errorf("%.2d: expected status for path %q %d, got %d", i+1, testCase.path, testCase.expectedStatus, res.StatusCode)
 		}
 
 		result, err := io.ReadAll(res.Body)
diff --git a/server/testdata/file_serving/conf_fileserving.hcl b/server/testdata/file_serving/conf_fileserving.hcl
index fe32170d..c3d9bdea 100644
--- a/server/testdata/file_serving/conf_fileserving.hcl
+++ b/server/testdata/file_serving/conf_fileserving.hcl
@@ -16,10 +16,21 @@ server "fileserving-tests" {
             // api wins
             "/api/**",
             // spa wins
-            "/"
+            "/",
         ]
     }
 
+    spa "myapp" {
+        bootstrap_file = "./htdocs/my_app/spa.html"
+        paths = [
+            "/my_app",
+            "/my_app/**"
+        ]
+        bootstrap_data = {
+            framework: "react.js"
+        }
+    }
+
     api {
         base_path = "/api"
         error_file = "./error.json"
diff --git a/server/testdata/file_serving/htdocs/another_dir/index.html b/server/testdata/file_serving/htdocs/another_dir/index.html
new file mode 100644
index 00000000..fa42f3f2
--- /dev/null
+++ b/server/testdata/file_serving/htdocs/another_dir/index.html
@@ -0,0 +1,1 @@
+<html>this is another_dir/index.html</html>
diff --git a/server/testdata/file_serving/htdocs/my_app/index.html b/server/testdata/file_serving/htdocs/my_app/index.html
new file mode 100644
index 00000000..0cef8808
--- /dev/null
+++ b/server/testdata/file_serving/htdocs/my_app/index.html
@@ -0,0 +1,1 @@
+<html>this is my_app/index.html</html>
diff --git a/server/testdata/file_serving/htdocs/my_app/spa.html b/server/testdata/file_serving/htdocs/my_app/spa.html
new file mode 100644
index 00000000..72a98c89
--- /dev/null
+++ b/server/testdata/file_serving/htdocs/my_app/spa.html
@@ -0,0 +1,1 @@
+<html><body><h1>__BOOTSTRAP_DATA__</h1></body></html>
diff --git a/server/testdata/integration/files_spa_api/01_app.html b/server/testdata/integration/files_spa_api/01_app.html
index 7ec0ba36..b346a9a5 100644
--- a/server/testdata/integration/files_spa_api/01_app.html
+++ b/server/testdata/integration/files_spa_api/01_app.html
@@ -1,1 +1,1 @@
-<html><body><title>SPA_01</title></body></html>
+<html><body><title>SPA_01</title>__BOOTSTRAP_DATA__</body></html>
diff --git a/server/testdata/integration/files_spa_api/01_couper.hcl b/server/testdata/integration/files_spa_api/01_couper.hcl
index 8cffaec7..457cf4e5 100644
--- a/server/testdata/integration/files_spa_api/01_couper.hcl
+++ b/server/testdata/integration/files_spa_api/01_couper.hcl
@@ -6,6 +6,9 @@ server "spa" {
   spa {
     bootstrap_file = "01_app.html"
     paths = ["/**"]
+    bootstrap_data = {
+      default = default(env.NOT_THERE, "true")
+    }
   }
   api {
     error_file = "./../api_error.json"