servelive 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- servelive-0.1.0/LICENSE +21 -0
- servelive-0.1.0/PKG-INFO +75 -0
- servelive-0.1.0/README.md +54 -0
- servelive-0.1.0/pyproject.toml +32 -0
- servelive-0.1.0/setup.cfg +4 -0
- servelive-0.1.0/src/servelive/__init__.py +14 -0
- servelive-0.1.0/src/servelive/__main__.py +4 -0
- servelive-0.1.0/src/servelive/_version.py +1 -0
- servelive-0.1.0/src/servelive/cli.py +98 -0
- servelive-0.1.0/src/servelive/server.py +168 -0
- servelive-0.1.0/src/servelive/watcher.py +186 -0
- servelive-0.1.0/src/servelive.egg-info/PKG-INFO +75 -0
- servelive-0.1.0/src/servelive.egg-info/SOURCES.txt +15 -0
- servelive-0.1.0/src/servelive.egg-info/dependency_links.txt +1 -0
- servelive-0.1.0/src/servelive.egg-info/entry_points.txt +2 -0
- servelive-0.1.0/src/servelive.egg-info/requires.txt +5 -0
- servelive-0.1.0/src/servelive.egg-info/top_level.txt +1 -0
servelive-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Reymart Centeno
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
servelive-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: servelive
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static file server with no-cache headers and inotify-driven live reload over SSE
|
|
5
|
+
Author-email: Reymart Centeno <reymartcenteno03@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/groovyrey/servelive
|
|
8
|
+
Classifier: Environment :: Web Environment
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Requires-Dist: build; extra == "dev"
|
|
19
|
+
Requires-Dist: twine; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# servelive
|
|
23
|
+
|
|
24
|
+
A static file server with no-cache headers and live reload, powered by Linux
|
|
25
|
+
inotify and Server-Sent Events. A drop-in replacement for `python3 -m http.server`
|
|
26
|
+
that refreshes the browser the moment a served file changes. No polling anywhere,
|
|
27
|
+
and no third-party dependencies (pure Python standard library + the system libc).
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- No-cache headers on every response so dev browsers always fetch fresh files
|
|
32
|
+
- Live reload via **inotify** (real file-change detection) + **SSE** push
|
|
33
|
+
- Serves over the network by default (`0.0.0.0`) and prints your LAN address
|
|
34
|
+
- Degrades gracefully on systems without inotify (survives, just no reload)
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install servelive
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
servelive # serve ./ on port 8000
|
|
46
|
+
servelive 8500 # serve ./ on port 8500
|
|
47
|
+
servelive 8500 ./site # serve ./site on port 8500
|
|
48
|
+
servelive --bind 127.0.0.1 9000 # localhost only
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Startup output mirrors a JS dev server:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
local: http://localhost:8000/
|
|
55
|
+
network: http://192.168.100.167:8000/
|
|
56
|
+
serving /home/you/site (live reload via inotify)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Open the `local:` or `network:` URL in a browser, edit any file under the served
|
|
60
|
+
directory, and the page reloads itself.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
1. A background thread watches the served tree with the Linux `inotify` API
|
|
65
|
+
(called through `ctypes`, stdlib only).
|
|
66
|
+
2. On a change, an 80 ms debounce coalesces the raw event burst into a single
|
|
67
|
+
notification.
|
|
68
|
+
3. The server broadcasts a reload over an SSE stream to every connected page.
|
|
69
|
+
|
|
70
|
+
The reload script is injected into served HTML, so any page you open through
|
|
71
|
+
`servelive` gets live reload automatically.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# servelive
|
|
2
|
+
|
|
3
|
+
A static file server with no-cache headers and live reload, powered by Linux
|
|
4
|
+
inotify and Server-Sent Events. A drop-in replacement for `python3 -m http.server`
|
|
5
|
+
that refreshes the browser the moment a served file changes. No polling anywhere,
|
|
6
|
+
and no third-party dependencies (pure Python standard library + the system libc).
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- No-cache headers on every response so dev browsers always fetch fresh files
|
|
11
|
+
- Live reload via **inotify** (real file-change detection) + **SSE** push
|
|
12
|
+
- Serves over the network by default (`0.0.0.0`) and prints your LAN address
|
|
13
|
+
- Degrades gracefully on systems without inotify (survives, just no reload)
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install servelive
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
servelive # serve ./ on port 8000
|
|
25
|
+
servelive 8500 # serve ./ on port 8500
|
|
26
|
+
servelive 8500 ./site # serve ./site on port 8500
|
|
27
|
+
servelive --bind 127.0.0.1 9000 # localhost only
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Startup output mirrors a JS dev server:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
local: http://localhost:8000/
|
|
34
|
+
network: http://192.168.100.167:8000/
|
|
35
|
+
serving /home/you/site (live reload via inotify)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Open the `local:` or `network:` URL in a browser, edit any file under the served
|
|
39
|
+
directory, and the page reloads itself.
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
1. A background thread watches the served tree with the Linux `inotify` API
|
|
44
|
+
(called through `ctypes`, stdlib only).
|
|
45
|
+
2. On a change, an 80 ms debounce coalesces the raw event burst into a single
|
|
46
|
+
notification.
|
|
47
|
+
3. The server broadcasts a reload over an SSE stream to every connected page.
|
|
48
|
+
|
|
49
|
+
The reload script is injected into served HTML, so any page you open through
|
|
50
|
+
`servelive` gets live reload automatically.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "servelive"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Static file server with no-cache headers and inotify-driven live reload over SSE"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Reymart Centeno", email = "reymartcenteno03@gmail.com" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Environment :: Web Environment",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
19
|
+
]
|
|
20
|
+
# No runtime dependencies: pure stdlib (ctypes inotify, http.server).
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/groovyrey/servelive"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
servelive = "servelive.cli:main"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest", "build", "twine"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""servelive - static file server with inotify-driven live reload.
|
|
2
|
+
|
|
3
|
+
A drop-in replacement for ``python3 -m http.server`` that sends
|
|
4
|
+
``Cache-Control: no-store`` so dev browsers always fetch fresh files, and
|
|
5
|
+
pushes an SSE "reload" event to connected pages whenever a served file changes.
|
|
6
|
+
File changes are detected with Linux inotify (via ctypes, no third-party
|
|
7
|
+
dependencies) so there is no polling anywhere.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from servelive.server import create_server
|
|
11
|
+
from servelive.cli import main
|
|
12
|
+
from servelive._version import __version__
|
|
13
|
+
|
|
14
|
+
__all__ = ["create_server", "main", "__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Command-line entry point for servelive."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import socket
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from servelive._version import __version__
|
|
9
|
+
from servelive.server import LiveServer
|
|
10
|
+
from servelive.watcher import is_watchable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def lan_ip():
|
|
14
|
+
"""Determine the primary non-loopback IPv4 address on this host."""
|
|
15
|
+
try:
|
|
16
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
17
|
+
try:
|
|
18
|
+
s.connect(("8.8.8.8", 80))
|
|
19
|
+
ip = s.getsockname()[0]
|
|
20
|
+
finally:
|
|
21
|
+
s.close()
|
|
22
|
+
if ip and not ip.startswith("127."):
|
|
23
|
+
return ip
|
|
24
|
+
except OSError:
|
|
25
|
+
pass
|
|
26
|
+
try:
|
|
27
|
+
out = subprocess.run(
|
|
28
|
+
["ip", "-4", "addr", "show", "scope", "global"],
|
|
29
|
+
capture_output=True, text=True,
|
|
30
|
+
).stdout
|
|
31
|
+
for line in out.splitlines():
|
|
32
|
+
line = line.strip()
|
|
33
|
+
if line.startswith("inet "):
|
|
34
|
+
ip = line.split()[1].split("/")[0]
|
|
35
|
+
if not ip.startswith("127."):
|
|
36
|
+
return ip
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_parser():
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="servelive",
|
|
45
|
+
description="Static file server with no-cache headers and inotify-driven live reload.",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"port", nargs="?", type=int, default=8000,
|
|
49
|
+
help="Port to bind (default: 8000).",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"directory", nargs="?", default=".",
|
|
53
|
+
help="Directory to serve (default: current directory).",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--bind", "-b", default="0.0.0.0",
|
|
57
|
+
help="Address to bind (default: 0.0.0.0 so other devices can connect).",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--version", action="version", version=f"servelive {__version__}",
|
|
61
|
+
)
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv=None):
|
|
66
|
+
args = build_parser().parse_args(argv)
|
|
67
|
+
|
|
68
|
+
if not (1 <= args.port <= 65535):
|
|
69
|
+
print(f"error: invalid port {args.port}", file=sys.stderr)
|
|
70
|
+
return 2
|
|
71
|
+
|
|
72
|
+
server = LiveServer(directory=args.directory, port=args.port)
|
|
73
|
+
|
|
74
|
+
watcher = "inotify" if is_watchable() else "none"
|
|
75
|
+
if watcher == "none":
|
|
76
|
+
print("warning: inotify unavailable - live reload disabled", file=sys.stderr)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
server.start()
|
|
80
|
+
except OSError as e:
|
|
81
|
+
print(f"error: could not bind {args.bind}:{args.port} ({e})", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
live = "live reload via inotify" if watcher == "inotify" else "live reload disabled"
|
|
85
|
+
local_host = "127.0.0.1" if args.bind not in ("0.0.0.0", "") else "localhost"
|
|
86
|
+
print(f" local: http://{local_host}:{args.port}/", flush=True)
|
|
87
|
+
if args.bind in ("0.0.0.0", ""):
|
|
88
|
+
nip = lan_ip()
|
|
89
|
+
if nip:
|
|
90
|
+
print(f" network: http://{nip}:{args.port}/", flush=True)
|
|
91
|
+
print(f" serving {server.directory} ({live})", flush=True)
|
|
92
|
+
|
|
93
|
+
server.serve_forever()
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""The static HTTP server with no-cache headers and SSE live reload."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import http.server
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import socketserver
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
from servelive.watcher import is_watchable, Watcher
|
|
11
|
+
|
|
12
|
+
LIVE_RELOAD = b"""<script>
|
|
13
|
+
(function () {
|
|
14
|
+
try {
|
|
15
|
+
var es = new EventSource(location.origin + "/__servelive_reload", { withCredentials: false });
|
|
16
|
+
es.onmessage = function (e) {
|
|
17
|
+
if (e.data === "reload") location.reload();
|
|
18
|
+
};
|
|
19
|
+
es.onerror = function () { es.close(); };
|
|
20
|
+
} catch (e) {}
|
|
21
|
+
})();
|
|
22
|
+
</script>
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
RELOAD_PATH = "/__servelive_reload"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReloadHub:
|
|
29
|
+
"""Fan-out reload notifications to connected SSE clients."""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self._clients = []
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
def add(self, event):
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._clients.append(event)
|
|
38
|
+
return self._remove_client
|
|
39
|
+
|
|
40
|
+
def _remove_client(self, event):
|
|
41
|
+
with self._lock:
|
|
42
|
+
if event in self._clients:
|
|
43
|
+
self._clients.remove(event)
|
|
44
|
+
|
|
45
|
+
def broadcast(self):
|
|
46
|
+
with self._lock:
|
|
47
|
+
clients = list(self._clients)
|
|
48
|
+
for ev in clients:
|
|
49
|
+
ev.set()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _SSEEvent(threading.Event):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sse_body(hub):
|
|
57
|
+
yield b"retry: 1000\n\n"
|
|
58
|
+
ev = _SSEEvent()
|
|
59
|
+
detach = hub.add(ev)
|
|
60
|
+
try:
|
|
61
|
+
while True:
|
|
62
|
+
if ev.wait(25):
|
|
63
|
+
ev.clear()
|
|
64
|
+
yield b"data: reload\n\n"
|
|
65
|
+
finally:
|
|
66
|
+
detach()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NoCacheHandler(http.server.SimpleHTTPRequestHandler):
|
|
70
|
+
server_version = "servelive/1.1"
|
|
71
|
+
|
|
72
|
+
def end_headers(self):
|
|
73
|
+
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
|
74
|
+
self.send_header("Pragma", "no-cache")
|
|
75
|
+
self.send_header("Expires", "0")
|
|
76
|
+
super().end_headers()
|
|
77
|
+
|
|
78
|
+
def do_GET(self):
|
|
79
|
+
if self.path.rstrip("/").endswith(RELOAD_PATH):
|
|
80
|
+
try:
|
|
81
|
+
self.send_response(200)
|
|
82
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
83
|
+
self.send_header("Cache-Control", "no-cache")
|
|
84
|
+
self.send_header("Connection", "keep-alive")
|
|
85
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
86
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
87
|
+
self.end_headers()
|
|
88
|
+
for chunk in sse_body(self.server.hub):
|
|
89
|
+
try:
|
|
90
|
+
self.wfile.write(chunk)
|
|
91
|
+
self.wfile.flush()
|
|
92
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
93
|
+
break
|
|
94
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
95
|
+
pass
|
|
96
|
+
return
|
|
97
|
+
return super().do_GET()
|
|
98
|
+
|
|
99
|
+
def send_head(self):
|
|
100
|
+
path = self.translate_path(self.path)
|
|
101
|
+
if os.path.isdir(path):
|
|
102
|
+
path = os.path.join(path, "index.html")
|
|
103
|
+
ctype = self.guess_type(path)
|
|
104
|
+
if ctype == "text/html" and os.path.exists(path):
|
|
105
|
+
try:
|
|
106
|
+
with open(path, "rb") as f:
|
|
107
|
+
html = f.read()
|
|
108
|
+
html = inject_reload_script(html)
|
|
109
|
+
self.send_response(200)
|
|
110
|
+
self.send_header("Content-Type", ctype or "text/html")
|
|
111
|
+
self.send_header("Content-Length", str(len(html)))
|
|
112
|
+
self.send_header("Last-Modified", self.date_time_string())
|
|
113
|
+
self.end_headers()
|
|
114
|
+
return io.BytesIO(html)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
return super().send_head()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def inject_reload_script(html):
|
|
121
|
+
if b"</body>" in html:
|
|
122
|
+
return html.replace(b"</body>", LIVE_RELOAD + b"</body>")
|
|
123
|
+
return html + LIVE_RELOAD
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
127
|
+
daemon_threads = True
|
|
128
|
+
allow_reuse_address = True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class LiveServer:
|
|
132
|
+
"""Convenience wrapper combining the HTTP server and the watcher."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, directory=".", port=8000):
|
|
135
|
+
self.directory = os.path.abspath(directory)
|
|
136
|
+
self.port = int(port)
|
|
137
|
+
self.hub = ReloadHub()
|
|
138
|
+
self.watcher = None
|
|
139
|
+
self.httpd = None
|
|
140
|
+
|
|
141
|
+
def start(self):
|
|
142
|
+
handler = functools.partial(NoCacheHandler, directory=self.directory)
|
|
143
|
+
self.httpd = ThreadingHTTPServer(("", self.port), handler)
|
|
144
|
+
self.httpd.hub = self.hub
|
|
145
|
+
if is_watchable():
|
|
146
|
+
self.watcher = Watcher(self.directory, self.hub.broadcast).start()
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def serve_forever(self):
|
|
150
|
+
if self.httpd is None:
|
|
151
|
+
self.start()
|
|
152
|
+
try:
|
|
153
|
+
self.httpd.serve_forever()
|
|
154
|
+
except KeyboardInterrupt:
|
|
155
|
+
pass
|
|
156
|
+
finally:
|
|
157
|
+
self.close()
|
|
158
|
+
|
|
159
|
+
def close(self):
|
|
160
|
+
if self.watcher is not None:
|
|
161
|
+
self.watcher.close()
|
|
162
|
+
if self.httpd is not None:
|
|
163
|
+
self.httpd.server_close()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def create_server(directory=".", port=8000):
|
|
167
|
+
"""Create (but do not start) a LiveServer."""
|
|
168
|
+
return LiveServer(directory=directory, port=port)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""inotify-backed change detection.
|
|
2
|
+
|
|
3
|
+
Watches a directory tree with the Linux inotify API through ctypes (stdlib
|
|
4
|
+
only). Calls a callback once per debounced change burst so consumers get a
|
|
5
|
+
single notification for what would otherwise be a burst of raw inotify events.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ctypes
|
|
9
|
+
import ctypes.util
|
|
10
|
+
import errno
|
|
11
|
+
import os
|
|
12
|
+
import select
|
|
13
|
+
import struct
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
_libc_path = ctypes.util.find_library("c") or (
|
|
19
|
+
"/lib/libc.so.6" if os.path.exists("/lib/libc.so.6") else "libc.so.6"
|
|
20
|
+
)
|
|
21
|
+
libc = ctypes.CDLL(_libc_path, use_errno=True)
|
|
22
|
+
INOTIFY_OK = True
|
|
23
|
+
except Exception: # pragma: no cover - platform without libc
|
|
24
|
+
libc = None
|
|
25
|
+
INOTIFY_OK = False
|
|
26
|
+
|
|
27
|
+
IN_ATTRIB = 0x00000002
|
|
28
|
+
IN_CREATE = 0x00000100
|
|
29
|
+
IN_DELETE = 0x00000200
|
|
30
|
+
IN_MODIFY = 0x00000002
|
|
31
|
+
IN_MOVED_FROM = 0x00000040
|
|
32
|
+
IN_MOVED_TO = 0x00000080
|
|
33
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
34
|
+
|
|
35
|
+
_WATCH_MASK = (
|
|
36
|
+
IN_ATTRIB
|
|
37
|
+
| IN_CREATE
|
|
38
|
+
| IN_DELETE
|
|
39
|
+
| IN_MODIFY
|
|
40
|
+
| IN_MOVED_FROM
|
|
41
|
+
| IN_MOVED_TO
|
|
42
|
+
| IN_CLOSE_WRITE
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_SIZE_OF_INOTIFY_EVENT = 16
|
|
46
|
+
_NAME_MAX = 4096
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _setup_libc():
|
|
50
|
+
if not INOTIFY_OK:
|
|
51
|
+
return
|
|
52
|
+
libc.inotify_init.restype = ctypes.c_int
|
|
53
|
+
libc.inotify_add_watch.restype = ctypes.c_int
|
|
54
|
+
libc.inotify_add_watch.argtypes = [
|
|
55
|
+
ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32,
|
|
56
|
+
]
|
|
57
|
+
libc.inotify_rm_watch.argtypes = [ctypes.c_int, ctypes.c_int]
|
|
58
|
+
libc.read.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_size_t]
|
|
59
|
+
libc.read.restype = ctypes.c_ssize_t
|
|
60
|
+
libc.close.argtypes = [ctypes.c_int]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_setup_libc()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_watchable():
|
|
67
|
+
"""Return True if inotify is available on this system."""
|
|
68
|
+
return INOTIFY_OK
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Watcher:
|
|
72
|
+
"""Watch a directory tree and call ``on_change`` per debounced change."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, root, on_change, debounce_ms=80, poll_ms=250):
|
|
75
|
+
self.root = root
|
|
76
|
+
self.on_change = on_change
|
|
77
|
+
self.debounce_ms = debounce_ms / 1000.0
|
|
78
|
+
self.poll_ms = poll_ms
|
|
79
|
+
self._fd = None
|
|
80
|
+
self._wd_to_path = {}
|
|
81
|
+
|
|
82
|
+
def start(self):
|
|
83
|
+
"""Start watching. Returns self, or None if inotify is unavailable."""
|
|
84
|
+
if not INOTIFY_OK:
|
|
85
|
+
return None
|
|
86
|
+
fd = libc.inotify_init()
|
|
87
|
+
if fd < 0:
|
|
88
|
+
return None
|
|
89
|
+
self._fd = fd
|
|
90
|
+
|
|
91
|
+
mask = _WATCH_MASK
|
|
92
|
+
poll = select.poll()
|
|
93
|
+
poll.register(fd, select.POLLIN)
|
|
94
|
+
buf = ctypes.create_string_buffer(_NAME_MAX * 4)
|
|
95
|
+
|
|
96
|
+
def add_watch(path):
|
|
97
|
+
wd = libc.inotify_add_watch(fd, os.fsencode(path), mask)
|
|
98
|
+
if wd >= 0:
|
|
99
|
+
self._wd_to_path[wd] = path
|
|
100
|
+
return wd
|
|
101
|
+
|
|
102
|
+
def add_tree(base):
|
|
103
|
+
for dirpath, dirnames, _ in os.walk(base):
|
|
104
|
+
for d in list(dirnames):
|
|
105
|
+
try:
|
|
106
|
+
add_watch(os.path.join(dirpath, d))
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
root_wd = add_watch(self.root)
|
|
111
|
+
if root_wd < 0:
|
|
112
|
+
libc.close(fd)
|
|
113
|
+
self._fd = None
|
|
114
|
+
return None
|
|
115
|
+
add_tree(self.root)
|
|
116
|
+
|
|
117
|
+
pending = {"flag": False}
|
|
118
|
+
lock = threading.Lock()
|
|
119
|
+
debounce = threading.Event()
|
|
120
|
+
|
|
121
|
+
def mark_changed():
|
|
122
|
+
with lock:
|
|
123
|
+
pending["flag"] = True
|
|
124
|
+
debounce.set()
|
|
125
|
+
|
|
126
|
+
def broadcaster():
|
|
127
|
+
while True:
|
|
128
|
+
debounce.wait()
|
|
129
|
+
debounce.clear()
|
|
130
|
+
time.sleep(self.debounce_ms)
|
|
131
|
+
with lock:
|
|
132
|
+
if not pending["flag"]:
|
|
133
|
+
continue
|
|
134
|
+
pending["flag"] = False
|
|
135
|
+
self.on_change()
|
|
136
|
+
|
|
137
|
+
threading.Thread(target=broadcaster, daemon=True).start()
|
|
138
|
+
|
|
139
|
+
def loop():
|
|
140
|
+
while True:
|
|
141
|
+
if not poll.poll(self.poll_ms):
|
|
142
|
+
continue
|
|
143
|
+
while True:
|
|
144
|
+
n = libc.read(fd, buf, len(buf))
|
|
145
|
+
if n < 0:
|
|
146
|
+
err = ctypes.get_errno()
|
|
147
|
+
if err in (errno.EAGAIN, errno.EINTR):
|
|
148
|
+
break
|
|
149
|
+
continue
|
|
150
|
+
if n == 0:
|
|
151
|
+
break
|
|
152
|
+
off = 0
|
|
153
|
+
while off < n:
|
|
154
|
+
wd, maskv, _cookie, name_len = struct.unpack_from(
|
|
155
|
+
"iIII", buf, off
|
|
156
|
+
)
|
|
157
|
+
off += _SIZE_OF_INOTIFY_EVENT
|
|
158
|
+
name = (
|
|
159
|
+
buf.raw[off:off + name_len]
|
|
160
|
+
.split(b"\x00", 1)[0]
|
|
161
|
+
.decode("utf-8", "replace")
|
|
162
|
+
if name_len
|
|
163
|
+
else ""
|
|
164
|
+
)
|
|
165
|
+
off += name_len
|
|
166
|
+
dirname = self._wd_to_path.get(wd, self.root)
|
|
167
|
+
if (maskv & (IN_CREATE | IN_MOVED_TO)) and name:
|
|
168
|
+
newpath = os.path.join(dirname, name)
|
|
169
|
+
if os.path.isdir(newpath):
|
|
170
|
+
try:
|
|
171
|
+
add_watch(newpath)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
mark_changed()
|
|
175
|
+
|
|
176
|
+
threading.Thread(target=loop, daemon=True).start()
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def close(self):
|
|
180
|
+
"""Stop watching and release the inotify fd."""
|
|
181
|
+
if self._fd is not None:
|
|
182
|
+
try:
|
|
183
|
+
libc.close(self._fd)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
self._fd = None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: servelive
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static file server with no-cache headers and inotify-driven live reload over SSE
|
|
5
|
+
Author-email: Reymart Centeno <reymartcenteno03@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/groovyrey/servelive
|
|
8
|
+
Classifier: Environment :: Web Environment
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Requires-Dist: build; extra == "dev"
|
|
19
|
+
Requires-Dist: twine; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# servelive
|
|
23
|
+
|
|
24
|
+
A static file server with no-cache headers and live reload, powered by Linux
|
|
25
|
+
inotify and Server-Sent Events. A drop-in replacement for `python3 -m http.server`
|
|
26
|
+
that refreshes the browser the moment a served file changes. No polling anywhere,
|
|
27
|
+
and no third-party dependencies (pure Python standard library + the system libc).
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- No-cache headers on every response so dev browsers always fetch fresh files
|
|
32
|
+
- Live reload via **inotify** (real file-change detection) + **SSE** push
|
|
33
|
+
- Serves over the network by default (`0.0.0.0`) and prints your LAN address
|
|
34
|
+
- Degrades gracefully on systems without inotify (survives, just no reload)
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install servelive
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
servelive # serve ./ on port 8000
|
|
46
|
+
servelive 8500 # serve ./ on port 8500
|
|
47
|
+
servelive 8500 ./site # serve ./site on port 8500
|
|
48
|
+
servelive --bind 127.0.0.1 9000 # localhost only
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Startup output mirrors a JS dev server:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
local: http://localhost:8000/
|
|
55
|
+
network: http://192.168.100.167:8000/
|
|
56
|
+
serving /home/you/site (live reload via inotify)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Open the `local:` or `network:` URL in a browser, edit any file under the served
|
|
60
|
+
directory, and the page reloads itself.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
1. A background thread watches the served tree with the Linux `inotify` API
|
|
65
|
+
(called through `ctypes`, stdlib only).
|
|
66
|
+
2. On a change, an 80 ms debounce coalesces the raw event burst into a single
|
|
67
|
+
notification.
|
|
68
|
+
3. The server broadcasts a reload over an SSE stream to every connected page.
|
|
69
|
+
|
|
70
|
+
The reload script is injected into served HTML, so any page you open through
|
|
71
|
+
`servelive` gets live reload automatically.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/servelive/__init__.py
|
|
5
|
+
src/servelive/__main__.py
|
|
6
|
+
src/servelive/_version.py
|
|
7
|
+
src/servelive/cli.py
|
|
8
|
+
src/servelive/server.py
|
|
9
|
+
src/servelive/watcher.py
|
|
10
|
+
src/servelive.egg-info/PKG-INFO
|
|
11
|
+
src/servelive.egg-info/SOURCES.txt
|
|
12
|
+
src/servelive.egg-info/dependency_links.txt
|
|
13
|
+
src/servelive.egg-info/entry_points.txt
|
|
14
|
+
src/servelive.egg-info/requires.txt
|
|
15
|
+
src/servelive.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
servelive
|