micronaut-guides icon indicating copy to clipboard operation
micronaut-guides copied to clipboard

Native serving of SPAs

Open morki opened this issue 4 years ago • 12 comments

I think we could make serving SPAs simpler as it is common pattern to use Micronaut for API and bundle SPA frontend application as static resource.

My proposed solution is to extend StaticResourceConfiguration class with something like

private boolean spa = false;

and extend StaticResourceResolver class with something like

if (config.isSpa()) {
    for (ResourceLoader loader : loaders) {
        Optional<URL> resource = loader.getResource(INDEX_PAGE);
        if (resource.isPresent()) {
            return resource;
        }
    }
}

under the end of loop finding available resource (for (ResourceLoader loader : loaders)) when no resource is found.

Usage in application.yml would be:

micronaut:
  router:
    static-resources:
      angular-frontend:
        paths: classpath:public/frontend
        mapping: /frontend/**
        spa: true
  1. Would you like me to create PR?
  2. Is naming it spa good? Maybe something like fallback-to-index?

morki avatar Jun 14 '20 18:06 morki

Referencing some SO questions:

  • https://stackoverflow.com/questions/60067639/resolving-index-html-with-mapping-mentioning-path-params-in-url
  • https://stackoverflow.com/questions/62347465/micronaut-spa-contoller
  • https://stackoverflow.com/questions/61898410/micronaut-and-angular-8

morki avatar Jun 14 '20 18:06 morki

@morki I don't think any code in Micronaut should be modified for this purpose. If I understand you right, you want the index.html served for what would normally be a 404 response. This can be achieved with something like this:

import io.micronaut.core.io.ResourceResolver;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.server.types.files.StreamedFile;

import javax.inject.Inject;
import java.util.Optional;

@Controller
public class ClientForwardController {

    @Inject
    ResourceResolver resourceResolver;

    @Get(value = "/{path:[^\\.]*}", produces = MediaType.TEXT_HTML)
    public Optional<StreamedFile> forward(String path) {
        return resourceResolver.getResource("classpath:static/index.html")
            .map(StreamedFile::new);
    }
}

jameskleeh avatar Jun 15 '20 23:06 jameskleeh

@jameskleeh Hi, thank you for your response. Yes I know there is this solution, I just thought that this is so common use case nowadays, that it should be directly in Micronaut. If you as a maintainer think that it should not, I am ok with that and feel free to close this :)

morki avatar Jun 16 '20 06:06 morki

@morki Transferred this to the examples project. I think this would make a good example app

jameskleeh avatar Jun 18 '20 15:06 jameskleeh

@jameskleeh I tried implementing your example and i'm getting an error:

BUG! exception in phase 'canonicalization' in source unit '/controller/ClientForwardController.groovy' Cannot invoke method isValidated() on null object

is there something that needs to be changed to be compatible micronaut 3?

warnerandy avatar Apr 16 '22 03:04 warnerandy

@warnerandy Not sure but I would do it differently now with an @Error route for status = HttpStatus.NOT_FOUND. Seems like you encountered a bug. Please file an issue.

@sdelamo I think this would be a good idea for a guide. This has come up many times in gitter, so, etc..

jameskleeh avatar Apr 16 '22 03:04 jameskleeh

@jameskleeh i'm trying to implement this, but still having trouble. the file i'm trying to serve is at /src/assets/app/index.html. i've tried all of the different permutations of the path to get this to work but i get a null response in the body or when i log it it comes back optional.empty.

any help would be awesome!

the yml config is:

router:
    static-resources:
      default:
        enabled: true   
        mapping: "/**"  
        paths:
          - classpath: public 

the controller looks like this:

import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Error;
import io.micronaut.http.hateoas.JsonError;
import io.micronaut.http.hateoas.Link;
import io.micronaut.core.io.ResourceResolver;
import io.micronaut.http.server.types.files.StreamedFile;

import jakarta.inject.Inject;
import java.util.Collections;

@Controller("/notfound") 
public class SpaCatcherController {

    @Inject
    ResourceResolver resourceResolver;

    @Error(status = HttpStatus.NOT_FOUND, global = true)  
    public HttpResponse forward(HttpRequest request) {

        if (request.getHeaders()
                .accept()
                .stream()
                .anyMatch(mediaType -> mediaType.getName().contains(MediaType.TEXT_HTML))) { 
            return HttpResponse(resourceResolver.getResource("classpath:static/index.html")
            .map(StreamedFile::new));
        }

        JsonError error = new JsonError("Page Not Found")
                .link(Link.SELF, Link.of(request.getUri()));

        return HttpResponse.<JsonError>notFound()
                .body(error); 
    }
}

warnerandy avatar Apr 18 '22 04:04 warnerandy

I eventually got it to work:


@Controller("/notfound") 
public class SpaCatcherController {

    @Inject
    ResourceResolver resourceResolver;

    @Produces(MediaType.TEXT_HTML)
    @Error(status = HttpStatus.NOT_FOUND, global = true)  
    public HttpResponse forward(HttpRequest request) {
        if (request.getHeaders()
                .accept()
                .stream()
                .anyMatch(mediaType -> mediaType.getName().contains(MediaType.TEXT_HTML))) {
            return HttpResponse.ok(resourceResolver.getResource("classpath:public/index.html")
            .map(StreamedFile::new).get().getInputStream());
        }
    }
}

warnerandy avatar Apr 19 '22 05:04 warnerandy

I think you can skip the content type check with @Produces(text/html) on the method

jameskleeh avatar Apr 19 '22 13:04 jameskleeh

@jameskleeh I've made an example app for using micronaut to host a SPA here: https://github.com/warnerandy/fluffy-octo-robot Probably the thing I think needs to be improved is the error method needs to be able to filter out css and js (and image) requests. I tried to figure that out but wasn't successful. If you know how to make that enhancement I'd be happy to add that in.

Hope this helps!

warnerandy avatar Apr 21 '22 03:04 warnerandy

@warnerandy You wouldn't have to because they wouldn't 404

jameskleeh avatar Apr 21 '22 03:04 jameskleeh

I like the @Error solution for SPA fallback. However, the error handler seems to miss one feature. Let's say our SPA is being served under /* (out of /public) and our JSON-based api is served under the routes /api/*. It looks like the error handler is still returning index.html for my /api/* routes where I would want my JS clients to see a 404. Am I missing something?

    @Get("/api/notfound")
    @Produces(MediaType.APPLICATION_JSON)
    public HttpResponse notFoundTest() {
        return HttpResponse.notFound();
    }

dcrall avatar Feb 16 '23 20:02 dcrall