Skip to content

Commit ac2f0dc

Browse files
committed
Add multi-application support with per-mount venvs
Add support for mounting multiple WSGI/ASGI applications at different URL prefixes, each with isolated virtual environments and pythonpath. Features: - ETS-based mount registry with longest-prefix matching - Per-mount venv and pythonpath configuration - Correct SCRIPT_NAME/PATH_INFO for WSGI apps - Correct root_path/path for ASGI apps - Backward compatible with single-app mode New API: hornbeam:start(#{ mounts => [ {"/api", "api_app:app", #{worker_class => asgi, venv => Venv}}, {"/", "main:app", #{worker_class => wsgi}} ] }). Includes Docker example with FastAPI, Flask, and WSGI apps.
1 parent dae8d35 commit ac2f0dc

25 files changed

Lines changed: 2195 additions & 37 deletions

docs/guides/asgi.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ The scope dict contains request information:
4242
| `http_version` | str | `"1.1"` or `"2"` |
4343
| `method` | str | HTTP method |
4444
| `scheme` | str | `"http"` or `"https"` |
45-
| `path` | str | URL path |
45+
| `path` | str | URL path (stripped of mount prefix in [multi-app mode](/docs/guides/multi-app)) |
4646
| `query_string` | bytes | Query string |
47-
| `root_path` | str | ASGI root path |
47+
| `root_path` | str | ASGI root path (set to mount prefix in [multi-app mode](/docs/guides/multi-app)) |
4848
| `headers` | list | `[[name, value], ...]` |
4949
| `server` | tuple | `(host, port)` |
5050
| `client` | tuple | `(host, port)` or None |
@@ -402,6 +402,7 @@ async def application(scope, receive, send):
402402

403403
## Next Steps
404404

405+
- [Multi-App Guide](/docs/guides/multi-app) - Mount multiple apps at different URLs
405406
- [WebSocket Guide](/docs/guides/websocket) - Real-time communication
406407
- [Erlang Integration](/docs/guides/erlang-integration) - ETS, RPC, Pub/Sub
407408
- [FastAPI Example](/docs/examples/fastapi-app) - Complete FastAPI application

docs/guides/multi-app.md

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
---
2+
title: Multi-Application Support
3+
description: Mounting multiple WSGI/ASGI applications at different URL prefixes
4+
order: 15
5+
---
6+
7+
# Multi-Application Support
8+
9+
Hornbeam supports mounting multiple WSGI/ASGI applications at different URL prefixes, allowing you to combine multiple Python applications in a single server.
10+
11+
## Basic Usage
12+
13+
```erlang
14+
hornbeam:start(#{
15+
mounts => [
16+
{"/api", "api:app", #{worker_class => asgi}},
17+
{"/admin", "admin:app", #{worker_class => wsgi}},
18+
{"/", "frontend:app", #{worker_class => wsgi}}
19+
]
20+
}).
21+
```
22+
23+
Each mount is a tuple of `{Prefix, AppSpec, Options}`:
24+
25+
- **Prefix** - URL path prefix (must start with `/`)
26+
- **AppSpec** - Python module:callable (e.g., `"myapp:application"`)
27+
- **Options** - Per-mount options (worker_class, workers, timeout)
28+
29+
## Routing Behavior
30+
31+
Hornbeam uses **longest-prefix matching** to route requests:
32+
33+
| Request Path | Matched Mount | PATH_INFO |
34+
|--------------|---------------|-----------|
35+
| `/api/users` | `/api` | `/users` |
36+
| `/api/v2/items` | `/api` | `/v2/items` |
37+
| `/admin/dashboard` | `/admin` | `/dashboard` |
38+
| `/about` | `/` | `/about` |
39+
40+
The root mount (`/`) acts as a catch-all for unmatched paths.
41+
42+
## WSGI Path Variables
43+
44+
For WSGI applications, Hornbeam sets the standard CGI variables:
45+
46+
| Variable | Value | Example |
47+
|----------|-------|---------|
48+
| `SCRIPT_NAME` | Mount prefix | `/api` |
49+
| `PATH_INFO` | Path after prefix | `/users/123` |
50+
51+
```python
52+
# api/app.py
53+
def application(environ, start_response):
54+
script_name = environ.get('SCRIPT_NAME', '') # "/api"
55+
path_info = environ.get('PATH_INFO', '/') # "/users/123"
56+
full_path = script_name + path_info # "/api/users/123"
57+
58+
start_response('200 OK', [('Content-Type', 'text/plain')])
59+
return [f'Path: {full_path}'.encode()]
60+
```
61+
62+
## ASGI Scope Variables
63+
64+
For ASGI applications, Hornbeam sets:
65+
66+
| Variable | Value | Example |
67+
|----------|-------|---------|
68+
| `root_path` | Mount prefix | `/api` |
69+
| `path` | Path after prefix | `/users/123` |
70+
71+
```python
72+
# api/app.py
73+
async def application(scope, receive, send):
74+
root_path = scope.get('root_path', '') # "/api"
75+
path = scope.get('path', '/') # "/users/123"
76+
full_path = root_path + path # "/api/users/123"
77+
78+
await send({
79+
'type': 'http.response.start',
80+
'status': 200,
81+
'headers': [[b'content-type', b'text/plain']],
82+
})
83+
await send({
84+
'type': 'http.response.body',
85+
'body': f'Path: {full_path}'.encode(),
86+
})
87+
```
88+
89+
## Per-Mount Options
90+
91+
Each mount can have its own configuration:
92+
93+
```erlang
94+
hornbeam:start(#{
95+
mounts => [
96+
%% High-performance async API with more workers
97+
{"/api", "api:app", #{
98+
worker_class => asgi,
99+
workers => 8,
100+
timeout => 60000
101+
}},
102+
103+
%% Admin panel - fewer workers needed
104+
{"/admin", "admin:app", #{
105+
worker_class => wsgi,
106+
workers => 2,
107+
timeout => 30000
108+
}},
109+
110+
%% Static frontend
111+
{"/", "frontend:app", #{
112+
worker_class => wsgi,
113+
workers => 4
114+
}}
115+
],
116+
bind => "0.0.0.0:8000"
117+
}).
118+
```
119+
120+
### Available Mount Options
121+
122+
| Option | Type | Default | Description |
123+
|--------|------|---------|-------------|
124+
| `worker_class` | atom | `wsgi` | Protocol: `wsgi` or `asgi` |
125+
| `workers` | integer | `4` | Number of Python workers |
126+
| `timeout` | integer | `30000` | Request timeout in ms |
127+
128+
## Global Options
129+
130+
Global options apply to all mounts:
131+
132+
```erlang
133+
hornbeam:start(#{
134+
mounts => [
135+
{"/api", "api:app", #{worker_class => asgi}},
136+
{"/", "frontend:app", #{worker_class => wsgi}}
137+
],
138+
139+
%% Global options
140+
bind => "0.0.0.0:8000",
141+
pythonpath => [".", "apps"],
142+
venv => "/path/to/venv",
143+
ssl => true,
144+
certfile => "/path/to/cert.pem",
145+
keyfile => "/path/to/key.pem",
146+
147+
%% HTTP hooks apply to all mounts
148+
hooks => #{
149+
on_request => fun(Req) ->
150+
logger:info("~s ~s", [maps:get(method, Req), maps:get(path, Req)]),
151+
Req
152+
end
153+
}
154+
}).
155+
```
156+
157+
## Custom Erlang Routes
158+
159+
You can add Erlang handlers alongside Python mounts:
160+
161+
```erlang
162+
hornbeam:start(#{
163+
mounts => [
164+
{"/api", "api:app", #{worker_class => asgi}},
165+
{"/", "frontend:app", #{worker_class => wsgi}}
166+
],
167+
168+
%% Custom Cowboy routes (matched before mounts)
169+
routes => [
170+
{"/health", health_handler, #{}},
171+
{"/metrics", metrics_handler, #{}}
172+
]
173+
}).
174+
```
175+
176+
The custom routes take precedence over mounts, so `/health` will be handled by `health_handler` rather than the Python apps.
177+
178+
## Mixed WSGI/ASGI Example
179+
180+
A common pattern is mixing sync (WSGI) and async (ASGI) apps:
181+
182+
```erlang
183+
hornbeam:start(#{
184+
mounts => [
185+
%% FastAPI for real-time API
186+
{"/api/v2", "api_v2:app", #{
187+
worker_class => asgi,
188+
workers => 8
189+
}},
190+
191+
%% Legacy Flask API
192+
{"/api/v1", "api_v1:app", #{
193+
worker_class => wsgi,
194+
workers => 4
195+
}},
196+
197+
%% Django admin
198+
{"/admin", "myproject.wsgi:application", #{
199+
worker_class => wsgi,
200+
workers => 2
201+
}},
202+
203+
%% React frontend (served by Flask)
204+
{"/", "frontend:app", #{
205+
worker_class => wsgi
206+
}}
207+
]
208+
}).
209+
```
210+
211+
## Validation Rules
212+
213+
Mount prefixes must follow these rules:
214+
215+
1. **Start with `/`** - `"/api"` is valid, `"api"` is not
216+
2. **No trailing slash** (except root) - `"/api"` is valid, `"/api/"` is not
217+
3. **No duplicates** - Each prefix must be unique
218+
219+
```erlang
220+
%% Invalid - prefix doesn't start with /
221+
hornbeam:start(#{mounts => [{"api", "app:app", #{}}]}).
222+
%% => {error, {invalid_mount, {prefix_must_start_with_slash, "api"}}}
223+
224+
%% Invalid - trailing slash
225+
hornbeam:start(#{mounts => [{"/api/", "app:app", #{}}]}).
226+
%% => {error, {invalid_mount, {prefix_must_not_end_with_slash, "/api/"}}}
227+
228+
%% Invalid - duplicate prefix
229+
hornbeam:start(#{mounts => [
230+
{"/api", "app1:app", #{}},
231+
{"/api", "app2:app", #{}}
232+
]}).
233+
%% => {error, duplicate_mount_prefix}
234+
```
235+
236+
## URL Generation in Apps
237+
238+
When generating URLs in your apps, use `SCRIPT_NAME`/`root_path` to build correct absolute URLs:
239+
240+
### Flask
241+
242+
```python
243+
from flask import Flask, url_for
244+
245+
app = Flask(__name__)
246+
247+
@app.route('/users')
248+
def users():
249+
# url_for automatically includes SCRIPT_NAME
250+
return f'Users at: {url_for("users", _external=True)}'
251+
```
252+
253+
### FastAPI
254+
255+
```python
256+
from fastapi import FastAPI, Request
257+
258+
app = FastAPI(root_path="/api") # Set at startup or via scope
259+
260+
@app.get("/users")
261+
async def users(request: Request):
262+
# request.url includes root_path
263+
return {"url": str(request.url)}
264+
```
265+
266+
### Django
267+
268+
```python
269+
# settings.py
270+
FORCE_SCRIPT_NAME = '/admin' # If mounted at /admin
271+
272+
# Or dynamically from WSGI environ
273+
USE_X_FORWARDED_HOST = True
274+
```
275+
276+
## Backward Compatibility
277+
278+
Single-app mode continues to work:
279+
280+
```erlang
281+
%% These are equivalent:
282+
hornbeam:start("app:application", #{worker_class => wsgi}).
283+
284+
hornbeam:start(#{
285+
mounts => [{"/", "app:application", #{worker_class => wsgi}}]
286+
}).
287+
```
288+
289+
## Next Steps
290+
291+
- [WSGI Guide](/docs/guides/wsgi) - WSGI applications in detail
292+
- [ASGI Guide](/docs/guides/asgi) - ASGI applications in detail
293+
- [Custom Applications](/docs/guides/custom-apps) - Building custom Erlang handlers
294+
- [Configuration Reference](/docs/reference/configuration) - All configuration options

docs/guides/wsgi.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ Hornbeam provides all standard WSGI environ variables:
3232
| Variable | Description |
3333
|----------|-------------|
3434
| `REQUEST_METHOD` | HTTP method (GET, POST, etc.) |
35-
| `SCRIPT_NAME` | URL path prefix |
36-
| `PATH_INFO` | URL path |
35+
| `SCRIPT_NAME` | URL path prefix (set by [multi-app mounts](/docs/guides/multi-app)) |
36+
| `PATH_INFO` | URL path (stripped of mount prefix in multi-app mode) |
3737
| `QUERY_STRING` | Query string after `?` |
3838
| `CONTENT_TYPE` | Request content type |
3939
| `CONTENT_LENGTH` | Request body length |
@@ -270,6 +270,7 @@ hornbeam:start("app:application", #{
270270

271271
## Next Steps
272272

273+
- [Multi-App Guide](/docs/guides/multi-app) - Mount multiple apps at different URLs
273274
- [ASGI Guide](/docs/guides/asgi) - For async applications
274275
- [Erlang Integration](/docs/guides/erlang-integration) - ETS, RPC, Pub/Sub
275276
- [Flask Example](/docs/examples/flask-app) - Complete Flask application

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Hornbeam is an HTTP server and application server that combines Python's web and
1919
- [ASGI Guide](/docs/guides/asgi) - Run FastAPI, Starlette, and async apps
2020

2121
### Guides
22+
- [Multi-App Support](/docs/guides/multi-app) - Mount multiple apps at different prefixes
2223
- [Channels & Presence](/docs/guides/channels) - Real-time multiplexed channels
2324
- [WebSocket Guide](/docs/guides/websocket) - Real-time bidirectional communication
2425
- [Erlang Integration](/docs/guides/erlang-integration) - ETS, RPC, Pub/Sub

0 commit comments

Comments
 (0)