Skip to content

Commit 0c5637f

Browse files
committed
2 parents 1a80126 + 52d8913 commit 0c5637f

File tree

8 files changed

+143
-15
lines changed

8 files changed

+143
-15
lines changed

fasthtml/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
from .authmw import *
44
from .components import *
55
from .xtend import *
6+
from .live_reload import *

fasthtml/_modidx.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
'fasthtml.core': {},
2020
'fasthtml.fastapp': {},
2121
'fasthtml.js': {},
22+
'fasthtml.live_reload': {},
2223
'fasthtml.oauth': { 'fasthtml.oauth.GitHubAppClient': ('oauth.html#githubappclient', 'fasthtml/oauth.py'),
2324
'fasthtml.oauth.GitHubAppClient.__init__': ('oauth.html#githubappclient.__init__', 'fasthtml/oauth.py'),
2425
'fasthtml.oauth.GoogleAppClient': ('oauth.html#googleappclient', 'fasthtml/oauth.py'),

fasthtml/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ def __init__(self, routes=None, redirect_slashes=True, default=None, on_startup=
202202
def add_route( self, path: str, endpoint: callable, methods=None, name=None, include_in_schema=True):
203203
route = RouteX(path, endpoint=endpoint, methods=methods, name=name, include_in_schema=include_in_schema,
204204
hdrs=self.hdrs, before=self.before, **self.bodykw)
205-
self.routes = [o for o in self.routes if o.methods!=methods or o.path!=path]
205+
self.routes = [o for o in self.routes if getattr(o,'methods',None)!=methods or o.path!=path]
206206
self.routes.append(route)
207207

208208
htmxscr = Script(

fasthtml/live_reload.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from starlette.routing import WebSocketRoute
2+
from fasthtml import FastHTML, Script
3+
4+
__all__ = ["FastHTMLWithLiveReload"]
5+
6+
7+
LIVE_RELOAD_SCRIPT = """
8+
(function() {
9+
var socket = new WebSocket(`ws://${window.location.host}/live-reload`);
10+
var maxReloadAttempts = 20;
11+
var reloadInterval = 250; // time between reload attempts in ms
12+
socket.onclose = function() {
13+
let reloadAttempts = 0;
14+
const intervalFn = setInterval(function(){
15+
window.location.reload();
16+
reloadCount++;
17+
if (reloadAttempts === maxReloadAttempts) {
18+
clearInterval(intervalFn);
19+
};
20+
}, reloadInterval);
21+
}
22+
})();
23+
"""
24+
25+
26+
async def live_reload_websocket(websocket):
27+
await websocket.accept()
28+
29+
30+
class FastHTMLWithLiveReload(FastHTML):
31+
"""
32+
`FastHTMLWithLiveReload` enables live reloading.
33+
This means that any code changes saved on the server will automatically
34+
trigger a reload of both the server and browser window.
35+
36+
How does it work?
37+
- a websocket is creaetd at `/live-reload`
38+
- a small js snippet `LIVE_RELOAD_SCRIPT` is injected into each webpage
39+
- this snippet connects to the websocket at `/live-reload` and listens for an `onclose` event
40+
- when the onclose event is detected the browser is reloaded
41+
42+
Why do we listen for an `onclose` event?
43+
When code changes are saved the server automatically reloads if the --reload flag is set.
44+
The server reload kills the websocket connection. The `onclose` event serves as a proxy
45+
for "developer has saved some changes".
46+
47+
Usage
48+
>>> from fasthtml.all import *
49+
>>> app = FastHTMLWithLiveReload()
50+
51+
Run:
52+
uvicorn main:app --reload
53+
"""
54+
LIVE_RELOAD_HEADER = Script(f'{LIVE_RELOAD_SCRIPT}')
55+
LIVE_RELOAD_ROUTE = WebSocketRoute("/live-reload", endpoint=live_reload_websocket)
56+
57+
def __init__(self, *args, **kwargs):
58+
# "hdrs" and "routes" can be missing, None, a list or a tuple.
59+
kwargs["hdrs"] = [*(kwargs.get("hdrs") or []), self.LIVE_RELOAD_HEADER]
60+
kwargs["routes"] = [*(kwargs.get("routes") or []), self.LIVE_RELOAD_ROUTE]
61+
super().__init__(*args, **kwargs)
62+

fasthtml/starlette.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from starlette.exceptions import HTTPException
1212
from starlette._utils import is_async_callable
1313
from starlette.convertors import Convertor, StringConvertor, register_url_convertor, CONVERTOR_TYPES
14-
from starlette.routing import Route, Router, Mount
14+
from starlette.routing import Route, Router, Mount, WebSocketRoute
1515
from starlette.exceptions import HTTPException,WebSocketException
1616
from starlette.endpoints import HTTPEndpoint,WebSocketEndpoint
1717
from starlette.config import Config

nbs/core_tests.ipynb

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,17 @@
116116
"metadata": {},
117117
"outputs": [],
118118
"source": [
119-
"app = FastHTML(secret_key='soopersecret')\n",
120-
"cli = TestClient(app)\n",
121-
"rt = app.route"
119+
"def get_cli(app): return app,TestClient(app),app.route"
120+
]
121+
},
122+
{
123+
"cell_type": "code",
124+
"execution_count": null,
125+
"id": "4e3d0e99",
126+
"metadata": {},
127+
"outputs": [],
128+
"source": [
129+
"app,cli,rt = get_cli(FastHTML(secret_key='soopersecret'))"
122130
]
123131
},
124132
{
@@ -421,13 +429,13 @@
421429
"name": "stdout",
422430
"output_type": "stream",
423431
"text": [
424-
"Set to 2024-06-09 13:25:46.659017\n"
432+
"Set to 2024-06-12 08:11:01.624854\n"
425433
]
426434
},
427435
{
428436
"data": {
429437
"text/plain": [
430-
"'Cookie was set at time 13:25:46.659017'"
438+
"'Cookie was set at time 08:11:01.624854'"
431439
]
432440
},
433441
"execution_count": null,
@@ -468,13 +476,13 @@
468476
"name": "stdout",
469477
"output_type": "stream",
470478
"text": [
471-
"Set to 2024-06-09 13:25:46.705099\n"
479+
"Set to 2024-06-12 08:11:03.878051\n"
472480
]
473481
},
474482
{
475483
"data": {
476484
"text/plain": [
477-
"'Session time: 13:25:46.705099'"
485+
"'Session time: 08:11:03.878051'"
478486
]
479487
},
480488
"execution_count": null,
@@ -502,11 +510,11 @@
502510
"\n",
503511
"<!-- do not remove -->\n",
504512
"\n",
505-
"## 0.0.9\n",
513+
"## 0.0.10\n",
506514
"\n",
507515
"### New Features\n",
508516
"\n",
509-
"- Module `_\n"
517+
"- sortable\n"
510518
]
511519
}
512520
],
@@ -528,16 +536,46 @@
528536
"outputs": [],
529537
"source": [
530538
"auth = user_pwd_auth(testuser='spycraft')\n",
531-
"app = FastHTML(middleware=[auth])\n",
532-
"cli = TestClient(app)\n",
539+
"app,cli,rt = get_cli(FastHTML(middleware=[auth]))\n",
533540
"\n",
534-
"@app.route(\"/locked\")\n",
541+
"@rt(\"/locked\")\n",
535542
"def get(auth): return 'Hello, ' + auth\n",
536543
"\n",
537544
"test_eq(cli.get('/locked').text, 'not authenticated')\n",
538545
"test_eq(cli.get('/locked', auth=(\"testuser\",\"spycraft\")).text, 'Hello, testuser')"
539546
]
540547
},
548+
{
549+
"cell_type": "code",
550+
"execution_count": null,
551+
"id": "97f2553c",
552+
"metadata": {},
553+
"outputs": [],
554+
"source": [
555+
"hdrs, routes = app.router.hdrs, app.routes"
556+
]
557+
},
558+
{
559+
"cell_type": "code",
560+
"execution_count": null,
561+
"id": "fa194d53",
562+
"metadata": {},
563+
"outputs": [],
564+
"source": [
565+
"app,cli,rt = get_cli(FastHTMLWithLiveReload())\n",
566+
"\n",
567+
"@rt(\"/hi\")\n",
568+
"def get(): return 'Hi there'\n",
569+
"\n",
570+
"test_eq(cli.get('/hi').text, \"Hi there\")\n",
571+
"\n",
572+
"lr_hdrs, lr_routes = app.router.hdrs, app.routes\n",
573+
"test_eq(len(lr_hdrs), len(hdrs)+1)\n",
574+
"assert app.LIVE_RELOAD_HEADER in lr_hdrs\n",
575+
"test_eq(len(lr_routes), len(routes)+1)\n",
576+
"assert app.LIVE_RELOAD_ROUTE in lr_routes"
577+
]
578+
},
541579
{
542580
"cell_type": "markdown",
543581
"id": "ff470ef6",

nbs/index.ipynb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,32 @@
707707
"cli.get('/hxtest', headers={'HX-Request':'1'}).text"
708708
]
709709
},
710+
{
711+
"cell_type": "markdown",
712+
"metadata": {},
713+
"source": [
714+
"### Live Reloading\n",
715+
"When building your app it can be useful to view your changes in a web browser as you make them. FastHTML supports live reloading which means that it watches for any changes to your code and automatically refreshes the webpage in your browser.\n",
716+
"\n",
717+
"To enable live reloading simply replace `FastHTML` in your app with `FastHTMLWithLiveReload`.\n",
718+
"\n",
719+
"```python\n",
720+
"from fasthtml.all import *\n",
721+
"app = FastHTMLWithLiveReload()\n",
722+
"```\n",
723+
"\n",
724+
"Then in your terminal run `uvicorn` with reloading enabled.\n",
725+
"\n",
726+
"```\n",
727+
"uvicorn: main:app --reload\n",
728+
"```\n",
729+
"\n",
730+
"**⚠️ Gotchas**\n",
731+
"- A reload is only triggered when you save your changes.\n",
732+
"- `FastHTMLWithLiveReload` should only be used during development.\n",
733+
"- If your app spans multiple directories you might need to use the `--reload-dir` flag to watch all files in each directory. See the uvicorn [docs](https://www.uvicorn.org/settings/#development) for more info."
734+
]
735+
},
710736
{
711737
"cell_type": "markdown",
712738
"metadata": {},

settings.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ lib_name = fasthtml
44
version = 0.0.11
55
min_python = 3.10
66
license = apache2
7-
requirements = fastcore>=1.5.45 python-dateutil starlette oauthlib itsdangerous uvicorn httpx fastlite>=0.0.6 python-multipart
7+
requirements = fastcore>=1.5.45 python-dateutil starlette oauthlib itsdangerous uvicorn[standard] httpx fastlite>=0.0.6 python-multipart
88
dev_requirements = ipython lxml
99
black_formatting = False
1010
conda_user = fastai

0 commit comments

Comments
 (0)