encode / starlette

The little ASGI framework that shines. 🌟
https://www.starlette.io/
BSD 3-Clause "New" or "Revised" License
10.31k stars 949 forks source link

Add options to support SPAs in StaticFiles #2591

Closed estheruary closed 5 months ago

estheruary commented 6 months ago

Summary

There are two changes supporting one aim, if you don't consider that to be atomic I can open two PRs.

  1. Support loading files from packages that might not be present at instantiation and are built asynchronously from webpack and it's contemporaries.
  2. The fallback_file option which is used in client side routing. These tools expect that any path that doesn't resolve to a file in the build directory will return the main SPA file (typically index.html).

Checklist

Fixes https://github.com/encode/starlette/discussions/1821

An example of someone in the wild wanting this kind of functionality: https://stackoverflow.com/questions/63069190. I think the examples you find around the web to be insufficient in the case where index.html is part of your static bundle that might live in a package.

Kludex commented 6 months ago

I think we tried to introduce the "fallback_file" with a different name some time ago... 🤔

I prefer the changes to be atomic, yes. And I think there's a discussion, or PR about your first point - you might want to check that.

estheruary commented 1 month ago

For anyone using Starlette / FastAPI running into the same issue:

app = FastAPI()

class SPAFiles(StaticFiles):
    def lookup_index(self):
        return super().lookup_path("index.html")

    def get_directories(
        self, directory: PathLike | None = None, packages: list[str | tuple[str, str]] | None = None
    ) -> List[PathLike]:

        directories = []
        if directory is not None:
            directories.append(directory)

        for package in packages or []:
            if isinstance(package, tuple):
                package, statics_dir = package
            else:
                statics_dir = "statics"
            spec = importlib.util.find_spec(package)
            assert spec is not None, f"Package {package!r} could not be found."
            assert spec.origin is not None, f"Package {package!r} could not be found."
            package_directory = os.path.normpath(os.path.join(spec.origin, "..", statics_dir))
            directories.append(package_directory)

        return directories

    def lookup_path(self, path: str) -> Tuple[str, os.stat_result | None]:
        path, stat = super().lookup_path(path)

        if path == "" and stat is None:
            return self.lookup_index()

        return path, stat

app.mount(
    "/app",
    SPAFiles(
        # If your webapp lives along side your server you can use packages as well.
        # Let's say your SPA was in project_root/myapp/webapp and when you ran 
        # npm build it output to the build/ directory inside there. You would specify
        # that like the following.
        #packages=[("myapp.webapp", "build")],
        directory="path/to/my/directory",
        html=True,
        follow_symlink=True,
    ),
    name="static",
)