Repository: candlecorp/wick (language: rust)

Issue:
http static router needs default path for SPA
**Is your feature request related to a problem? Please describe.**
To serve a single page app, the static router needs to redirect all traffic for pages not found to a known path.

**Propose a solution**
Need the equivalent to this in NGINX:
```
location / {
    try_files $uri $uri/ /index.html;
}
```

**Describe alternatives you've considered**
None

**Additional context**
None


Discussion / hints:

Fix patch:
diff --git a/crates/wick/wick-config/definitions/v1/manifest.apex b/crates/wick/wick-config/definitions/v1/manifest.apex
index b11ebe99d..b9a27b253 100644
--- a/crates/wick/wick-config/definitions/v1/manifest.apex
+++ b/crates/wick/wick-config/definitions/v1/manifest.apex
@@ -217,6 +217,9 @@ type StaticRouter @tagged("wick/router/static@v1") {
 
   "The volume to serve static files from."
   volume: string @required
+
+  "Fallback path (relative to volume `resource`) for files to serve in case of a 404. Useful for SPA's. if volume resource is: /www and fallback: index.html, then a 404 will serve /www/index.html"
+  fallback: string?
 }
 
 "A RawHttpRouter delegates raw requests and bodies to operations based on the request path."
diff --git a/crates/wick/wick-config/docs/v1.html b/crates/wick/wick-config/docs/v1.html
index d912bf398..e53ac1acc 100644
--- a/crates/wick/wick-config/docs/v1.html
+++ b/crates/wick/wick-config/docs/v1.html
@@ -975,6 +975,20 @@ <h3 class=field-name>
 <li><span class="type field-note">Type: <span class=field-type>string</span></span></li>
 <li><span class="annotation field-note">Required</span></li>
 
+</ul>
+</div><div class=field>
+
+<h3 class=field-name>
+  <a name="fallback">fallback</a>
+</h3>
+
+<span class="description field-description">Fallback path (relative to `path`) for files to serve in case of a 404. Useful for SPA's. if path: /www and fallback: index.html, then a 404 will serve /www/index.html</span>
+
+
+<ul class="field-notes">
+<li><span class="type field-note">Type: <span class=field-type>string<span class=optional>(Optional)</span></span></span></li>
+
+
 </ul>
 </div></div></div><div class="definition">
 <h2 class="definition-name type-name">
diff --git a/crates/wick/wick-config/json-schema/manifest.json b/crates/wick/wick-config/json-schema/manifest.json
index 0eb82bb56..eaff6e65f 100644
--- a/crates/wick/wick-config/json-schema/manifest.json
+++ b/crates/wick/wick-config/json-schema/manifest.json
@@ -968,6 +968,11 @@
           "volume": {
             "description": "The volume to serve static files from.",
             "type": "string"
+          },
+          "fallback": {
+            "description": "Fallback path (relative to &#x60;path&#x60;) for files to serve in case of a 404. Useful for SPA&#x27;s. if path: /www and fallback: index.html, then a 404 will serve /www/index.html",
+            "required": false,
+            "type": "string"
           }
         },
         "required": [
diff --git a/crates/wick/wick-config/json-schema/v1/manifest.json b/crates/wick/wick-config/json-schema/v1/manifest.json
index 8e62264f4..9a4f8d101 100644
--- a/crates/wick/wick-config/json-schema/v1/manifest.json
+++ b/crates/wick/wick-config/json-schema/v1/manifest.json
@@ -610,6 +610,12 @@
         "volume": {
           "description": "The volume to serve static files from.",
 
+          "type": "string"
+        },
+        "fallback": {
+          "description": "Fallback path (relative to &#x60;path&#x60;) for files to serve in case of a 404. Useful for SPA&#x27;s. if path: /www and fallback: index.html, then a 404 will serve /www/index.html",
+          "required": false,
+
           "type": "string"
         }
       },
diff --git a/crates/wick/wick-config/src/config/app_config/triggers/http/static_router.rs b/crates/wick/wick-config/src/config/app_config/triggers/http/static_router.rs
index cf6448e5d..444650dfd 100644
--- a/crates/wick/wick-config/src/config/app_config/triggers/http/static_router.rs
+++ b/crates/wick/wick-config/src/config/app_config/triggers/http/static_router.rs
@@ -9,4 +9,6 @@ pub struct StaticRouterConfig {
   pub(crate) path: String,
   #[asset(skip)]
   pub(crate) volume: String,
+  #[asset(skip)]
+  pub(crate) fallback: Option<String>,
 }
diff --git a/crates/wick/wick-config/src/v1.rs b/crates/wick/wick-config/src/v1.rs
index f2d52cd7b..7650ecd1a 100644
--- a/crates/wick/wick-config/src/v1.rs
+++ b/crates/wick/wick-config/src/v1.rs
@@ -484,6 +484,11 @@ pub(crate) struct StaticRouter {
 
   #[serde(deserialize_with = "crate::helpers::with_expand_envs_string")]
   pub(crate) volume: String,
+  /// Fallback path (relative to &#x60;path&#x60;) for files to serve in case of a 404. Useful for SPA&#x27;s. if path: /www and fallback: index.html, then a 404 will serve /www/index.html
+
+  #[serde(default)]
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub(crate) fallback: Option<String>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
diff --git a/crates/wick/wick-config/src/v1/conversions.rs b/crates/wick/wick-config/src/v1/conversions.rs
index 218636f5e..11e0e077a 100644
--- a/crates/wick/wick-config/src/v1/conversions.rs
+++ b/crates/wick/wick-config/src/v1/conversions.rs
@@ -494,6 +494,7 @@ impl TryFrom<StaticRouterConfig> for v1::StaticRouter {
     Ok(Self {
       path: value.path,
       volume: value.volume,
+      fallback: value.fallback,
     })
   }
 }
@@ -1225,6 +1226,7 @@ impl TryFrom<v1::HttpRouter> for HttpRouterConfig {
       v1::HttpRouter::StaticRouter(v) => Self::StaticRouter(StaticRouterConfig {
         path: v.path,
         volume: v.volume,
+        fallback: v.fallback,
       }),
       v1::HttpRouter::ProxyRouter(v) => Self::ProxyRouter(ProxyRouterConfig {
         path: v.path,
diff --git a/crates/wick/wick-runtime/src/triggers/http.rs b/crates/wick/wick-runtime/src/triggers/http.rs
index b341ccff5..001ff580b 100644
--- a/crates/wick/wick-runtime/src/triggers/http.rs
+++ b/crates/wick/wick-runtime/src/triggers/http.rs
@@ -265,7 +265,10 @@ fn register_static_router(
       ))
     }
   };
-  let router = StaticRouter::new(volume, Some(router_config.path().to_owned()));
+
+  let fallback = router_config.fallback().cloned();
+
+  let router = StaticRouter::new(volume, Some(router_config.path().to_owned()), fallback);
   let router_component = config::ComponentDefinition::Native(config::components::NativeComponent {});
   let router_binding = config::ImportBinding::component(index_to_router_id(index), router_component);
   Ok((
diff --git a/crates/wick/wick-runtime/src/triggers/http/static_router.rs b/crates/wick/wick-runtime/src/triggers/http/static_router.rs
index 34caee002..5bbb8e3e9 100644
--- a/crates/wick/wick-runtime/src/triggers/http/static_router.rs
+++ b/crates/wick/wick-runtime/src/triggers/http/static_router.rs
@@ -20,9 +20,9 @@ pub(super) struct StaticRouter {
 }
 
 impl StaticRouter {
-  pub(super) fn new(root: PathBuf, strip: Option<String>) -> Self {
+  pub(super) fn new(root: PathBuf, strip: Option<String>, fallback: Option<String>) -> Self {
     debug!(directory = %root.display(), "{}: serving", ID);
-    let handler = Static::new(root, strip);
+    let handler = Static::new(root, strip, fallback);
     Self { handler }
   }
 }
@@ -46,16 +46,30 @@ impl RawRouter for StaticRouter {
   }
 }
 
+fn create_response<B>(request: &Request<B>, result: ResolveResult) -> Result<Response<Body>, std::io::Error>
+where
+  B: Send + Sync + 'static,
+{
+  #[allow(clippy::expect_used)]
+  Ok(
+    ResponseBuilder::new()
+      .request(request)
+      .build(result)
+      .expect("unable to build response"),
+  )
+}
+
 #[derive(Clone)]
 struct Static {
   root: PathBuf,
   strip: Option<String>,
+  fallback: Option<String>,
 }
 
 impl Static {
-  fn new(root: impl Into<PathBuf>, strip: Option<String>) -> Self {
+  fn new(root: impl Into<PathBuf>, strip: Option<String>, fallback: Option<String>) -> Self {
     let root = root.into();
-    Static { root, strip }
+    Static { root, strip, fallback }
   }
 
   /// Serve a request.
@@ -63,18 +77,12 @@ impl Static {
   where
     B: Send + Sync + 'static,
   {
-    let Self { root, strip } = self;
+    let Self { root, strip, fallback } = self;
     // Handle only `GET`/`HEAD` and absolute paths.
     match *request.method() {
       Method::HEAD | Method::GET => {}
       _ => {
-        #[allow(clippy::expect_used)]
-        return Ok(
-          ResponseBuilder::new()
-            .request(&request)
-            .build(ResolveResult::MethodNotMatched)
-            .expect("unable to build response"),
-        );
+        return create_response(&request, ResolveResult::MethodNotMatched);
       }
     }
 
@@ -83,13 +91,19 @@ impl Static {
       |path| request.uri().path().trim_start_matches(&path),
     );
 
-    resolve_path(root, path).await.map(|result| {
-      #[allow(clippy::expect_used)]
-      ResponseBuilder::new()
-        .request(&request)
-        .build(result)
-        .expect("unable to build response")
-    })
+    let result = resolve_path(root.clone(), path).await;
+
+    match result {
+      Ok(ResolveResult::Found(_, _, _)) => create_response(&request, result?),
+      _ => {
+        if let Some(fb) = &fallback {
+          let fallback_result = resolve_path(root.clone(), fb).await;
+          create_response(&request, fallback_result?)
+        } else {
+          create_response(&request, result?)
+        }
+      }
+    }
   }
 }
 


Test patch: