Skip to content

Commit 4d6805f

Browse files
committed
feat: live dev server for contributors
automatic re-generate and reload quick and dirty server stolen from another project of mine
1 parent 2d84b11 commit 4d6805f

3 files changed

Lines changed: 220 additions & 0 deletions

File tree

server.bat

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
python3 server.py

server.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# simple dev server: serves out/, runs generate.py on file changes, and notifies clients to reload
2+
import http.server
3+
import socketserver
4+
import threading
5+
import time
6+
import os
7+
import sys
8+
import subprocess
9+
import urllib.parse
10+
11+
PORT = 80
12+
OUT_DIR = os.path.join(os.getcwd(), 'out')
13+
14+
15+
class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
16+
daemon_threads = True
17+
allow_reuse_address = True
18+
19+
20+
clients = []
21+
clients_lock = threading.Lock()
22+
23+
def inject(html):
24+
# inject live-reload script into html
25+
inject = "<script>" \
26+
"let __liveserver = new EventSource('/__reload');" \
27+
"__liveserver.onmessage=function(){location.reload();};" \
28+
"window.addEventListener('beforeunload', function() { __liveserver.close(); });" \
29+
"</script>"
30+
if '</body>' in html.lower():
31+
idx = html.lower().rfind('</body>')
32+
return html[:idx] + inject + html[idx:]
33+
else:
34+
return html + inject
35+
36+
37+
class DevHandler(http.server.SimpleHTTPRequestHandler):
38+
def translate_path(self, path):
39+
# Serve files from the out/ directory
40+
path = path.split('?', 1)[0].split('#', 1)[0]
41+
path = urllib.parse.unquote(path)
42+
requested = path.lstrip('/')
43+
full_path = os.path.join(OUT_DIR, requested)
44+
return full_path
45+
46+
def do_GET(self):
47+
if self.path.startswith('/__reload'):
48+
self.send_response(200)
49+
self.send_header('Content-Type', 'text/event-stream')
50+
self.send_header('Cache-Control', 'no-cache')
51+
self.send_header('Connection', 'keep-alive')
52+
self.end_headers()
53+
# register client
54+
with clients_lock:
55+
clients.append(self)
56+
print('Client connected, total clients:', len(clients))
57+
try:
58+
# keep connection open
59+
while True:
60+
time.sleep(1)
61+
except Exception:
62+
print('Client connection error')
63+
pass
64+
finally:
65+
with clients_lock:
66+
if self in clients:
67+
clients.remove(self)
68+
print('Client disconnected, total clients:', len(clients))
69+
return
70+
# serve from out/, but inject reload script into html responses
71+
# resolve filesystem path for requested resource
72+
req_path = self.path.split('?', 1)[0].split('#', 1)[0]
73+
fs_path = self.translate_path(req_path)
74+
75+
# if path is a directory, try index.html
76+
if fs_path.endswith(os.path.sep) or os.path.isdir(fs_path):
77+
# If the request URL omitted the trailing slash (e.g. GET /bpe2text),
78+
# browsers resolve relative URLs incorrectly (they request /bpe2text.js).
79+
# GitHub Pages redirects to the slash. Mirror that behavior here.
80+
if os.path.isdir(fs_path):
81+
if not req_path.endswith('/'):
82+
# preserve query string if present
83+
qs = ''
84+
if '?' in self.path:
85+
qs = self.path.split('?', 1)[1]
86+
qs = '?' + qs
87+
self.send_response(301)
88+
self.send_header('Location', req_path + '/' + qs)
89+
self.end_headers()
90+
return
91+
index_path = os.path.join(fs_path, 'index.html') if os.path.isdir(fs_path) else fs_path
92+
if os.path.exists(index_path):
93+
fs_path = index_path
94+
95+
# if requested path has no extension, try adding .html
96+
if not os.path.exists(fs_path):
97+
base, ext = os.path.splitext(fs_path)
98+
if ext == '':
99+
html_candidate = fs_path + '.html'
100+
if os.path.exists(html_candidate) and os.path.isfile(html_candidate):
101+
fs_path = html_candidate
102+
103+
if os.path.exists(fs_path) and os.path.isfile(fs_path):
104+
# serve the file
105+
try:
106+
ctype = self.guess_type(fs_path)
107+
if ctype == 'text/html':
108+
with open(fs_path, 'rb') as f:
109+
data = f.read()
110+
try:
111+
text = data.decode('utf-8', errors='ignore')
112+
text = inject(text)
113+
encoded = text.encode('utf-8')
114+
self.send_response(200)
115+
self.send_header('Content-Type', 'text/html; charset=utf-8')
116+
self.send_header('Content-Length', str(len(encoded)))
117+
self.end_headers()
118+
self.wfile.write(encoded)
119+
except Exception:
120+
# fallback to binary stream
121+
with open(fs_path, 'rb') as f:
122+
self.send_response(200)
123+
self.send_header('Content-Type', ctype)
124+
fs = os.fstat(f.fileno())
125+
self.send_header('Content-Length', str(fs.st_size))
126+
self.end_headers()
127+
self.copyfile(f, self.wfile)
128+
else:
129+
with open(fs_path, 'rb') as f:
130+
self.send_response(200)
131+
self.send_header('Content-Type', ctype)
132+
fs = os.fstat(f.fileno())
133+
self.send_header('Content-Length', str(fs.st_size))
134+
self.end_headers()
135+
self.copyfile(f, self.wfile)
136+
except BrokenPipeError:
137+
return
138+
return
139+
140+
# file not found: try to serve out/404.html (like GitHub Pages)
141+
notfound_path = os.path.join(OUT_DIR, '404.html')
142+
if os.path.exists(notfound_path):
143+
try:
144+
with open(notfound_path, 'rb') as f:
145+
data = f.read()
146+
text = data.decode('utf-8', errors='ignore')
147+
text = inject(text)
148+
encoded = text.encode('utf-8')
149+
self.send_response(404)
150+
self.send_header('Content-Type', 'text/html; charset=utf-8')
151+
self.send_header('Content-Length', str(len(encoded)))
152+
self.end_headers()
153+
self.wfile.write(encoded)
154+
return
155+
except BrokenPipeError:
156+
return
157+
158+
# fallback to default 404
159+
self.send_error(404, 'File not found')
160+
161+
162+
def notify_clients():
163+
dead = []
164+
with clients_lock:
165+
for h in list(clients):
166+
try:
167+
msg = 'data: reload\n\n'
168+
h.wfile.write(msg.encode('utf-8'))
169+
h.wfile.flush()
170+
except Exception:
171+
dead.append(h)
172+
for d in dead:
173+
if d in clients:
174+
clients.remove(d)
175+
176+
177+
def snapshot_tree(root):
178+
state = {}
179+
for dirpath, dirnames, filenames in os.walk(root):
180+
for name in filenames:
181+
if dirpath.startswith(OUT_DIR):
182+
continue
183+
if dirpath.startswith(os.path.join(os.getcwd(), '.git')):
184+
continue
185+
path = os.path.join(dirpath, name)
186+
try:
187+
state[path] = os.path.getmtime(path)
188+
except OSError:
189+
state[path] = None
190+
return state
191+
192+
193+
def watch_and_build(root, interval=1.0):
194+
prev = snapshot_tree(root)
195+
while True:
196+
time.sleep(interval)
197+
curr = snapshot_tree(root)
198+
if curr != prev:
199+
try:
200+
subprocess.run([sys.executable, os.path.join(os.getcwd(), 'generate.py')], check=False)
201+
except Exception as e:
202+
print('Error running generate.py:', e)
203+
notify_clients()
204+
prev = curr
205+
206+
207+
if __name__ == '__main__':
208+
subprocess.run([sys.executable, os.path.join(os.getcwd(), 'generate.py')], check=False)
209+
print('Serving', OUT_DIR, 'on port', PORT)
210+
server = ThreadingHTTPServer(('', PORT), DevHandler)
211+
watcher = threading.Thread(target=watch_and_build, args=(os.getcwd(),), daemon=True)
212+
watcher.start()
213+
try:
214+
server.serve_forever()
215+
except KeyboardInterrupt:
216+
print('\nShutting down')
217+
server.shutdown()

server.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/bash
2+
python3 server.py

0 commit comments

Comments
 (0)