Skip to content

Commit dd03576

Browse files
committed
fix(serve): make uvicorn WebSocket backend configurable with safe default
The serve command hardcoded ws="websockets-sansio" in the uvicorn.run kwargs. Uvicorn imports that backend at startup, so `artisan serve` crashed with ModuleNotFoundError: No module named 'websockets' on apps that never use WebSockets and don't have the optional package installed. Replace the hardcoded value with a --ws option (auto, none, websockets, websockets-sansio, wsproto) resolved as CLI flag > fastapi config > default. The default is 'auto', which uvicorn resolves lazily and never requires the websockets package unless a WebSocket connection is opened. Invalid values report a clear error and exit non-zero instead of raising a stack trace. A matching fastapi.ws config field (env APP_WS) is added.
1 parent 42463a0 commit dd03576

4 files changed

Lines changed: 83 additions & 3 deletions

File tree

fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ class ServeCommand(Command):
1010
name = "serve"
1111
description = "Start the FastAPI server."
1212

13+
# WebSocket backends accepted by uvicorn's ``--ws`` option. ``auto`` is the
14+
# safe default: uvicorn only imports a concrete backend when a WebSocket
15+
# connection is actually opened, so serving never requires the optional
16+
# ``websockets`` package for apps that don't use WebSockets.
17+
WS_BACKENDS = ("auto", "none", "websockets", "websockets-sansio", "wsproto")
18+
DEFAULT_WS_BACKEND = "auto"
19+
1320
options = [
1421
option(
1522
"port",
@@ -39,6 +46,17 @@ class ServeCommand(Command):
3946
default="bootstrap.application:app",
4047
description="The application to serve",
4148
),
49+
option(
50+
"ws",
51+
None,
52+
flag=False,
53+
default=None,
54+
description=(
55+
"WebSocket backend passed to uvicorn: "
56+
"auto, none, websockets, websockets-sansio, wsproto. "
57+
"Defaults to 'auto' (overrides fastapi config)"
58+
),
59+
),
4260
]
4361

4462
def resolve_option(self, key: str, default: str | int | None = None):
@@ -57,12 +75,21 @@ def resolve_url(self) -> Uriable:
5775

5876
return uri.with_port(port) if port else uri
5977

78+
def resolve_ws(self) -> str:
79+
"""Select the uvicorn WebSocket backend: CLI flag > config > safe default."""
80+
return self.option("ws") or Config.get("fastapi.ws") or self.DEFAULT_WS_BACKEND
81+
6082
def handle(self):
6183
import uvicorn
6284

6385
from fastapi_startkit import Config
6486
from fastapi_startkit.container import Container
6587

88+
ws = self.resolve_ws()
89+
if ws not in self.WS_BACKENDS:
90+
self.line(f"<error>Invalid --ws backend '{ws}'. Allowed values: {', '.join(self.WS_BACKENDS)}.</error>")
91+
return 1
92+
6693
# Resolve server settings: CLI flag > fastapi config > uvicorn default (None)
6794
cfg_reload_dirs = Config.get("fastapi.reload_dirs") or None
6895
cfg_reload_excludes = Config.get("fastapi.reload_excludes") or None
@@ -74,7 +101,7 @@ def handle(self):
74101
"host": url.host(),
75102
"port": url.port(),
76103
"reload": reload,
77-
"ws": "websockets-sansio",
104+
"ws": ws,
78105
}
79106

80107
if self.is_app_exist():

fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
class FastAPIConfig:
88
app_url: str = dataclasses.field(default_factory=lambda: env("APP_URL", "http://127.0.0.1:8000"))
99
reload: bool = dataclasses.field(default_factory=lambda: env("APP_RELOAD", True))
10+
# uvicorn WebSocket backend: auto, none, websockets, websockets-sansio, wsproto.
11+
# 'auto' never requires the optional 'websockets' package unless a WS connection is opened.
12+
ws: str = dataclasses.field(default_factory=lambda: env("APP_WS", "auto"))
1013
reload_dirs: list | None = None
1114
reload_excludes: list = dataclasses.field(
1215
default_factory=lambda: [

fastapi_startkit/tests/fastapi/test_serve_command.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,61 @@ def test_default_port_in_output(self):
8585
tester, _ = run()
8686
assert str(_DEFAULT_PORT) in tester.io.fetch_output()
8787

88-
def test_uvicorn_kwargs_contain_ws(self):
88+
def test_uvicorn_kwargs_default_ws_is_auto(self):
89+
"""The default WebSocket backend must be the safe 'auto', never
90+
'websockets-sansio' — otherwise serve crashes when the optional
91+
'websockets' package is not installed.
92+
"""
8993
_, mock_uvicorn = run()
9094
_, kwargs = mock_uvicorn.call_args
95+
assert kwargs.get("ws") == "auto"
96+
97+
98+
# ---------------------------------------------------------------------------
99+
# 6. --ws option — WebSocket backend selection
100+
# ---------------------------------------------------------------------------
101+
102+
103+
class TestWsOption:
104+
def test_ws_flag_passed_to_uvicorn(self):
105+
_, mock_uvicorn = run("--ws websockets")
106+
_, kwargs = mock_uvicorn.call_args
107+
assert kwargs.get("ws") == "websockets"
108+
109+
def test_ws_websockets_sansio_opt_in(self):
110+
_, mock_uvicorn = run("--ws websockets-sansio")
111+
_, kwargs = mock_uvicorn.call_args
91112
assert kwargs.get("ws") == "websockets-sansio"
92113

114+
def test_ws_none_backend(self):
115+
_, mock_uvicorn = run("--ws none")
116+
_, kwargs = mock_uvicorn.call_args
117+
assert kwargs.get("ws") == "none"
118+
119+
def test_ws_config_used_when_no_cli_flag(self):
120+
_, mock_uvicorn = run(config={"fastapi.ws": "wsproto"})
121+
_, kwargs = mock_uvicorn.call_args
122+
assert kwargs.get("ws") == "wsproto"
123+
124+
def test_cli_flag_overrides_config(self):
125+
_, mock_uvicorn = run("--ws auto", config={"fastapi.ws": "wsproto"})
126+
_, kwargs = mock_uvicorn.call_args
127+
assert kwargs.get("ws") == "auto"
128+
129+
def test_invalid_ws_exits_nonzero(self):
130+
tester, _ = run("--ws bogus")
131+
assert tester.status_code == 1
132+
133+
def test_invalid_ws_does_not_call_uvicorn(self):
134+
_, mock_uvicorn = run("--ws bogus")
135+
mock_uvicorn.assert_not_called()
136+
137+
def test_invalid_ws_reports_allowed_values(self):
138+
tester, _ = run("--ws bogus")
139+
output = tester.io.fetch_output() + tester.io.fetch_error()
140+
assert "bogus" in output
141+
assert "websockets-sansio" in output
142+
93143

94144
# ---------------------------------------------------------------------------
95145
# 2. CLI --host / --port override defaults

fastapi_startkit/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)