Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Document best practices for serving frontend app with frontend routing #372

Closed
zachsiegel-capsida opened this issue Mar 1, 2022 · 11 comments

Comments

@zachsiegel-capsida
Copy link

zachsiegel-capsida commented Mar 1, 2022

Python Version

3.9.5

Django Version

4.0.2

Package Version

6.0.0

Description

There are many SO posts in which folks ask "How do I configure Django to serve my index.html when I visit routes handled by a frontend router?" (such as react-router, react-router-dom, or Angular's router). For example, when I visit myDjangoURL.com/app/frontendRoute, how do I serve a file in app/build/index.html that loads a React/Angular app with a browser-based router that handles the /frontendRoute route?

This, this, this, and this all have answers that suggest configuring Django wildcard URLs/paths and returning something like django.shortcuts.render(request,'index.html') or django.views.generic.TemplateView. Of course django.contrib.staticfiles.views.serve is not available in production (when django.conf.settings.DEBUG is False), but everyone asking this question really wants a function like serve, and not a function like render (maybe the implementation of these functions is similar enough that it doesn't matter, but the function names suggest something is wrong). "Serving" one single static file as a rendered template is probably okay performance-wise if Whitenoise is managing and serving all the built dependencies linked to in index.html.

I think it creates a few problems, though:

  • Users might have to add their static build folder to their django.conf.settings.TEMPLATES, where it doesn't belong, possibly causing template name conflicts (because this build folder may not be managed in the same way as a Python app)
  • Using render might cause weird behavior considering Javascript frameworks might use some of the same syntax as Django's/Jinja's templating language (e.g. {{variablename}} might cause a templating/rendering error).
  • Obviously the index.html file would not be a version-hashed copy in the static folder, which could fail to update in caches somewhere downstream, defeating the purpose of Whitenoise's versioning efforts for this one file.

Whitenoise already supports some "interface sugar" by serving an "index" file (WHITENOISE_INDEX_FILE = True in Django) when the static file root is served. Considering the proliferation of frontend routing (evidenced by the StackOverflow posts I linked to above), I think you should expose a frontend routing interface to be used in Django's settings. I think this interface could look something like the following:

WHITENOISE_FRONTEND_ROUTES_BASE = '/app/' # defaults to STATIC_URL

that automatically adds a catchall route that serves index.html for any route starting with '/app/

or

WHITENOISE_FRONTEND_ROUTES_BASES = [('/app/', 'app/index.html'), ('/webapp/' , 'webapp/index.html')] # for multiple frontends served by one Django/Whitenoise app

The interface would probably need some way to both include and exclude certain routes/filepaths, and Whitenoise might want to add a quick check to ensure none of these routes conflict with existing static content routes (and if so, maybe it automatically changes file and directory names in the STATIC_ROOT folder so that static content can still be served without this issue).

I don't 100% understand how Whitenoise does its thing, but it makes the case for replacing Nginx unless you know how to configure everything according the best practices. This reverse-proxying configuration seems like a very easy concern compared to everything else Whitenoise does, and I think it seems wrong for users (like me) to set up Nginx for the sole purpose of it having config options to allow frontend routing.

If this is already possible in a first-class supported way using Django/Whitenoise, please close the issue but please explain what I'm missing (and what the others on the SO posts are missing). Please note that if this is supported, I don't think it's clear from the documentation.

@merwok
Copy link

merwok commented Mar 1, 2022

Honestly, the best practice there would be to not have django serve static files.
A full-featured HTTP server like nginx can do it much better, or even a specialized static hosting with separate domains or sub-domains for app and backend.

@zachsiegel-capsida
Copy link
Author

Another option is to expose a serve function to be used in views that is suitable for production, unlike django.contrib.staticfiles.views.serve. Such as django.contrib.whitenoise.views.serve. Then users can indeed have a catchall route and serve from their views.

My impression, though, is that you wanted Whitenoise to be used as middleware only and not appear anywhere in an app except for settings.py.

@zachsiegel-capsida
Copy link
Author

zachsiegel-capsida commented Mar 1, 2022

@merwok thank you for the response! Did I misinterpret the Whitenoise documentation? It seemed to me to imply "sometimes Whitenoise is actually better than setting up Nginx yourself, unless you are an expert". Based on the SO posts I linked to, there are many people who

  • are not experts (i.e. they don't already have this set up in Nginx)
  • probably would prefer an easier-to-maintain stack (Nginx layer is more than they care to set up)
  • want to use Whitenoise in a plug-and-play manner
  • want to enable frontend routing

(Note that all of those describe me.)

@zachsiegel-capsida
Copy link
Author

zachsiegel-capsida commented Mar 1, 2022

Can this be achieved by directly using Whitenoise with a Django wsgi application instead of as middleware, as in this SO answer?

from my_django_project import MyWSGIApp

application = MyWSGIApp()
application = WhiteNoise(application, root='/path/to/static/files')
application.add_files('/path/to/more/static/files', prefix='more-files/',root='something')

The WhiteNoise class has both a prefix and a root parameter that might enable this already. Either way, I think it should be possible to do a PR that exclusively edits that class to enable frontend routing via the non-middleware interface in this comment.

@merwok
Copy link

merwok commented Mar 1, 2022

Did I misinterpret the Whitenoise documentation? It seemed to me to imply "sometimes Whitenoise is actually better than setting up Nginx yourself, unless you are an expert".

That is what the doc says! And I (just a user of whitenoise note, not a maintainer) do agree with it for django static files.
But I wouldn’t try to serve a single-page app with a catch-all route using whitenoise.

@zachsiegel-capsida
Copy link
Author

zachsiegel-capsida commented Mar 1, 2022

I think I came up with a decent solution, leveraging the way WhiteNoise middleware manages its own configuration.

views.py:

from django.conf import settings
from whitenoise.middleware import WhiteNoiseMiddleware
wn = WhiteNoiseMiddleware()

def frontend(request, route):
	output = wn.process_request(request)
	if output is not None:
		return output
	if wn.autorefresh:
		static_file = wn.find_file(settings.STATIC_URL)
	else:
		static_file = wn.files.get(settings.STATIC_URL)
	return wn.serve(static_file, request)

urls.py:

from . import views
from django.conf import settings
urlpatterns = [
    ...,
    path(f"{settings.STATIC_URL_BASE}<path:route>", views.frontend, name="frontend")
]
  • Note that this route captures a "path" keyword parameter called route that the frontend function ignores because the WhiteNoiseMiddleware object is already getting it from request.path_info in its WhiteNoiseMiddleware.process_request.

settings.py

WHITENOISE_INDEX_FILE = True
  • The last part is necessary just so that when you visit settings.STATIC_URL, the index.html file is automatically served if there is one. That's already part of Whitenoise.

Please comment on whether this is reasonable for production! Thank you.

@zachsiegel-capsida
Copy link
Author

When I say "reasonable in production", please know that I understand you ideally don't want a Python if statement in between users and their static content, but since Whitenoise already does this, I am really just asking if this introduces additional issues beyond what is unavoidable with Whitenoise. As I mentioned above, "just use Nginx instead of Whitenoise" ignores the versioning that Whitenoise automatically manages. I basically want Whitenoise to be exactly what it already is but to allow frontend routing in SPAs. I think this achieves that.

Really hoping a Whitenoise developer can comment. Thank you.

@zachsiegel-capsida
Copy link
Author

zachsiegel-capsida commented Mar 1, 2022

If anyone comes across this, you can create a custom middleware that directs to your SPA's routes in your app that extends WhiteNoise as so:

yourapp/middleware/WhiteNoiseSPAMiddleware

from whitenoise.middleware import WhiteNoiseMiddleware
from django.http import HttpResponseNotFound
from django.conf import settings


class WhiteNoiseSPAMiddleware(WhiteNoiseMiddleware):
	def __call__(self, request):
		response = self.process_request(request)
		if response is None:
			response = self.get_response(request)
		if settings.WHITENOISE_CUSTOM_FRONTEND_ROUTING and (
			isinstance(response, HttpResponseNotFound) or response.status_code == 404
			) and request.path_info.startswith(settings.STATIC_URL):

			# serve index, route from frontend
			if self.autorefresh:
				static_file = self.find_file(settings.STATIC_URL)
			else:
				static_file = self.files.get(settings.STATIC_URL)
			return self.serve(static_file, request)
		return response

Then, in your settings.py file, replace the Whitenoise middleware with this, and add the WHITENOISE_CUSTOM_FRONTEND_ROUTING setting I created. This could easily be configured differently, but this configuration makes sense for the routes in my SPA.

MIDDLEWARE = [
    ...,
    # "whitenoise.middleware.WhiteNoiseMiddleware", # remove - replace with extension
    'yourapp.middleware.WhiteNoiseSPAMiddleware',
    ...,
    ]
...
WHITENOISE_CUSTOM_FRONTEND_ROUTING = True

With this middleware, you don't have to add ANYTHING to your urls.py to make sure frontend routing is handled by your frontend. This basically does the following:

  1. Checks if the route is handled by Django. If so, return the response.
  2. Checks if the route corresponds to a static file (using exactly the method WhiteNoise uses). If so, return the static file.
  3. If neither is true, but the route starts with your "frontend" prefix (like /app or /static), then just serve the frontend (e.g. /static/index.html), which presumably handles the routing.

The obvious drawback to this: if your frontend refers to a ./something.js file, it will receive your index.html file, which will definitely not work. This is bad, but it's kind of bad anyway if your frontend requests a file that doesn't exist.

I suppose you could prevent Django from serving your index.html file if the requested route ends in something like /*.html or '/*.js'. That would probably make sense. Maybe don't allow this to happen if the last sub-route (*/.../sub_route) contains a period at all, because that would probably indicate it is requesting a file. Seems to be getting jankier the more I futz with it but it does the job for me for now.

@zachsiegel-capsida
Copy link
Author

If you want to use the idea in my last comment, use this:

from whitenoise.middleware import WhiteNoiseMiddleware
from django.http import HttpResponseNotFound
from django.conf import settings


class WhiteNoiseSPAMiddleware(WhiteNoiseMiddleware):
	def __call__(self, request):
		response = self.process_request(request)
		if response is None:
			response = self.get_response(request)
		if settings.WHITENOISE_CUSTOM_FRONTEND_ROUTING and (
			isinstance(response, HttpResponseNotFound) or response.status_code == 404
			) and request.path_info.startswith(
			settings.STATIC_URL
			) and ('.' not in request.path_info.rsplit("/", 1)[-1]):

			# serve index, route from frontend
			if self.autorefresh:
				static_file = self.find_file(settings.STATIC_URL)
			else:
				static_file = self.files.get(settings.STATIC_URL)
			return self.serve(static_file, request)
		return response

This excludes routes like .../static/something.js so that if your frontend requests a dependency that does not exist (like <script src="./something.js"/>, with a "." in the filename), instead of accidentally serving index.html, it just does whatever a normal Django+Whitenoise app does (serve 404 and let the Javascript app respond appropriately)

@zachsiegel-capsida
Copy link
Author

Honestly I think this functionality makes a great case for Whitenoise as a project. I would much rather implement this in Python than figure out the Nginx config mini-language. I know Nginx is used all over the world and it's probably very easy to figure this out from documentation, but I don't see why we should have to.

@adamchainz
Copy link
Collaborator

If you want to serve the same HTML file at multiple URL's, or even a catch-all URL, use plain Django to do that. I think it's out of scope for Whitenoise, whose focus is on serving static files which have single URL's.

For example, you can write catch-all-paths routing like so:

from django.urls import path

from example.core.views import frontend_index

urlpatterns = [
    path("", frontend_index),
    path("<path:path>", frontend_index),
]

And then write a view that serves a pre-built index.html like so:

from django.conf import settings
from django.http import FileResponse
from django.views.decorators.http import require_GET


@require_GET
def frontend_index(request, path=""):
    index_file = (settings.BASE_DIR / "frontend" / "index.html").open("rb")
    return FileResponse(index_file)

There's no need to invoke Whitenoise to serve a file within Django, nor for whitenoise to provide a "serve" function. FileResponse is sufficient, although do consider caching headers.

It's probably better to use a Django template for index.html though, as within a template you can use {% static %} to refer to hashed, cacheable static files from Whitenoise. But if that doesn't suit, for whatever reason, you can serve a folder of arbitrary files (built JS etc.) with the WHITENOISE_ROOT setting - set an appropriate value for WHITENOISE_MAX_AGE.

For more examples on serving a few arbitrary files with vanilla Django see my favicons post.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants