micronaut-guides
micronaut-guides copied to clipboard
Native serving of SPAs
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
- Would you like me to create PR?
- Is naming it
spa
good? Maybe something likefallback-to-index
?
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 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 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 Transferred this to the examples project. I think this would make a good example app
@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 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 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);
}
}
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());
}
}
}
I think you can skip the content type check with @Produces(text/html)
on the method
@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 You wouldn't have to because they wouldn't 404
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();
}