Starlette: Serve Simple Page App (angular) on root alongside a backend on starlette

Created on 15 Mar 2019  路  20Comments  路  Source: encode/starlette

Hello,
I'm developing an angular (7)+ starlette api backend and I would like to make starlette serve the angular app alongside with the api.
I know that I could use ngnix to serve it but I would like to try to deploy just one server. Also I have found the https://github.com/tiangolo/uvicorn-gunicorn-starlette-docker and it would be amazing to be able to just use it with the whole app.
As a normal angular app, it expects (by default) to be located to the "/" endpoint. As my angular prod files are in a folder ./angular relative to the starlette app.py file I have tried the following:

_CURDIR = dirname(abspath(__file__))
app = Starlette()
app.mount('/', StaticFiles(directory=join(_CURDIR, 'angular' )))

@app.exception_handler(404)
async def not_found(request, exc):
    return FileResponse('./angular/index.html')

if __name__ == '__main__':
    uvicorn.run(app, host='0.0.0.0', port=8090)

The index.html is the usual on the angular apps:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>ArcherytimerMatrix</title>
  <base href="/">

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="styles.54bd9f72bfb2c07e2a6b.css"></head>
<body>
  <app-root></app-root>
<script type="text/javascript" src="runtime.253d2798c34ad4bc8428.js"></script><script type="text/javascript" src="es2015-polyfills.4a4cfea0ce682043f4e9.js" nomodule>
</script><script type="text/javascript" src="polyfills.407a467dedb63cfdd103.js"></script><script type="text/javascript" src="main.cfa2a2d950b21a173917.js"></script></body>
</html>

when I try to reach localhost:8090 the index.html is "shown" but it can't find the styles / runtime / polyfills and main

INFO: Uvicorn running on http://0.0.0.0:8090 (Press CTRL+C to quit)
INFO: ('127.0.0.1', 34138) - "GET / HTTP/1.1" 200
INFO: ('127.0.0.1', 34138) - "GET /styles.54bd9f72bfb2c07e2a6b.css HTTP/1.1" 200
INFO: ('127.0.0.1', 34140) - "GET /runtime.253d2798c34ad4bc8428.js HTTP/1.1" 200
INFO: ('127.0.0.1', 34138) - "GET /polyfills.407a467dedb63cfdd103.js HTTP/1.1" 200
INFO: ('127.0.0.1', 34140) - "GET /main.cfa2a2d950b21a173917.js HTTP/1.1" 200

(the status is 200 as I'm catching the exception and redirecting...)
If I try to access the static file directy:
localhost:8090/styles.54bd9f72bfb2c07e2a6b.css
Is not found either

If I serve the statics files from an endpoint:
app.mount('/angular', StaticFiles(directory=join(_CURDIR, 'angular' )))
and I try to access to the "angular"
localhost:8090/angular/styles.54bd9f72bfb2c07e2a6b.css the file is served.
So It seems that the "/" root can't be used for endopoint to static files (or there is a bug if this is not the intended behavior)

Maybe I'm doing it wrong, is there another way to achieve this?

Thank you in advance
Regards

Most helpful comment

@andrewmwhite If you create your frontend and api as separate starlette apps, and then mount each of those in a single "parent" app, you should be able to adapt the approach above without any awkward explicit excludes, since the FileResponse-generating middleware will now _only_ apply to the frontend app:

import uvicorn

from starlette.applications import Starlette
from starlette.staticfiles import StaticFiles
from starlette.responses import FileResponse


main_app = Starlette()

frontend = Starlette()
api = Starlette()


@frontend.middleware("http")
async def add_custom_header(request, call_next):
    response = await call_next(request)
    if response.status_code == 404:
        return FileResponse('docs/authentication.md')
    return response


@frontend.exception_handler(404)
def not_found(request, exc):
    return FileResponse('docs/authentication.md')


frontend.mount('/', StaticFiles(directory='tests'))


main_app.mount('/api', app=api)
main_app.mount('/', app=frontend)


if __name__ == '__main__':
    uvicorn.run(main_app, host='0.0.0.0', port=8090)

I just ran the above in a file exactly as is, in the root folder of starlette, and I get the auth docs page for any 404 that doesn't start /api, and I get the standard mostly-blank 404 response from any route starting /api.

Would that work for you?

All 20 comments

For one thing - you don't really need to mount StaticFiles within a Starlette app if that's all you're using - you can just do...

app = StaticFiles(directory=join(_CURDIR, 'angular' ))

Make sure you're using a leading / in the hyperlinks. I expect the issue is that you're using relative URLs, which are then pointed at the wrong location.

Also, StaticFiles doesn't yet support using / to serve index.html, which would be a really useful thing for it to do. Ideally we'd have a mode like app = StaticFiles(directory=..., html=True), which would:

  • Support serving index.html for directory URLs.
  • Serve any 404.html in the root, for any 404 cases.

@tomchristie Thanks for answering.
I'm not only using startlette to serve just the angular app. The main purpouse of Startlette in my project is to provide a backend rest api for such angular app. I wanted to just avoid having to add another server to serve the static app.
I will try adding the slash on the hyperlinks inside the index.html, but it should be pointing to the root.
I'm a bit confused, asthe rest of your comments makes me think that startlette can't really serve the app (at least with the current implementation) Am I right?

The main purpouse of Startlette in my project is to provide a backend rest api for such angular app. I wanted to just avoid having to add another server to serve the static app.

Oh right, sure - then mount the StaticFiles app. If you're mounting it at / then you'll need to make sure it's the last thing in the routing, since it'll match any path. Yes you can serve both the app and that static files like that.

Also I've just double checked that there's no issue with serving mounted at the root path...

example.py

from starlette.applications import Starlette
from starlette.staticfiles import StaticFiles

app = Starlette()
app.mount('/', StaticFiles(directory='statics'))

statics/a.txt

123
$ uvicorn example:app

Loads http://127.0.0.1:8000/a.txt just fine.

I'd suggest checking if you're missing the leading '/'.

Alternatively you can also do...

app.mount('/', StaticFiles(directory='statics'), name='static')

Then in the template tags, use url_for('static', path="...") for any hyperlinks. (That's kinda overkill for this case, but it'll handle giving you the correct URL, even if you change the mount point)

Hello,
I don't know what is happening. If I tried your example, http://127.0.0.1:8000/a.txtreturns a 404
However if I just serve in a diferent endpoint:
app.mount('/dummy', StaticFiles(directory='statics'))
Accesing to
http://127.0.0.1:8000/dummy/a.txt
is showing the file...
Why I can't mount the files on the root?

Are you copying example.py exactly?
Do you have the latest version of Starlette installed?

Hello,
Just to be sure I have updated python to python 3.7.2, create a new environment using conda and installing just starlette with pip3 install starletteand pip3 install uvicorn and it is still not working. I receive a 404 every time I try to access http://127.0.0.1/a.txt
The startlette version is the pypi one. this is my pip list output:

Package           Version
----------------- -------
astroid           2.2.5  
Click             7.0    
h11               0.8.1  
httptools         0.0.13 
isort             4.3.15 
lazy-object-proxy 1.3.1  
mccabe            0.6.1  
pip               18.1   
pylint            2.3.1  
setuptools        40.6.2 
six               1.12.0 
starlette         0.11.3 
typed-ast         1.3.1  
uvicorn           0.6.1  
uvloop            0.12.1 
websockets        7.0    
wrapt             1.11.1 

I have copied the example exactly.
I'm running the coe on a MacOs but it doesn't work either on a windows 10

Okay good catch - Mount('/', ...) was broken.
Now resolved in Starlette 0.11.4.
Thanks so much for raising the issue!

Hello,
Thanks for the fix!.
Just for completeness of the initial question.
At this moment the right way to handle the SPA is to mount root as static and catch the 404 and redirect it to the index.hml. Am I right?

I'd probably do something like this...

from starlette.applications import Starlette
from starlette.staticfiles import StaticFiles

async def homepage(request):
    return FileResponse('statics/index.html')

app = Starlette()
app.route('/', homepage, methods=['GET'])
app.mount('/', StaticFiles(directory='statics'))

Hello again, sorry to disturb you again.
Something very strange is happening.
Using your code when I access to the root the index,html is loaded and the styles and files are properly show, but, if I try to reach to any of the routes of the SPA that has to be redirected to the index.html, they are not redirected and a 404 is throw.
Then I tried to add an exception handler:

app = Starlette()

@app.exception_handler(404)
async def not_found(request, exc):
    return FileResponse('angular/index.html')

app.route('/', homepage)
app.mount('/', StaticFiles(directory='angular'))

But the exception handler is never reached. It seems that the staticsfiles is not throwing a 404 when a file is not found.

StaticFiles doesn't raise 404 exceptions, no. It just returns plain text 404 responses. (Typically in a web app you won't want missing static files rendering into your 404 html page.)

I see,
Is there any way to be able to "catch" such response to redirect to index.hml?

Ok, I think I found a solution. I placed a middleware to catch the 404.
The whole code is like this:

from starlette.applications import Starlette
from starlette.staticfiles import StaticFiles
from starlette.responses import FileResponse
import uvicorn

async def homepage(request):
    return FileResponse('angular/index.html')


app = Starlette()

@app.middleware("http")
async def add_custom_header(request, call_next):
    response = await call_next(request)
    if response.status_code == 404:
        return FileResponse('angular/index.html')
    return response

@app.exception_handler(404)
def not_found(request, exc):
    return FileResponse('angular/index.html')

app.route('/', homepage)
app.mount('/', StaticFiles(directory='angular'))


if __name__ == '__main__':
    uvicorn.run(app, host='0.0.0.0', port=8090)

I'm not very confident on such solution but it seems to work for the moment. I have to test with the real backend api in place and with websockets.

We're running into the same issue as in the comment above where we want to catch any 404s that aren't in our /api routes and either render the index page or redirect to it. Is there any better method than the middleware above? We'd have to explicitly exclude our /api routes in the middleware, which feels hacky.

(Thanks for Starlette, though, so far I"m really impressed and loving the possibilities.)

@andrewmwhite If you create your frontend and api as separate starlette apps, and then mount each of those in a single "parent" app, you should be able to adapt the approach above without any awkward explicit excludes, since the FileResponse-generating middleware will now _only_ apply to the frontend app:

import uvicorn

from starlette.applications import Starlette
from starlette.staticfiles import StaticFiles
from starlette.responses import FileResponse


main_app = Starlette()

frontend = Starlette()
api = Starlette()


@frontend.middleware("http")
async def add_custom_header(request, call_next):
    response = await call_next(request)
    if response.status_code == 404:
        return FileResponse('docs/authentication.md')
    return response


@frontend.exception_handler(404)
def not_found(request, exc):
    return FileResponse('docs/authentication.md')


frontend.mount('/', StaticFiles(directory='tests'))


main_app.mount('/api', app=api)
main_app.mount('/', app=frontend)


if __name__ == '__main__':
    uvicorn.run(main_app, host='0.0.0.0', port=8090)

I just ran the above in a file exactly as is, in the root folder of starlette, and I get the auth docs page for any 404 that doesn't start /api, and I get the standard mostly-blank 404 response from any route starting /api.

Would that work for you?

Yes, that should work for us -- looks like it does in a quick test, anyway. I'm assuming the exception handler isn't actually necessary there, just the middleware?

@dmontagu that is what I was looking for yesterday night :) I was stuck with

app = Starlette()
app = Router(...) 
if __name__ == '__main__':
    uvicorn.run(main_app, host='0.0.0.0', port=8090)

and didnt know how to add route to starlette app instance

@andrewmwhite I think the exception handler is only necessary if you are raising a starlette.exceptions.HTTPException with status code 404. I don't know how Starlette's static files app handles 404s (I didn't test disabling one or the other of the 404 handlers), but it wouldn't surprise me if you only needed one of those two handlers.

@dmontagu you are right, In fact, I removed the @app.exception_handler(404) from my code and it works without problem

This has been answered, but if you don't want to override 404 -- for example if you're serving your SPA in a subdirectory like /app/', this works well enough:

from starlette.staticfiles import StaticFiles


class SPAStaticFiles(StaticFiles):
    async def get_response(self, path: str, scope: Scope) -> Response:
        response = await super().get_response(path, scope)
        if response.status_code == 404:
            response = await super().get_response('.', scope)
        return response


app.mount('/app/', SPAStaticFiles(directory=spa_directory, html=True))
Was this page helpful?
0 / 5 - 0 ratings

Related issues

hyperknot picture hyperknot  路  6Comments

Immortalin picture Immortalin  路  3Comments

kouohhashi picture kouohhashi  路  3Comments

koddr picture koddr  路  6Comments

tomchristie picture tomchristie  路  5Comments