starlette icon indicating copy to clipboard operation
starlette copied to clipboard

Add options to support SPAs in StaticFiles

Open estheruary opened this issue 1 year ago • 1 comments

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

  • [x] I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
  • [x] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • [x] I've updated the documentation accordingly.

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.

estheruary avatar May 08 '24 03:05 estheruary

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.

Kludex avatar May 08 '24 05:05 Kludex

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",
)

estheruary avatar Oct 05 '24 03:10 estheruary