Repository: marimo-team/marimo
Pull request: feat: create_asgi_app to programatically run marimo apps
This exposes a new function from the `marimo` module: `create_asgi_app`. Which is a builder to add many marimo apps:

Closes #904 #395

Regarding the API:
* i chose a builder instead of seperate `create_asgi_app` so 1) we can share configs, 2) we can initialize shared singletons between the apps
* i called the top-level `root` instead of `filename` so we can latter support `dirs` or `code`

It would look something like:

```python
from typing import Annotated, Callable, Coroutine
from fastapi.responses import HTMLResponse, RedirectResponse
import marimo
from fastapi import FastAPI, Form, Request, Response


# Create a marimo asgi app
server = (
    marimo.create_asgi_app()
    .with_app(path="", root="./pages/index.py")
    .with_app(path="/dashboard", root="./pages/dashboard.py")
    .with_app(path="/sales", root="./pages/sales.py")
)

# Create a FastAPI app
app = FastAPI()

app.add_middleware(auth_middleware)
app.add_route("/login", my_login_route, methods=["POST"])

app.mount("/", server.build())

# Run the server
if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="localhost", port=8000)
```



Problem statement:
Embed Marimo in FastAPI/Starlette Server
### Description

I'd like to make Marimo embeddable in another FastAPI / Starlette server, i.e. Marimo as a library.




### Suggested solution

```python
from marimo import Instance # made up :) 

my_notebook = Instance('/path/to/notebook.py')

my_notebook.start_kernel() # also has stop_kernel, etc.

app = FastAPI()
app.include_router(my_notebook.router(), prefix="/my-prefix")
```

Then I could integrate my own authentication, etc.

I'd also like to be able to provide a function that provides some global variable overrides to the instance based on the user request, so that I can implement some security for serving the same notebook to different internal users. 

Maybe something like:
```python
from marimo import Instance

department_analytics = Instance('/path/to/department_analytics.py')
department_analytics.start_kernel()

async def get_initial_params():
    department_options = await get_allowed_departments_for_dropdown_from_req()
    return { "department_options": department_options }

app = FastAPI()
app.include_router(
    department_analytics.router(get_initial_params=get_initial_params),
    prefix="/analytics"
)
```

### Alternative

_No response_

### Additional context

_No response_

Hints from issue discussion:
This is something I've thought about and makes a lot of sense. I'm not sure what the best entrypoint is (file-path, app code, etc) and what the API is (return a starlette app, routes, etc)

We will need to think about this, and we are open to suggestions. Ideally the API is minimal but room to expand.

Have you seen other libraries do this well?
I think Benjie has done this well with [graphile-worker](https://github.com/graphile/worker) and [Postgraphile](https://github.com/graphile/crystal). Obviously a different ecosystem and different type of projects, but those both support CLI mode and library mode. CLI mode just supports the most common user, and then most customization requires ejecting to library usage.

For each, I think those projects answered your questions with "all of the above". Basically build a higher level API out of lower level ones, but still expose the lower level building blocks for you to use directly when you need more customization. The graphile projects also use their own lower level building blocks to construct the higher level APIs, so I can go and look in the code for the higher level API to see how to use the building blocks correctly.

I haven't done too much server programming in Python so not sure what all of the equivalents are though
Personally, I think requiring users to provide the imported `marimo.App` would be fine (that's supportable, right?). I think the key requirement for the API is that it can be proxyable and share auth and other middleware with other routes in your app.
Thanks for the examples - will try to take some inspiration. Yea we would need to update some of the utils / exposed classes. 

I think we can start small and expose just the right amount, and include snippets to say integrate with fastapi

Commit message: fix asgi

Patch to review:
diff --git a/docs/guides/deploying/index.md b/docs/guides/deploying/index.md
index dd877ab2636..def789008e1 100644
--- a/docs/guides/deploying/index.md
+++ b/docs/guides/deploying/index.md
@@ -5,6 +5,7 @@
   :maxdepth: 2
   :hidden:
 
+  programmatically
   deploying_docker
   deploying_public_gallery
 ```
@@ -13,6 +14,7 @@ These guides help you deploy marimo notebooks.
 
 |                                 |                                                           |
 | :------------------------------ | :-------------------------------------------------------- |
+| {doc}`programmatically`         | Programmatically run the marimo backend for customization |
 | {doc}`deploying_docker`         | Deploying marimo notebooks and apps with Docker           |
 | {doc}`deploying_public_gallery` | Deploying marimo notebooks and apps to our public gallery |
 
@@ -27,6 +29,12 @@ WASM notebooks support most but not all Python features and packages. See our
 [guide on WASM notebooks](../../guides/wasm.md) to learn more.
 ```
 
+## Programmatically running the marimo backend
+
+When deploying marimo notebooks, you can run the marimo backend programmatically. This allows you to customize the backend to your needs and deploy it in your own environment.
+
+See the [programmatically running the marimo backend guide](programmatically.md) for more information.
+
 ## Health and status endpoints
 
 The following endpoints may be useful when deploying your application:
diff --git a/docs/guides/deploying/programmatically.md b/docs/guides/deploying/programmatically.md
new file mode 100644
index 00000000000..bcf3243eb0e
--- /dev/null
+++ b/docs/guides/deploying/programmatically.md
@@ -0,0 +1,37 @@
+# Running the marimo backend programmatically
+
+marimo can be run programmatically using the `marimo` module. This is useful when you want to run marimo as part of a larger application or when you need to customize the behavior of marimo (e.g. middleware, custom error handling, authentication, routing, etc).
+
+## FastAPI Example
+
+Here's an example of how you can run a marimo application programmatically using FastAPI:
+
+```python
+from typing import Annotated, Callable, Coroutine
+from fastapi.responses import HTMLResponse, RedirectResponse
+import marimo
+from fastapi import FastAPI, Form, Request, Response
+
+
+# Create a marimo asgi app
+server = (
+    marimo.create_asgi_app()
+    .with_app(path="", root="./pages/index.py")
+    .with_app(path="/dashboard", root="./pages/dashboard.py")
+    .with_app(path="/sales", root="./pages/sales.py")
+)
+
+# Create a FastAPI app
+app = FastAPI()
+
+app.add_middleware(auth_middleware)
+app.add_route("/login", my_login_route, methods=["POST"])
+
+app.mount("/", server.build())
+
+# Run the server
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(app, host="localhost", port=8000)
+```
diff --git a/frontend/src/core/websocket/__tests__/createWsUrl.test.ts b/frontend/src/core/websocket/__tests__/createWsUrl.test.ts
new file mode 100644
index 00000000000..4be794f4202
--- /dev/null
+++ b/frontend/src/core/websocket/__tests__/createWsUrl.test.ts
@@ -0,0 +1,48 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+
+import { describe, expect, it } from "vitest";
+import { createWsUrl } from "../createWsUrl";
+
+describe("createWsUrl", () => {
+  it("should return a URL with the wss protocol when the baseURI uses https", () => {
+    // Mock the document.baseURI to return an https URL
+    Object.defineProperty(document, "baseURI", {
+      value: "https://marimo.app",
+      writable: true,
+    });
+
+    const sessionId = "1234";
+    const result = createWsUrl(sessionId);
+    expect(result).toBe("wss://marimo.app/ws?session_id=1234");
+    const url = new URL(result);
+    expect(url.searchParams.get("session_id")).toBe(sessionId);
+  });
+
+  it("should return a URL with the ws protocol when the baseURI uses http", () => {
+    // Mock the document.baseURI to return an http URL
+    Object.defineProperty(document, "baseURI", {
+      value: "http://marimo.app",
+      writable: true,
+    });
+
+    const sessionId = "1234";
+    const result = createWsUrl(sessionId);
+    expect(result).toBe("ws://marimo.app/ws?session_id=1234");
+    const url = new URL(result);
+    expect(url.searchParams.get("session_id")).toBe(sessionId);
+  });
+
+  it("should work with nested baseURI", () => {
+    // Mock the document.baseURI to return an http URL
+    Object.defineProperty(document, "baseURI", {
+      value: "http://marimo.app/nested",
+      writable: true,
+    });
+
+    const sessionId = "1234";
+    const result = createWsUrl(sessionId);
+    expect(result).toBe("ws://marimo.app/nested/ws?session_id=1234");
+    const url = new URL(result);
+    expect(url.searchParams.get("session_id")).toBe(sessionId);
+  });
+});
diff --git a/frontend/src/core/websocket/createWsUrl.ts b/frontend/src/core/websocket/createWsUrl.ts
new file mode 100644
index 00000000000..3c0175b8406
--- /dev/null
+++ b/frontend/src/core/websocket/createWsUrl.ts
@@ -0,0 +1,19 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+export function createWsUrl(sessionId: string): string {
+  const baseURI = document.baseURI;
+
+  const url = new URL(baseURI);
+  const protocol = url.protocol === "https:" ? "wss" : "ws";
+  url.protocol = protocol;
+  url.pathname = `${withoutTrailingSlash(url.pathname)}/ws`;
+
+  const searchParams = new URLSearchParams(url.search);
+  searchParams.set("session_id", sessionId);
+  url.search = searchParams.toString();
+
+  return url.toString();
+}
+
+function withoutTrailingSlash(url: string): string {
+  return url.endsWith("/") ? url.slice(0, -1) : url;
+}
diff --git a/frontend/src/core/websocket/useMarimoWebSocket.tsx b/frontend/src/core/websocket/useMarimoWebSocket.tsx
index 9e0c29e08d7..faef3a670af 100644
--- a/frontend/src/core/websocket/useMarimoWebSocket.tsx
+++ b/frontend/src/core/websocket/useMarimoWebSocket.tsx
@@ -30,6 +30,7 @@ import { SessionId } from "../kernel/session";
 import { useBannersActions } from "../errors/state";
 import { useAlertActions } from "../alerts/state";
 import { generateUUID } from "@/utils/uuid";
+import { createWsUrl } from "./createWsUrl";
 
 /**
  * WebSocket that connects to the Marimo kernel and handles incoming messages.
@@ -325,9 +326,3 @@ export function useMarimoWebSocket(opts: {
 
   return { connStatus };
 }
-
-function createWsUrl(sessionId: string): string {
-  const protocol = window.location.protocol === "https:" ? "wss" : "ws";
-
-  return `${protocol}://${window.location.host}/ws?session_id=${sessionId}`;
-}
diff --git a/marimo/__init__.py b/marimo/__init__.py
index 8cea160fd23..4a74c0fa812 100644
--- a/marimo/__init__.py
+++ b/marimo/__init__.py
@@ -16,6 +16,7 @@
     "App",
     "Cell",
     "MarimoStopError",
+    "create_asgi_app",
     "accordion",
     "carousel",
     "as_html",
@@ -90,3 +91,4 @@
 from marimo._runtime.control_flow import MarimoStopError, stop
 from marimo._runtime.runtime import defs, refs
 from marimo._runtime.state import state
+from marimo._server.asgi import create_asgi_app
diff --git a/marimo/_server/api/deps.py b/marimo/_server/api/deps.py
index 29bc81ce281..efd5ded9727 100644
--- a/marimo/_server/api/deps.py
+++ b/marimo/_server/api/deps.py
@@ -27,23 +27,18 @@ def __init__(self, request: Union[Request, WebSocket]) -> None:
         assert (
             request.app.state.session_manager is not None
         ), "Session manager not initialized"
-        assert request.app.state.server is not None, "Server not initialized"
-        assert request.app.state.host is not None, "Host not initialized"
-        assert request.app.state.port is not None, "Port not initialized"
+        self.session_manager: SessionManager = (
+            request.app.state.session_manager
+        )
+
         assert (
             request.app.state.base_url is not None
         ), "Base URL not initialized"
+        self._base_url: str = request.app.state.base_url
+
         assert (
             request.app.state.config_manager is not None
         ), "Config manager not initialized"
-
-        self.session_manager: SessionManager = (
-            request.app.state.session_manager
-        )
-        self._server: Server = request.app.state.server
-        self._host: str = request.app.state.host
-        self._port: int = request.app.state.port
-        self._base_url: str = request.app.state.base_url
         self._config_manager: UserConfigManager = (
             request.app.state.config_manager
         )
@@ -92,11 +87,15 @@ def development_mode(self) -> bool:
 
     @property
     def host(self) -> str:
-        return self._host
+        assert self.request.app.state.host is not None, "Host not initialized"
+        host: str = self.request.app.state.host
+        return host
 
     @property
     def port(self) -> int:
-        return self._port
+        assert self.request.app.state.port is not None, "Port not initialized"
+        post: int = self.request.app.state.port
+        return post
 
     @property
     def base_url(self) -> str:
@@ -108,7 +107,11 @@ def server_token(self) -> str:
 
     @property
     def server(self) -> Server:
-        return self._server
+        assert (
+            self.request.app.state.server is not None
+        ), "Server not initialized"
+        server: Server = self.request.app.state.server
+        return server
 
     @property
     def config_manager(self) -> UserConfigManager:
diff --git a/marimo/_server/api/lifespans.py b/marimo/_server/api/lifespans.py
index 6685b946e68..932ea2c638f 100644
--- a/marimo/_server/api/lifespans.py
+++ b/marimo/_server/api/lifespans.py
@@ -25,7 +25,6 @@
 from marimo._server.api.utils import open_url_in_browser
 from marimo._server.model import SessionMode
 from marimo._server.print import print_shutdown, print_startup
-from marimo._server.sessions import get_manager
 from marimo._server.utils import initialize_mimetypes
 from marimo._server.uvicorn_utils import close_uvicorn
 
@@ -67,7 +66,7 @@ def __call__(self, app: Starlette) -> AbstractAsyncContextManager[None]:
 @contextlib.asynccontextmanager
 async def lsp(app: Starlette) -> AsyncIterator[None]:
     user_config = app.state.config_manager.get_config()
-    session_mgr = get_manager()
+    session_mgr = app.state.session_manager
     run = session_mgr.mode == SessionMode.RUN
     if not run and user_config["completion"]["copilot"]:
         LOGGER.debug("GitHub Copilot is enabled")
@@ -79,20 +78,20 @@ async def lsp(app: Starlette) -> AsyncIterator[None]:
 async def watcher(app: Starlette) -> AsyncIterator[None]:
     watch: bool = app.state.watch
     if watch:
-        session_mgr = get_manager()
+        session_mgr = app.state.session_manager
         session_mgr.start_file_watcher()
     yield
 
 
 @contextlib.asynccontextmanager
 async def open_browser(app: Starlette) -> AsyncIterator[None]:
-    host = app.state.host
-    port = app.state.port
-    base_url = app.state.base_url
-    url = f"http://{host}:{port}{base_url}"
-    user_config = app.state.config_manager.get_config()
     headless = app.state.headless
     if not headless:
+        host = app.state.host
+        port = app.state.port
+        base_url = app.state.base_url
+        url = f"http://{host}:{port}{base_url}"
+        user_config = app.state.config_manager.get_config()
         browser = user_config["server"]["browser"]
         # Wait 20ms for the server to start and then open the browser, but this
         # function must complete
@@ -104,7 +103,7 @@ async def open_browser(app: Starlette) -> AsyncIterator[None]:
 
 @contextlib.asynccontextmanager
 async def logging(app: Starlette) -> AsyncIterator[None]:
-    manager = get_manager()
+    manager = app.state.session_manager
     host = app.state.host
     port = app.state.port
     base_url = app.state.base_url
@@ -140,12 +139,13 @@ async def logging(app: Starlette) -> AsyncIterator[None]:
 
 @contextlib.asynccontextmanager
 async def signal_handler(app: Starlette) -> AsyncIterator[None]:
-    manager = get_manager()
+    manager = app.state.session_manager
 
     # Interrupt handler
     def shutdown() -> None:
         manager.shutdown()
-        close_uvicorn(app.state.server)
+        if app.state.server:
+            close_uvicorn(app.state.server)
 
     InterruptHandler(
         quiet=manager.quiet,
@@ -160,15 +160,3 @@ async def etc(app: Starlette) -> AsyncIterator[None]:
     # Mimetypes
     initialize_mimetypes()
     yield
-
-
-LIFESPANS = Lifespans(
-    [
-        lsp,
-        watcher,
-        etc,
-        signal_handler,
-        logging,
-        open_browser,
-    ]
-)
diff --git a/marimo/_server/api/middleware.py b/marimo/_server/api/middleware.py
index 96bc8440c46..0ac556281f7 100644
--- a/marimo/_server/api/middleware.py
+++ b/marimo/_server/api/middleware.py
@@ -46,7 +46,9 @@ async def __call__(
         if request.method != "POST":
             return await self.app(scope, receive, send)
         # If ws, skip
-        if request.url.path.startswith("/ws"):
+        if request.url.path.startswith("/ws") or request.url.path.endswith(
+            "/ws"
+        ):
             return await self.app(scope, receive, send)
 
         expected_server_token = request.app.state.session_manager.server_token
@@ -64,39 +66,39 @@ async def __call__(
         return await self.app(scope, receive, send)
 
 
-ALLOWED_BASE_URLS = set(["/health", "/healthz", "/metrics", "/"])
+# ALLOWED_BASE_URLS = set(["/health", "/healthz", "/metrics", "/"])
 
 
-class StripBaseURLMiddleware:
-    def __init__(self, app: ASGIApp, base_url: str) -> None:
-        self.app = app
-        self.base_url = base_url
+# class StripBaseURLMiddleware:
+#     def __init__(self, app: ASGIApp, base_url: str) -> None:
+#         self.app = app
+#         self.base_url = base_url
 
-    async def __call__(
-        self, scope: Scope, receive: Receive, send: Send
-    ) -> None:
-        # If not HTTP, skip
-        if scope["type"] != "http":
-            return await self.app(scope, receive, send)
+#     async def __call__(
+#         self, scope: Scope, receive: Receive, send: Send
+#     ) -> None:
+#         # If not HTTP, skip
+#         if scope["type"] != "http":
+#             return await self.app(scope, receive, send)
 
-        # If base URL is empty, skip
-        if self.base_url == "" or self.base_url == "/":
-            return await self.app(scope, receive, send)
+#         # If base URL is empty, skip
+#         if self.base_url == "" or self.base_url == "/":
+#             return await self.app(scope, receive, send)
 
-        request = Request(scope)
+#         request = Request(scope)
 
-        # If under common infra routes, allow
-        if request.url.path in ALLOWED_BASE_URLS:
-            return await self.app(scope, receive, send)
+#         # If under common infra routes, allow
+#         if request.url.path in ALLOWED_BASE_URLS:
+#             return await self.app(scope, receive, send)
 
-        # If not under base URL or under infra routes, return 404
-        # Otherwise, this may hide real issues
-        if not request.url.path.startswith(self.base_url):
-            response = JSONResponse({"error": "Not found"}, status_code=404)
-            return await response(scope, receive, send)
+#         # If not under base URL or under infra routes, return 404
+#         # Otherwise, this may hide real issues
+#         if not request.url.path.startswith(self.base_url):
+#             response = JSONResponse({"error": "Not found"}, status_code=404)
+#             return await response(scope, receive, send)
 
-        # Strip base URL
-        scope["path"] = scope["path"][len(self.base_url) :]
-        if not scope["path"].startswith("/"):
-            scope["path"] = "/" + scope["path"]
-        return await self.app(scope, receive, send)
+#         # Strip base URL
+#         scope["path"] = scope["path"][len(self.base_url) :]
+#         if not scope["path"].startswith("/"):
+#             scope["path"] = "/" + scope["path"]
+#         return await self.app(scope, receive, send)
diff --git a/marimo/_server/api/router.py b/marimo/_server/api/router.py
index d65274308a4..f073dad451c 100644
--- a/marimo/_server/api/router.py
+++ b/marimo/_server/api/router.py
@@ -1,6 +1,10 @@
 # Copyright 2024 Marimo. All rights reserved.
 
 
+from typing import List
+
+from starlette.routing import BaseRoute
+
 from marimo._server.api.endpoints.ai import router as ai_router
 from marimo._server.api.endpoints.assets import router as assets_router
 from marimo._server.api.endpoints.config import router as config_router
@@ -12,20 +16,26 @@
 from marimo._server.api.endpoints.ws import router as ws_router
 from marimo._server.router import APIRouter
 
-# Define the main router
-app_router = APIRouter()
-app_router.include_router(
-    execution_router, prefix="/api/kernel", name="execution"
-)
-app_router.include_router(config_router, prefix="/api/kernel", name="config")
-app_router.include_router(editing_router, prefix="/api/kernel", name="editing")
-app_router.include_router(files_router, prefix="/api/kernel", name="files")
-app_router.include_router(
-    file_explorer, prefix="/api/files", name="file_explorer"
-)
-app_router.include_router(ai_router, prefix="/api/ai", name="ai")
-app_router.include_router(health_router)
-app_router.include_router(ws_router)
-app_router.include_router(assets_router)
 
-ROUTES = app_router.routes
+# Define the app routes
+def build_routes(base_url: str = "") -> List[BaseRoute]:
+    app_router = APIRouter(prefix=base_url)
+    app_router.include_router(
+        execution_router, prefix="/api/kernel", name="execution"
+    )
+    app_router.include_router(
+        config_router, prefix="/api/kernel", name="config"
+    )
+    app_router.include_router(
+        editing_router, prefix="/api/kernel", name="editing"
+    )
+    app_router.include_router(files_router, prefix="/api/kernel", name="files")
+    app_router.include_router(
+        file_explorer, prefix="/api/files", name="file_explorer"
+    )
+    app_router.include_router(ai_router, prefix="/api/ai", name="ai")
+    app_router.include_router(health_router, name="health")
+    app_router.include_router(ws_router, name="ws")
+    app_router.include_router(assets_router, name="assets")
+
+    return app_router.routes
diff --git a/marimo/_server/asgi.py b/marimo/_server/asgi.py
new file mode 100644
index 00000000000..86d0915206b
--- /dev/null
+++ b/marimo/_server/asgi.py
@@ -0,0 +1,147 @@
+# Copyright 2024 Marimo. All rights reserved.
+import abc
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from starlette.types import ASGIApp
+
+
+class ASGIAppBuilder(abc.ABC):
+    @abc.abstractmethod
+    def with_app(self, *, path: str, root: str) -> "ASGIAppBuilder":
+        pass
+
+    @abc.abstractmethod
+    def build(self) -> "ASGIApp":
+        pass
+
+
+def create_asgi_app(
+    *,
+    quiet: bool = False,
+    include_code: bool = False,
+) -> ASGIAppBuilder:
+    """
+    Public API to create an ASGI app that can serve multiple notebooks.
+    This only works for application that are in Run mode.
+
+    **Examples.**
+
+    You can create an ASGI app, and serve the application with a
+    server like `uvicorn`:
+
+    ```python
+    import uvicorn
+    builder = (
+        create_asgi_app()
+        .with_app(path="/", root="home.py")
+        .with_app(path="/app", root="app.py")
+        .with_app(path="/app2", root="app2.py")
+    )
+    app = builder.build()
+
+    if __name__ == "__main__":
+        uvicorn.run(app, port=8000)
+    ```
+
+    Or you can further integrate it with a FastAPI app:
+
+    ```python
+    import uvicorn
+    from fastapi import FastAPI
+    import my_middlewares
+    import my_routes
+
+    app = FastAPI()
+
+    builder = (
+        create_asgi_app()
+        .with_app(path="/app", root="app.py")
+        .with_app(path="/app2", root="app2.py")
+    )
+
+    # Add middlewares
+    app.add_middleware(my_middlewares.auth_middleware)
+
+    # Add routes
+    @app.get("/login")
+    async def root():
+        pass
+
+    # Add the marimo app
+    app.mount("/", builder.build())
+
+    if __name__ == "__main__":
+        uvicorn.run(app, port=8000)
+    ```
+
+    **Args.**
+
+    - quiet (bool, optional): Suppress standard out
+    - include_code (bool, optional): Include notebook code in the app
+
+    **Returns.**
+
+    - ASGIAppBuilder: A builder object to create multiple ASGI apps
+    """
+    from starlette.applications import Starlette
+    from starlette.responses import RedirectResponse
+
+    import marimo._server.api.lifespans as lifespans
+    from marimo._config.manager import UserConfigManager
+    from marimo._server.main import create_starlette_app
+    from marimo._server.model import SessionMode
+    from marimo._server.sessions import NoopLspServer, SessionManager
+
+    user_config_mgr = UserConfigManager()
+    base_app = Starlette()
+    package_manager = user_config_mgr.config["package_management"]["manager"]
+
+    # We call the entrypoint `root` instead of `filename` incase we want to
+    # support directories or code in the future
+    class Builder(ASGIAppBuilder):
+        def with_app(self, *, path: str, root: str) -> "ASGIAppBuilder":
+            session_manager = SessionManager(
+                filename=root,
+                mode=SessionMode.RUN,
+                development_mode=False,
+                quiet=quiet,
+                include_code=include_code,
+                # Currently we only support run mode,
+                # which doesn't require an LSP server
+                lsp_server=NoopLspServer(),
+                package_manager=package_manager,
+            )
+            app = create_starlette_app(
+                base_url="",
+                lifespan=lifespans.Lifespans(
+                    [
+                        # Not all lifespans are needed for run mode
+                        lifespans.etc,
+                        lifespans.signal_handler,
+                    ]
+                ),
+            )
+            app.state.session_manager = session_manager
+            app.state.base_url = path
+            app.state.config_manager = user_config_mgr
+
+            base_app.mount(path, app)
+
+            # If path is not empty,
+            # add a redirect from /{path} to /{path}/
+            # otherwise, we get a 404
+            if path:
+                base_app.add_route(
+                    path,
+                    lambda _: RedirectResponse(
+                        url=f"{path}/", status_code=301
+                    ),
+                )
+
+            return self
+
+        def build(self) -> "ASGIApp":
+            return base_app
+
+    return Builder()
diff --git a/marimo/_server/main.py b/marimo/_server/main.py
index 41d11d9ff38..26756a7f1c0 100644
--- a/marimo/_server/main.py
+++ b/marimo/_server/main.py
@@ -1,5 +1,5 @@
 # Copyright 2024 Marimo. All rights reserved.
-from typing import Any
+from typing import Any, List, Optional
 
 from starlette.applications import Starlette
 from starlette.exceptions import HTTPException
@@ -8,14 +8,13 @@
 from starlette.middleware.cors import CORSMiddleware
 from starlette.requests import Request
 from starlette.responses import JSONResponse
+from starlette.types import Lifespan
 
-from marimo._server.api.lifespans import LIFESPANS
 from marimo._server.api.middleware import (
     AuthBackend,
-    StripBaseURLMiddleware,
     ValidateServerTokensMiddleware,
 )
-from marimo._server.api.router import ROUTES
+from marimo._server.api.router import build_routes
 from marimo._server.api.status import HTTPException as MarimoHTTPException
 
 
@@ -40,8 +39,10 @@ async def handle_error(request: Request, response: Any) -> Any:
 # Create app
 def create_starlette_app(
     base_url: str,
+    middleware: Optional[List[Middleware]] = None,
+    lifespan: Optional[Lifespan[Starlette]] = None,
 ) -> Starlette:
-    middleware = [
+    final_middlewares: List[Middleware] = [
         Middleware(
             CORSMiddleware,
             allow_origins=["*"],
@@ -49,18 +50,17 @@ def create_starlette_app(
             allow_methods=["*"],
             allow_headers=["*"],
         ),
-        Middleware(
-            StripBaseURLMiddleware,
-            base_url=base_url,
-        ),
         Middleware(AuthenticationMiddleware, backend=AuthBackend()),
         Middleware(ValidateServerTokensMiddleware),
     ]
 
+    if middleware:
+        final_middlewares.extend(middleware)
+
     return Starlette(
-        routes=ROUTES,
-        middleware=middleware,
-        lifespan=LIFESPANS,
+        routes=build_routes(base_url=base_url),
+        middleware=final_middlewares,
+        lifespan=lifespan,
         exception_handlers={
             Exception: handle_error,
             HTTPException: handle_error,
diff --git a/marimo/_server/router.py b/marimo/_server/router.py
index d4251256187..6340a3e1d4f 100644
--- a/marimo/_server/router.py
+++ b/marimo/_server/router.py
@@ -118,11 +118,12 @@ def include_router(
     ) -> None:
         """Include another router in this one."""
         # Merge Mounts with the same path
+        resolved_prefix = self.prefix + prefix
         for route in self.routes:
-            if isinstance(route, Mount) and route.path == prefix:
+            if isinstance(route, Mount) and route.path == resolved_prefix:
                 # NOTE: We don't merge middleware here, because it's not
                 # clear what the correct behavior is.
                 route.routes.extend(router.routes)
                 return
 
-        self.mount(path=self.prefix + prefix, app=router, name=name)
+        self.mount(path=resolved_prefix, app=router, name=name)
diff --git a/marimo/_server/sessions.py b/marimo/_server/sessions.py
index 701558b6fda..abd3af52132 100644
--- a/marimo/_server/sessions.py
+++ b/marimo/_server/sessions.py
@@ -445,7 +445,6 @@ def rename(self, filename: Optional[str]) -> None:
         or opened another file.
         """
         self.filename = filename
-        self.app_metadata.filename = self.path
 
     def create_session(
         self, session_id: SessionId, session_consumer: SessionConsumer
@@ -456,7 +455,7 @@ def create_session(
             self.sessions[session_id] = Session.create(
                 session_consumer=session_consumer,
                 mode=self.mode,
-                app_metadata=self.app_metadata,
+                app_metadata=AppMetadata(filename=self.path),
                 app_file_manager=AppFileManager(self.path),
                 package_manager=self.package_manager,
             )
@@ -660,30 +659,12 @@ def stop(self) -> None:
             LOGGER.debug("LSP server not running")
 
 
-def initialize_manager(
-    filename: Optional[str],
-    mode: SessionMode,
-    port: int,
-    development_mode: bool,
-    quiet: bool,
-    include_code: bool,
-    package_manager: str,
-) -> SessionManager:
-    """Must be called on server start."""
-    global SESSION_MANAGER
-    SESSION_MANAGER = SessionManager(
-        filename=filename,
-        mode=mode,
-        development_mode=development_mode,
-        quiet=quiet,
-        include_code=include_code,
-        lsp_server=LspServer(port * 10),
-        package_manager=package_manager,
-    )
-    return SESSION_MANAGER
-
-
-def get_manager() -> SessionManager:
-    """Cannot be called until manager has been initialized."""
-    assert SESSION_MANAGER is not None
-    return SESSION_MANAGER
+class NoopLspServer(LspServer):
+    def __init__(self) -> None:
+        super().__init__(0)
+
+    def start(self) -> None:
+        pass
+
+    def stop(self) -> None:
+        pass
diff --git a/marimo/_server/start.py b/marimo/_server/start.py
index 1ab3a3c79dc..d19db278a4c 100644
--- a/marimo/_server/start.py
+++ b/marimo/_server/start.py
@@ -4,10 +4,14 @@
 
 import uvicorn
 
+import marimo._server.api.lifespans as lifespans
 from marimo._config.manager import UserConfigManager
 from marimo._server.main import create_starlette_app
 from marimo._server.model import SessionMode
-from marimo._server.sessions import initialize_manager
+from marimo._server.sessions import (
+    LspServer,
+    SessionManager,
+)
 from marimo._server.utils import (
     find_free_port,
     import_files,
@@ -40,13 +44,13 @@ def start(
     port = port or find_free_port(DEFAULT_PORT)
     user_config_mgr = UserConfigManager()
 
-    session_manager = initialize_manager(
+    session_manager = SessionManager(
         filename=filename,
         mode=mode,
         development_mode=development_mode,
         quiet=quiet,
         include_code=include_code,
-        port=port,
+        lsp_server=LspServer(port * 10),
         package_manager=user_config_mgr.config["package_management"][
             "manager"
         ],
@@ -54,7 +58,19 @@ def start(
 
     log_level = "info" if development_mode else "error"
 
-    app = create_starlette_app(base_url=base_url)
+    app = create_starlette_app(
+        base_url=base_url,
+        lifespan=lifespans.Lifespans(
+            [
+                lifespans.lsp,
+                lifespans.watcher,
+                lifespans.etc,
+                lifespans.signal_handler,
+                lifespans.logging,
+                lifespans.open_browser,
+            ]
+        ),
+    )
 
     app.state.headless = headless
     app.state.port = port
diff --git a/marimo/_smoke_tests/custom_server/my_server.py b/marimo/_smoke_tests/custom_server/my_server.py
new file mode 100644
index 00000000000..8e8d4cfbafe
--- /dev/null
+++ b/marimo/_smoke_tests/custom_server/my_server.py
@@ -0,0 +1,69 @@
+# Copyright 2024 Marimo. All rights reserved.
+from typing import Annotated, Callable, Coroutine
+from fastapi.responses import HTMLResponse, RedirectResponse
+import marimo
+from fastapi import FastAPI, Form, Request, Response
+
+
+server = (
+    marimo.create_asgi_app()
+    # Mount the dataframe app at /dataframes
+    .with_app(path="/dataframes", root="../dataframe.py")
+    # Mount the ansi app at /ansi
+    .with_app(path="/ansi", root="../ansi.py")
+    # Mount the buttons app at the root
+    .with_app(path="", root="../buttons.py")
+)
+
+# Create a FastAPI app
+app = FastAPI()
+
+
+# Super simple auth middleware
+# If no token, redirect to login page with simple form
+# Any password is valid for a token
+@app.middleware("http")
+async def auth_middleware(
+    request: Request,
+    call_next: Callable[[Request], Coroutine[None, None, Response]],
+) -> Response:
+    if request.url.path == "/login":
+        response = await call_next(request)
+        return response
+    if "token" not in request.cookies:
+        return RedirectResponse(url="/login")
+    return await call_next(request)
+
+
+@app.get("/login")
+async def get_login():
+    return HTMLResponse(
+        """
+        <form action="/login" method="post">
+            <label for="token">Token</label>
+            <input type="text" id="token" name="token">
+            <button type="submit">Submit</button>
+        </form>
+        """
+    )
+
+
+@app.post("/login")
+async def post_login(token: Annotated[str, Form()]):
+    response = RedirectResponse(url="/")
+    response.set_cookie(key="token", value=token)
+    return response
+
+
+@app.get("/ping")
+async def root():
+    return {"message": "pong"}
+
+
+app.mount("/", server.build())
+
+# Run the server
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(app, host="localhost", port=8000, log_level="info")
diff --git a/tests/_server/api/test_middleware.py b/tests/_server/api/test_middleware.py
index 21a7f268b4c..13a3c09ebb7 100644
--- a/tests/_server/api/test_middleware.py
+++ b/tests/_server/api/test_middleware.py
@@ -24,21 +24,21 @@ def test_base_url() -> None:
 
     client = TestClient(app)
 
-    # Infra routes, allows fallback
+    # Infra routes
     response = client.get("/foo/health")
     assert response.status_code == 200, response.text
     response = client.get("/foo/healthz")
     assert response.status_code == 200, response.text
     response = client.get("/health")
-    assert response.status_code == 200, response.text
+    assert response.status_code == 404, response.text
     response = client.get("/healthz")
-    assert response.status_code == 200, response.text
+    assert response.status_code == 404, response.text
 
-    # Index, allows fallback
+    # Index
     response = client.get("/foo/")
     assert response.status_code == 200, response.text
     response = client.get("/")
-    assert response.status_code == 200, response.text
+    assert response.status_code == 404, response.text
 
     # Favicon, fails when missing base_url
     response = client.get("/foo/favicon.ico")
diff --git a/tests/_server/asgi.py b/tests/_server/asgi.py
new file mode 100644
index 00000000000..5a1ae7a861a
--- /dev/null
+++ b/tests/_server/asgi.py
@@ -0,0 +1,106 @@
+import os
+import tempfile
+import unittest
+
+from starlette.testclient import TestClient
+
+from marimo._server.asgi import ASGIAppBuilder, create_asgi_app
+
+contents = """
+import marimo
+
+__generated_with = "0.0.1"
+app = marimo.App()
+
+
+@app.cell
+def __():
+    import marimo as mo
+    print("Hello from placeholder")
+    return mo,
+
+
+if __name__ == "__main__":
+    app.run()
+"""
+
+
+class TestASGIAppBuilder(unittest.TestCase):
+    def setUp(self):
+        # Create a temporary directory for the tests
+        self.temp_dir = tempfile.TemporaryDirectory()
+        self.app1 = os.path.join(self.temp_dir.name, "app1.py")
+        self.app2 = os.path.join(self.temp_dir.name, "app2.py")
+        with open(self.app1, "w") as f:
+            f.write(contents.replace("placeholder", "app1"))
+        with open(self.app2, "w") as f:
+            f.write(contents.replace("placeholder", "app2"))
+
+    def tearDown(self):
+        # Clean up the temporary directory
+        self.temp_dir.cleanup()
+
+    def test_create_asgi_app(self):
+        builder = create_asgi_app(quiet=True, include_code=True)
+        assert isinstance(builder, ASGIAppBuilder)
+
+        builder = create_asgi_app(quiet=True, include_code=True)
+        assert isinstance(builder, ASGIAppBuilder)
+
+        builder = builder.with_app(path="/test", root=self.app1)
+        app = builder.build()
+        assert callable(app)
+
+    def test_app_base(self):
+        builder = create_asgi_app(quiet=True, include_code=True)
+        builder = builder.with_app(path="/", root=self.app1)
+        app = builder.build()
+        client = TestClient(app)
+        response = client.get("/")
+        assert response.status_code == 200
+        assert "app1.py" in response.text
+
+    def test_app_redirect(self):
+        builder = create_asgi_app(quiet=True, include_code=True)
+        builder = builder.with_app(path="/test", root=self.app1)
+        app = builder.build()
+        client = TestClient(app)
+        response = client.get("/test")
+        assert response.status_code == 200, response.text
+        assert "app1.py" in response.text
+
+    def test_multiple_apps(self):
+        builder = create_asgi_app(quiet=True, include_code=True)
+        builder = builder.with_app(path="/app1", root=self.app1)
+        builder = builder.with_app(path="/app2", root=self.app2)
+        app = builder.build()
+        client = TestClient(app)
+        response = client.get("/app1")
+        assert response.status_code == 200, response.text
+        assert "app1.py" in response.text
+        response = client.get("/app2")
+        assert response.status_code == 200, response.text
+        assert "app2.py" in response.text
+        response = client.get("/")
+        assert response.status_code == 404, response.text
+        response = client.get("/app3")
+        assert response.status_code == 404, response.text
+
+    def test_can_include_code(self):
+        builder = create_asgi_app(quiet=True, include_code=True)
+        builder = builder.with_app(path="/app1", root=self.app1)
+        app = builder.build()
+        client = TestClient(app)
+        response = client.get("/app1")
+        assert response.status_code == 200, response.text
+        assert "app1.py" in response.text
+
+    def test_can_hit_health(self):
+        builder = create_asgi_app(quiet=True, include_code=True)
+        builder = builder.with_app(path="/app1", root=self.app1)
+        app = builder.build()
+        client = TestClient(app)
+        response = client.get("/health")
+        assert response.status_code == 404, response.text
+        response = client.get("/app1/health")
+        assert response.status_code == 200, response.text


Code review comments:
diff_hunk: @@ -64,39 +66,39 @@ async def __call__(
         return await self.app(scope, receive, send)
 
 
-ALLOWED_BASE_URLS = set(["/health", "/healthz", "/metrics", "/"])
+# ALLOWED_BASE_URLS = set(["/health", "/healthz", "/metrics", "/"])
 
 
-class StripBaseURLMiddleware:
-    def __init__(self, app: ASGIApp, base_url: str) -> None:
-        self.app = app
-        self.base_url = base_url
+# class StripBaseURLMiddleware:
+#     def __init__(self, app: ASGIApp, base_url: str) -> None:
+#         self.app = app
+#         self.base_url = base_url
 
-    async def __call__(
-        self, scope: Scope, receive: Receive, send: Send
-    ) -> None:
-        # If not HTTP, skip
-        if scope["type"] != "http":
-            return await self.app(scope, receive, send)
+#     async def __call__(
+#         self, scope: Scope, receive: Receive, send: Send
+#     ) -> None:
+#         # If not HTTP, skip
+#         if scope["type"] != "http":
+#             return await self.app(scope, receive, send)
 
-        # If base URL is empty, skip
-        if self.base_url == "" or self.base_url == "/":
-            return await self.app(scope, receive, send)
+#         # If base URL is empty, skip
+#         if self.base_url == "" or self.base_url == "/":
+#             return await self.app(scope, receive, send)
 
-        request = Request(scope)
+#         request = Request(scope)
 
-        # If under common infra routes, allow
-        if request.url.path in ALLOWED_BASE_URLS:
-            return await self.app(scope, receive, send)
+#         # If under common infra routes, allow
+#         if request.url.path in ALLOWED_BASE_URLS:
+#             return await self.app(scope, receive, send)
 
-        # If not under base URL or under infra routes, return 404
-        # Otherwise, this may hide real issues
-        if not request.url.path.startswith(self.base_url):
-            response = JSONResponse({"error": "Not found"}, status_code=404)
-            return await response(scope, receive, send)
+#         # If not under base URL or under infra routes, return 404
+#         # Otherwise, this may hide real issues
+#         if not request.url.path.startswith(self.base_url):
+#             response = JSONResponse({"error": "Not found"}, status_code=404)
+#             return await response(scope, receive, send)
 
-        # Strip base URL
-        scope["path"] = scope["path"][len(self.base_url) :]
-        if not scope["path"].startswith("/"):
-            scope["path"] = "/" + scope["path"]
-        return await self.app(scope, receive, send)
+#         # Strip base URL
+#         scope["path"] = scope["path"][len(self.base_url) :]
+#         if not scope["path"].startswith("/"):
+#             scope["path"] = "/" + scope["path"]
+#         return await self.app(scope, receive, send)
path: marimo/_server/api/middleware.py
text: @user1:
Can all this dead code be deleted?