Repository: avenga/couper
Pull request #510 (2022-05-24 07:33:56)

Change multiple spa blocks
Allow multiple spa block definitions.

---
<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>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac80c67c..6746c95b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
     * renamed `beta_insufficient_scope` [error type](./docs/ERRORS.md#api-and-endpoint-error-types) to `beta_insufficient_permissions`
     * added `request.context.beta_required_permission` and `request.context.beta_granted_permissions` [request variables](./docs/REFERENCE.md#request)
   * Clarified the type of various [attributes/variables](./docs/REFERENCE.md) ([#485](https://github.com/avenga/couper/pull/485))
+  * [`spa` block](./docs/REFERENCE.md#spa-block) can be defined multiple times now ([#510](https://github.com/avenga/couper/pull/510))
 
 * **Fixed**
   * Keys in object type attribute values are only handled case-insensitively if reasonable (e.g. they represent HTTP methods or header field values) ([#461](https://github.com/avenga/couper/pull/461))
diff --git a/config/configload/load.go b/config/configload/load.go
index 67b4dcc9..6f74add4 100644
--- a/config/configload/load.go
+++ b/config/configload/load.go
@@ -35,6 +35,7 @@ const (
 	request      = "request"
 	server       = "server"
 	settings     = "settings"
+	spa          = "spa"
 	// defaultNameLabel maps the hcl label attr 'name'.
 	defaultNameLabel = "default"
 )
@@ -396,6 +397,18 @@ func LoadConfig(body hcl.Body, src []byte, filename, dirPath string) (*config.Co
 			}
 		}
 
+		for _, spaBlock := range bodyToContent(serverConfig.Remain).Blocks.OfType(spa) {
+			spaConfig := &config.Spa{}
+			if diags := gohcl.DecodeBody(spaBlock.Body, helper.context, spaConfig); diags.HasErrors() {
+				return nil, diags
+			}
+
+			if len(spaBlock.Labels) > 0 {
+				spaConfig.Name = spaBlock.Labels[0]
+			}
+			serverConfig.SPAs = append(serverConfig.SPAs, spaConfig)
+		}
+
 		// standalone endpoints
 		err = refineEndpoints(helper, serverConfig.Endpoints, true)
 		if err != nil {
diff --git a/config/configload/merge.go b/config/configload/merge.go
index f3d58d64..91448248 100644
--- a/config/configload/merge.go
+++ b/config/configload/merge.go
@@ -32,7 +32,17 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 			endpoints       namedBlocks
 			errorHandler    namedBlocks
 		}
+		spaDefinition struct {
+			labels          []string
+			typeRange       hcl.Range
+			labelRanges     []hcl.Range
+			openBraceRange  hcl.Range
+			closeBraceRange hcl.Range
+			attributes      hclsyntax.Attributes
+			blocks          namedBlocks
+		}
 		namedAPIs        map[string]*apiDefinition
+		namedSPAs        map[string]*spaDefinition
 		serverDefinition struct {
 			labels          []string
 			typeRange       hcl.Range
@@ -43,7 +53,9 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 			blocks          namedBlocks
 			endpoints       namedBlocks
 			apis            namedAPIs
+			spas            namedSPAs
 		}
+
 		servers map[string]*serverDefinition
 	)
 
@@ -99,6 +111,7 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 					blocks:          make(namedBlocks),
 					endpoints:       make(namedBlocks),
 					apis:            make(namedAPIs),
+					spas:            make(namedSPAs),
 				}
 			}
 
@@ -111,14 +124,11 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 			}
 
 			for _, block := range outerBlock.Body.Blocks {
-				uniqueAPILabels := make(map[string]struct{})
+				uniqueAPILabels, uniqueSPALabels := make(map[string]struct{}), make(map[string]struct{})
 
-				// TODO: Do we need this IF around the FOR?
-				if block.Type == "files" || block.Type == "spa" || block.Type == api || block.Type == endpoint {
-					for _, name := range []string{"error_file", "document_root"} {
-						if attr, ok := block.Body.Attributes[name]; ok {
-							block.Body.Attributes[name].Expr = absPath(attr)
-						}
+				for _, name := range []string{"error_file", "document_root"} {
+					if attr, ok := block.Body.Attributes[name]; ok {
+						block.Body.Attributes[name].Expr = absPath(attr)
 					}
 				}
 
@@ -207,6 +217,44 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 							results[serverKey].apis[apiKey].blocks[subBlock.Type] = subBlock
 						}
 					}
+				} else if block.Type == spa {
+					var spaKey string
+
+					if len(block.Labels) > 0 {
+						spaKey = block.Labels[0]
+					}
+
+					if len(bodies) > 1 {
+						if _, ok := uniqueSPALabels[spaKey]; ok {
+							return nil, newMergeError(errUniqueLabels, block)
+						}
+
+						uniqueSPALabels[spaKey] = struct{}{}
+					} else {
+						// Create unique key for multiple spa blocks inside a single config file.
+						spaKey += fmt.Sprintf("|%p", &spaKey)
+					}
+
+					if results[serverKey].spas[spaKey] == nil {
+						results[serverKey].spas[spaKey] = &spaDefinition{
+							labels:          block.Labels,
+							typeRange:       block.TypeRange,
+							labelRanges:     block.LabelRanges,
+							openBraceRange:  block.OpenBraceRange,
+							closeBraceRange: block.CloseBraceRange,
+							attributes:      make(hclsyntax.Attributes),
+							blocks:          make(namedBlocks),
+						}
+					}
+
+					for name, attr := range block.Body.Attributes {
+						results[serverKey].spas[spaKey].attributes[name] = attr
+					}
+
+					for _, subBlock := range block.Body.Blocks {
+						results[serverKey].spas[spaKey].blocks[subBlock.Type] = subBlock
+					}
+
 				} else {
 					results[serverKey].blocks[block.Type] = block
 				}
@@ -258,6 +306,29 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 			serverBlocks = append(serverBlocks, mergedAPI)
 		}
 
+		for _, spaBlock := range serverBlock.spas {
+			var spaBlocks hclsyntax.Blocks
+
+			for _, b := range spaBlock.blocks {
+				spaBlocks = append(spaBlocks, b)
+			}
+
+			mergedSPA := &hclsyntax.Block{
+				Type:   spa,
+				Labels: spaBlock.labels,
+				Body: &hclsyntax.Body{
+					Attributes: spaBlock.attributes,
+					Blocks:     spaBlocks,
+				},
+				TypeRange:       spaBlock.typeRange,
+				LabelRanges:     spaBlock.labelRanges,
+				OpenBraceRange:  spaBlock.openBraceRange,
+				CloseBraceRange: spaBlock.closeBraceRange,
+			}
+
+			serverBlocks = append(serverBlocks, mergedSPA)
+		}
+
 		mergedServer := &hclsyntax.Block{
 			Type:   server,
 			Labels: serverBlock.labels,
diff --git a/config/configload/schema.go b/config/configload/schema.go
index 0fb01dcc..3c274ed9 100644
--- a/config/configload/schema.go
+++ b/config/configload/schema.go
@@ -229,7 +229,7 @@ func completeSchemaComponents(body hcl.Body, schema *hcl.BodySchema, attrs hcl.A
 			added := false
 			for _, block := range bodyContent.Blocks {
 				switch block.Type {
-				case "api", "backend", "error_handler", "proxy", "request", "server":
+				case api, backend, errorHandler, proxy, request, server, spa:
 					blocks = append(blocks, block)
 
 					added = true
diff --git a/config/runtime/endpoint.go b/config/runtime/endpoint.go
index 262d9b9e..5e495b76 100644
--- a/config/runtime/endpoint.go
+++ b/config/runtime/endpoint.go
@@ -40,15 +40,22 @@ func newEndpointMap(srvConf *config.Server, serverOptions *server.Options) (endp
 			continue
 		}
 
-		var filesBasePath, spaBasePath string
+		var filesBasePath string
 		if serverOptions.FilesBasePath != "" {
 			filesBasePath = serverOptions.FilesBasePath
 		}
-		if serverOptions.SPABasePath != "" {
-			spaBasePath = serverOptions.SPABasePath
-		}
 
-		isAPIBasePathUniqueToFilesAndSPA := basePath != filesBasePath && basePath != spaBasePath
+		var isAPIBasePathUniqueToFilesAndSPA bool
+		if len(serverOptions.SPABasePaths) > 0 {
+			for _, s := range serverOptions.SPABasePaths {
+				isAPIBasePathUniqueToFilesAndSPA = basePath != filesBasePath && basePath != s
+				if !isAPIBasePathUniqueToFilesAndSPA {
+					break
+				}
+			}
+		} else {
+			isAPIBasePathUniqueToFilesAndSPA = basePath != filesBasePath
+		}
 
 		if isAPIBasePathUniqueToFilesAndSPA {
 			endpoints[apiConf.CatchAllEndpoint] = apiConf
diff --git a/config/runtime/server.go b/config/runtime/server.go
index 064c9f14..7d0bd7a1 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/docker/go-units"
 	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/hclsyntax"
 	"github.com/sirupsen/logrus"
 
 	ac "github.com/avenga/couper/accesscontrol"
@@ -144,8 +145,8 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 		serverBodies = append(serverBodies, srvConf.Remain)
 
 		var spaHandler http.Handler
-		if srvConf.Spa != nil {
-			spaHandler, err = handler.NewSpa(srvConf.Spa.BootstrapFile, serverOptions, []hcl.Body{srvConf.Spa.Remain, srvConf.Remain})
+		for _, spaConf := range srvConf.SPAs {
+			spaHandler, err = handler.NewSpa(spaConf.BootstrapFile, serverOptions, []hcl.Body{spaConf.Remain, srvConf.Remain})
 			if err != nil {
 				return nil, err
 			}
@@ -157,7 +158,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 
 			spaHandler, err = configureProtectedHandler(accessControls, confCtx,
 				config.NewAccessControl(srvConf.AccessControl, srvConf.DisableAccessControl),
-				config.NewAccessControl(srvConf.Spa.AccessControl, srvConf.Spa.DisableAccessControl),
+				config.NewAccessControl(spaConf.AccessControl, spaConf.DisableAccessControl),
 				&protectedOptions{
 					epOpts:   epOpts,
 					handler:  spaHandler,
@@ -169,22 +170,27 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca
 				return nil, err
 			}
 
-			corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, srvConf.Spa), allowedMethodsHandler.MethodAllowed)
+			corsOptions, cerr := middleware.NewCORSOptions(whichCORS(srvConf, spaConf), allowedMethodsHandler.MethodAllowed)
 			if cerr != nil {
 				return nil, cerr
 			}
 
 			spaHandler = middleware.NewCORSHandler(corsOptions, spaHandler)
 
-			spaBodies := bodiesWithACBodies(conf.Definitions, srvConf.Spa.AccessControl, srvConf.Spa.DisableAccessControl)
+			spaBodies := bodiesWithACBodies(conf.Definitions, spaConf.AccessControl, spaConf.DisableAccessControl)
 			spaHandler = middleware.NewCustomLogsHandler(
-				append(serverBodies, append(spaBodies, srvConf.Spa.Remain)...), spaHandler, "",
+				append(serverBodies, append(spaBodies, spaConf.Remain)...), spaHandler, "",
 			)
 
-			for _, spaPath := range srvConf.Spa.Paths {
-				err = setRoutesFromHosts(serverConfiguration, portsHosts, path.Join(serverOptions.SPABasePath, spaPath), spaHandler, spa)
+			for _, spaPath := range spaConf.Paths {
+				err = setRoutesFromHosts(serverConfiguration, portsHosts,
+					path.Join(serverOptions.SrvBasePath, spaConf.BasePath, spaPath), spaHandler, spa)
 				if err != nil {
-					return nil, err
+					sbody := spaConf.HCLBody().(*hclsyntax.Body)
+					return nil, hcl.Diagnostics{&hcl.Diagnostic{
+						Subject: &sbody.Attributes["paths"].SrcRange,
+						Summary: err.Error(),
+					}}
 				}
 			}
 		}
@@ -645,8 +651,6 @@ func setRoutesFromHosts(
 	path = utils.JoinPath("/", path)
 
 	for port, hosts := range portsHosts {
-		check := make(map[string]struct{})
-
 		for host := range hosts {
 			var routes map[string]http.Handler
 
@@ -663,13 +667,11 @@ func setRoutesFromHosts(
 				return fmt.Errorf("unknown route kind")
 			}
 
-			key := fmt.Sprintf("%d:%s:%s\n", port, host, path)
-			if _, exist := check[key]; exist {
-				return fmt.Errorf("duplicate route found on port %q: %q", port, path)
+			if _, exist := routes[path]; exist {
+				return fmt.Errorf("duplicate route found on port %d: %s", port, path)
 			}
 
 			routes[path] = handler
-			check[key] = struct{}{}
 		}
 	}
 
diff --git a/config/runtime/server/options.go b/config/runtime/server/options.go
index 607288c2..3d6d4bf6 100644
--- a/config/runtime/server/options.go
+++ b/config/runtime/server/options.go
@@ -20,7 +20,7 @@ type Options struct {
 	ServerErrTpl  *errors.Template
 	APIBasePaths  map[*config.API]string
 	FilesBasePath string
-	SPABasePath   string
+	SPABasePaths  []string
 	SrvBasePath   string
 	ServerName    string
 }
@@ -77,8 +77,8 @@ func NewServerOptions(conf *config.Server, logger *logrus.Entry) (*Options, erro
 		options.FilesBasePath = utils.JoinPath(options.SrvBasePath, conf.Files.BasePath)
 	}
 
-	if conf.Spa != nil {
-		options.SPABasePath = utils.JoinPath(options.SrvBasePath, conf.Spa.BasePath)
+	for _, s := range conf.SPAs {
+		options.SPABasePaths = append(options.SPABasePaths, utils.JoinPath(options.SrvBasePath, s.BasePath))
 	}
 
 	return options, nil
diff --git a/config/server.go b/config/server.go
index e3435bb6..d0f12898 100644
--- a/config/server.go
+++ b/config/server.go
@@ -19,8 +19,9 @@ type Server struct {
 	Hosts                []string  `hcl:"hosts,optional"`
 	Name                 string    `hcl:"name,label"`
 	Remain               hcl.Body  `hcl:",remain"`
-	Spa                  *Spa      `hcl:"spa,block"`
-	APIs                 APIs
+
+	APIs APIs
+	SPAs SPAs
 }
 
 // Servers represents a list of <Server> objects.
@@ -35,6 +36,7 @@ func (s Server) HCLBody() hcl.Body {
 func (s Server) Inline() interface{} {
 	type Inline struct {
 		APIs               APIs                      `hcl:"api,block"`
+		SPAs               SPAs                      `hcl:"spa,block"`
 		AddResponseHeaders map[string]string         `hcl:"add_response_headers,optional"`
 		DelResponseHeaders []string                  `hcl:"remove_response_headers,optional"`
 		SetResponseHeaders map[string]string         `hcl:"set_response_headers,optional"`
diff --git a/config/spa.go b/config/spa.go
index e5066872..8c6a38e2 100644
--- a/config/spa.go
+++ b/config/spa.go
@@ -7,6 +7,8 @@ import (
 
 var _ Inline = &Spa{}
 
+type SPAs []*Spa
+
 // Spa represents the <Spa> object.
 type Spa struct {
 	AccessControl        []string `hcl:"access_control,optional"`
@@ -14,6 +16,7 @@ type Spa struct {
 	BootstrapFile        string   `hcl:"bootstrap_file"`
 	CORS                 *CORS    `hcl:"cors,block"`
 	DisableAccessControl []string `hcl:"disable_access_control,optional"`
+	Name                 string   `hcl:"name,label"`
 	Paths                []string `hcl:"paths"`
 	Remain               hcl.Body `hcl:",remain"`
 }
diff --git a/docs/MERGE.md b/docs/MERGE.md
index b17f49f5..dbe8eb16 100644
--- a/docs/MERGE.md
+++ b/docs/MERGE.md
@@ -18,7 +18,7 @@ replace existing attributes with the same name, if any, otherwise they are added
 Blocks that cannot have labels (eg. `cors`, `files` etc.) replace existing blocks
 with the same name, if any, otherwise they are added.
 
-Blocks with optional labels (eg. `server`, `api` etc.) are merged recursively with
+Blocks with optional labels (eg. `server`, `api`, `spa` etc.) are merged recursively with
 blocks with the same label (blocks without a label are merged with blocks with the
 same name and no label in each context), if any, otherwise they are added. Only one
 unlabeled block of the same type is allowed in each context (eg. `api` blocks in
@@ -30,26 +30,31 @@ name and label in each context, if any, otherwise they are added.
 Blocks with (optional) multiple labels (eg. `error_handler`) replace existing blocks
 with identical labels, if any, otherwise they are added.
 
-Currently here is no way to remove an attribute or a block from the configuration.
+Currently, here is no way to remove an attribute or a block from the configuration.
 
 ## Merging of `server` Blocks
 
 * When `server` blocks are merged:
   * All attributes replace existing attributes with the same name, if any, otherwise
     they are added.
-  * The `cors`, `files` and `spa` blocks replace existing blocks with the same 
-    name, if any, otherwise they are added.
+  * The `cors` and `files` blocks replace existing blocks with the same name, if any,
+    otherwise they are added.
   * All `endpoint` blocks replace existing blocks with the same label, if any, otherwise
     they are added.
+  * When `spa` blocks are merged:
+    * All attributes replace existing attributes with the same name, if any, otherwise
+      they are added.
+    * The `cors` block replaces existing `cors` block, if any, otherwise a new `cors`
+      block is added.
   * When `api` blocks are merged:
     * All attributes replace existing attributes with the same name, if any, otherwise
-	  they are added.
+      they are added.
     * The `cors` block replaces existing `cors` block, if any, otherwise a new `cors`
-	  block is added.
+      block is added.
     * All `endpoint` blocks replace existing blocks with the same label, if any,
-	  otherwise they are added.
+      otherwise they are added.
     * All `error_handler` blocks replace existing blocks with identical labels,
-	  if any, otherwise they are added.
+      if any, otherwise they are added.
 
 **Note:** An `error_handler` block cannot be replaced in or added to an `endpoint`
 block. Therefore, the `endpoint` block must be completely replaced.
diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md
index 4aa5b03c..75bdbc02 100644
--- a/docs/REFERENCE.md
+++ b/docs/REFERENCE.md
@@ -56,9 +56,9 @@
 
 The `server` block is one of the root configuration blocks of Couper's configuration file.
 
-| Block name | Context | Label            | Nested block(s) |
-| :--------- | :------ | :--------------- | :-------------- |
-| `server`   | -       | optional | [CORS Block](#cors-block), [Files Block](#files-block), [SPA Block](#spa-block) , [API Block(s)](#api-block), [Endpoint Block(s)](#endpoint-block) |
+| Block name | Context | Label    | Nested block(s)                                                                                                                                       |
+|:-----------|:--------|:---------|:------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `server`   | -       | optional | [CORS Block](#cors-block), [Files Block](#files-block), [SPA Block(s)](#spa-block) , [API Block(s)](#api-block), [Endpoint Block(s)](#endpoint-block) |
 
 | Attribute(s)     | Type   | Default      | Description | Characteristic(s) | Example |
 | :--------------- | :----- | :----------- | :---------- | :---------------- | :------ |
@@ -88,20 +88,20 @@ The `files` block configures the file serving.
 
 ### SPA Block
 
-The `spa` block configures the Web serving for SPA assets.
+The `spa` blocks configures the Web serving for SPA assets. Can be defined multiple times as long as the `base_path`+`paths` is unique.
 
 | Block name | Context                       | Label    | Nested block(s)           |
-| :--------- | :---------------------------- | :------- | :------------------------ |
-| `spa`      | [Server Block](#server-block) | no label | [CORS Block](#cors-block) |
-
-| Attribute(s)     | Type   | Default | Description | Characteristic(s) | Example |
-| :--------------- | :----- | :------ | :---------- | :---------------- | :------ |
-| `base_path`      | string | -       | Configures the path prefix for all requests. | - | `base_path = "/assets"` |
-| `bootstrap_file` | string | -       | Location of the bootstrap file. | &#9888; required | `bootstrap_file = "./htdocs/index.html"` |
-| `paths`          | tuple (string) | - | List of SPA paths that need the bootstrap file. | &#9888; required | `paths = ["/app/**"]` |
-| `access_control` | tuple (string) | - | Sets predefined [Access Control](#access-control) for `spa` block context. | - | `access_control = ["foo"]` |
-| `disable_access_control` | tuple (string) | - | Disables access controls by name. | - | `disable_access_control = ["foo"]` |
-| `custom_log_fields` | object | -    | Defines log fields for [Custom Logging](LOGS.md#custom-logging). | &#9888; Inherited by nested blocks. | - |
+|:-----------|:------------------------------|:---------|:--------------------------|
+| `spa`      | [Server Block](#server-block) | Optional | [CORS Block](#cors-block) |
+
+| Attribute(s)             | Type           | Default | Description                                                                | Characteristic(s)                   | Example                                  |
+|:-------------------------|:---------------|:--------|:---------------------------------------------------------------------------|:------------------------------------|:-----------------------------------------|
+| `base_path`              | string         | -       | Configures the path prefix for all requests.                               | -                                   | `base_path = "/assets"`                  |
+| `bootstrap_file`         | string         | -       | Location of the bootstrap file.                                            | &#9888; required                    | `bootstrap_file = "./htdocs/index.html"` |
+| `paths`                  | tuple (string) | -       | List of SPA paths that need the bootstrap file.                            | &#9888; required                    | `paths = ["/app/**"]`                    |
+| `access_control`         | tuple (string) | -       | Sets predefined [Access Control](#access-control) for `spa` block context. | -                                   | `access_control = ["foo"]`               |
+| `disable_access_control` | tuple (string) | -       | Disables access controls by name.                                          | -                                   | `disable_access_control = ["foo"]`       |
+| `custom_log_fields`      | object         | -       | Defines log fields for [Custom Logging](LOGS.md#custom-logging).           | &#9888; Inherited by nested blocks. | -                                        |
 
 ### API Block
 
diff --git a/server/mux.go b/server/mux.go
index a7ca0ea6..be4ffbe0 100644
--- a/server/mux.go
+++ b/server/mux.go
@@ -180,8 +180,17 @@ func (m *Mux) getAPIErrorTemplate(reqPath string) (*errors.Template, *config.API
 			continue
 		}
 
-		if isAPIError(path, m.opts.ServerOptions.FilesBasePath, m.opts.ServerOptions.SPABasePath, reqPath) {
-			return m.opts.ServerOptions.APIErrTpls[api], api
+		if len(m.opts.ServerOptions.SPABasePaths) == 0 {
+			if isAPIError(path, m.opts.ServerOptions.FilesBasePath, "", reqPath) {
+				return m.opts.ServerOptions.APIErrTpls[api], api
+			}
+			continue
+		}
+
+		for _, spaPath := range m.opts.ServerOptions.SPABasePaths {
+			if isAPIError(path, m.opts.ServerOptions.FilesBasePath, spaPath, reqPath) {
+				return m.opts.ServerOptions.APIErrTpls[api], api
+			}
 		}
 	}
 


Test patch:
diff --git a/config/runtime/server/options_test.go b/config/runtime/server/options_test.go
index 650b3207..205caca6 100644
--- a/config/runtime/server/options_test.go
+++ b/config/runtime/server/options_test.go
@@ -22,7 +22,6 @@ func TestServer_NewServerOptions_NoConfig(t *testing.T) {
 		ServerErrTpl:  errors.DefaultHTML,
 		APIBasePaths:  map[*config.API]string(nil),
 		FilesBasePath: "",
-		SPABasePath:   "",
 		SrvBasePath:   "",
 		ServerName:    "",
 	}
@@ -45,7 +44,6 @@ func TestServer_NewServerOptions_EmptyConfig(t *testing.T) {
 		ServerErrTpl:  errors.DefaultHTML,
 		APIBasePaths:  map[*config.API]string(nil),
 		FilesBasePath: "",
-		SPABasePath:   "",
 		SrvBasePath:   "/",
 		ServerName:    "",
 	}
@@ -77,9 +75,9 @@ func TestServer_NewServerOptions_ConfigWithPaths(t *testing.T) {
 		Files: &config.Files{
 			BasePath: "/files",
 		},
-		Spa: &config.Spa{
+		SPAs: []*config.Spa{{
 			BasePath: "/spa",
-		},
+		}},
 		APIs: config.APIs{
 			api1, api2,
 		},
@@ -96,7 +94,7 @@ func TestServer_NewServerOptions_ConfigWithPaths(t *testing.T) {
 		ServerErrTpl:  errors.DefaultHTML,
 		APIBasePaths:  abps,
 		FilesBasePath: "/server/files",
-		SPABasePath:   "/server/spa",
+		SPABasePaths:  []string{"/server/spa"},
 		SrvBasePath:   "/server",
 		ServerName:    "ServerName",
 	}
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index 3215f1e6..d80b5252 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -92,7 +92,14 @@ func newCouper(file string, helper *test.Helper) (func(), *logrustest.Hook) {
 }
 
 func newCouperMultiFiles(file, dir string, helper *test.Helper) (func(), *logrustest.Hook) {
-	couperConfig, err := configload.LoadFiles(filepath.Join(testWorkingDir, file), dir)
+	filePath, dirPath := file, dir
+	if filePath != "" {
+		filePath = filepath.Join(testWorkingDir, file)
+	}
+	if dirPath != "" {
+		dirPath = filepath.Join(testWorkingDir, dir)
+	}
+	couperConfig, err := configload.LoadFiles(filePath, dirPath)
 	helper.Must(err)
 
 	return newCouperWithConfig(couperConfig, helper)
@@ -154,7 +161,7 @@ func newCouperWithConfig(couperConfig *config.Couper, helper *test.Helper) (func
 	defer func() { command.RunCmdTestCallback = nil }()
 
 	go func() {
-		if err := command.NewRun(ctx).Execute([]string{couperConfig.Filename}, couperConfig, log.WithContext(ctx)); err != nil {
+		if err := command.NewRun(ctx).Execute(nil, couperConfig, log.WithContext(ctx)); err != nil {
 			command.RunCmdTestCallback()
 			shutdownFn()
 			if lerr, ok := err.(*errors.Error); ok {
diff --git a/server/http_test.go b/server/http_test.go
index becae02f..3d9d8d6a 100644
--- a/server/http_test.go
+++ b/server/http_test.go
@@ -77,7 +77,7 @@ func TestHTTPServer_ServeHTTP_Files(t *testing.T) {
 	srvConf, err := runtime.NewServerConfiguration(conf, logger, tmpMemStore)
 	helper.Must(err)
 
-	spaContent, err := os.ReadFile(conf.Servers[0].Spa.BootstrapFile)
+	spaContent, err := os.ReadFile(conf.Servers[0].SPAs[0].BootstrapFile)
 	helper.Must(err)
 
 	port := runtime.Port(conf.Settings.DefaultPort)
@@ -167,7 +167,7 @@ func TestHTTPServer_ServeHTTP_Files2(t *testing.T) {
 	helper.Must(err)
 
 	error404Content := []byte("<html><body><h1>route not found error: My custom error template</h1></body></html>")
-	spaContent, err := os.ReadFile(conf.Servers[0].Spa.BootstrapFile)
+	spaContent, err := os.ReadFile(conf.Servers[0].SPAs[0].BootstrapFile)
 	helper.Must(err)
 
 	tmpStoreCh := make(chan struct{})
@@ -643,7 +643,7 @@ func TestHTTPServer_RequestID(t *testing.T) {
 			}
 
 			if tc.status == http.StatusOK {
-				if lastLog.Message != "" {
+				if lastLog != nil && lastLog.Message != "" {
 					subT.Errorf("Unexpected log message given: %s", lastLog.Message)
 				}
 
diff --git a/server/multi_files_test.go b/server/multi_files_test.go
index 771a81b8..47829402 100644
--- a/server/multi_files_test.go
+++ b/server/multi_files_test.go
@@ -218,7 +218,12 @@ func Test_MultipleLabels(t *testing.T) {
 			"testdata/multi/errors/couper_02.hcl:2,11-14: cannot match argument name from: Only 1 labels (name) are expected for api blocks.",
 		},
 		{
-			"api and server without labels",
+			"spa with multiple labels",
+			"testdata/multi/errors/couper_04.hcl",
+			"testdata/multi/errors/couper_04.hcl:2,11-14: cannot match argument name from: Only 1 labels (name) are expected for spa blocks.",
+		},
+		{
+			"api, spa and server without labels",
 			"testdata/multi/errors/couper_03.hcl",
 			"",
 		},
@@ -233,3 +238,46 @@ func Test_MultipleLabels(t *testing.T) {
 		})
 	}
 }
+
+func TestMultiFiles_SPAs(t *testing.T) {
+	helper := test.New(t)
+	client := newClient()
+
+	shutdown, _ := newCouperMultiFiles("", "testdata/multi/server/spa.d", helper)
+	defer shutdown()
+
+	type testcase struct {
+		path        string
+		expStatus   int
+		expContains string
+	}
+
+	for _, tc := range []testcase{
+		{"/", http.StatusNotFound, ""},
+		{"/app", http.StatusOK, "02_spa.hcl"},
+		{"/another", http.StatusOK, "03_spa.hcl"},
+	} {
+		t.Run(tc.path, func(st *testing.T) {
+			h := test.New(st)
+			req, err := http.NewRequest(http.MethodGet, "http://couper.local:8080"+tc.path, nil)
+			h.Must(err)
+
+			res, err := client.Do(req)
+			h.Must(err)
+
+			if res.StatusCode != tc.expStatus {
+				st.Errorf("want status: %d, got: %d", tc.expStatus, res.StatusCode)
+			}
+
+			b, err := io.ReadAll(res.Body)
+			h.Must(err)
+
+			h.Must(res.Body.Close())
+
+			if tc.expContains != "" && !strings.Contains(string(b), tc.expContains) {
+				st.Errorf("want %q, got:\n%q", tc.expContains, string(b))
+			}
+		})
+	}
+
+}
diff --git a/server/testdata/multi/errors/couper_03.hcl b/server/testdata/multi/errors/couper_03.hcl
index 4b6ed759..fbdc4532 100644
--- a/server/testdata/multi/errors/couper_03.hcl
+++ b/server/testdata/multi/errors/couper_03.hcl
@@ -1,4 +1,8 @@
 server {
   api {
   }
+  spa {
+    bootstrap_file = "./couper_03.hcl"
+    paths = ["/**"]
+  }
 }
diff --git a/server/testdata/multi/errors/couper_04.hcl b/server/testdata/multi/errors/couper_04.hcl
new file mode 100644
index 00000000..3e51cfbf
--- /dev/null
+++ b/server/testdata/multi/errors/couper_04.hcl
@@ -0,0 +1,6 @@
+server {
+  spa "a" "b" {
+    bootstrap_file = "./couper_04.hcl"
+    paths = ["/**"]
+  }
+}
diff --git a/server/testdata/multi/server/spa.d/01_spa.hcl b/server/testdata/multi/server/spa.d/01_spa.hcl
new file mode 100644
index 00000000..9e228c39
--- /dev/null
+++ b/server/testdata/multi/server/spa.d/01_spa.hcl
@@ -0,0 +1,5 @@
+server {
+  spa {
+    bootstrap_file = "./01_spa.hcl"
+  }
+}
diff --git a/server/testdata/multi/server/spa.d/02_spa.hcl b/server/testdata/multi/server/spa.d/02_spa.hcl
new file mode 100644
index 00000000..40079bd3
--- /dev/null
+++ b/server/testdata/multi/server/spa.d/02_spa.hcl
@@ -0,0 +1,11 @@
+server {
+  spa {
+    bootstrap_file = "./02_spa.hcl"
+    paths = ["/app"]
+  }
+
+  spa "another" {
+    bootstrap_file = "./02_spa.hcl"
+    paths = ["/another"]
+  }
+}
diff --git a/server/testdata/multi/server/spa.d/03_spa.hcl b/server/testdata/multi/server/spa.d/03_spa.hcl
new file mode 100644
index 00000000..4cdaa8db
--- /dev/null
+++ b/server/testdata/multi/server/spa.d/03_spa.hcl
@@ -0,0 +1,6 @@
+server {
+  spa "another" {
+    bootstrap_file = "./03_spa.hcl"
+    paths = ["/another"]
+  }
+}