pywire 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl
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.
- pywire/__init__.py +2 -0
- pywire/cli/__init__.py +1 -0
- pywire/cli/generators.py +48 -0
- pywire/cli/main.py +309 -0
- pywire/cli/tui.py +563 -0
- pywire/cli/validate.py +26 -0
- pywire/client/.prettierignore +8 -0
- pywire/client/.prettierrc +7 -0
- pywire/client/build.mjs +73 -0
- pywire/client/eslint.config.js +46 -0
- pywire/client/package.json +39 -0
- pywire/client/pnpm-lock.yaml +2971 -0
- pywire/client/src/core/app.ts +263 -0
- pywire/client/src/core/dom-updater.test.ts +78 -0
- pywire/client/src/core/dom-updater.ts +321 -0
- pywire/client/src/core/index.ts +5 -0
- pywire/client/src/core/transport-manager.test.ts +179 -0
- pywire/client/src/core/transport-manager.ts +159 -0
- pywire/client/src/core/transports/base.ts +122 -0
- pywire/client/src/core/transports/http.ts +142 -0
- pywire/client/src/core/transports/index.ts +13 -0
- pywire/client/src/core/transports/websocket.ts +97 -0
- pywire/client/src/core/transports/webtransport.ts +149 -0
- pywire/client/src/dev/dev-app.ts +93 -0
- pywire/client/src/dev/error-trace.test.ts +97 -0
- pywire/client/src/dev/error-trace.ts +76 -0
- pywire/client/src/dev/index.ts +4 -0
- pywire/client/src/dev/status-overlay.ts +63 -0
- pywire/client/src/events/handler.test.ts +318 -0
- pywire/client/src/events/handler.ts +454 -0
- pywire/client/src/pywire.core.ts +22 -0
- pywire/client/src/pywire.dev.ts +27 -0
- pywire/client/tsconfig.json +17 -0
- pywire/client/vitest.config.ts +15 -0
- pywire/compiler/__init__.py +6 -0
- pywire/compiler/ast_nodes.py +304 -0
- pywire/compiler/attributes/__init__.py +6 -0
- pywire/compiler/attributes/base.py +24 -0
- pywire/compiler/attributes/conditional.py +37 -0
- pywire/compiler/attributes/events.py +55 -0
- pywire/compiler/attributes/form.py +37 -0
- pywire/compiler/attributes/loop.py +75 -0
- pywire/compiler/attributes/reactive.py +34 -0
- pywire/compiler/build.py +28 -0
- pywire/compiler/build_artifacts.py +342 -0
- pywire/compiler/codegen/__init__.py +5 -0
- pywire/compiler/codegen/attributes/__init__.py +6 -0
- pywire/compiler/codegen/attributes/base.py +19 -0
- pywire/compiler/codegen/attributes/events.py +35 -0
- pywire/compiler/codegen/directives/__init__.py +6 -0
- pywire/compiler/codegen/directives/base.py +16 -0
- pywire/compiler/codegen/directives/path.py +53 -0
- pywire/compiler/codegen/generator.py +2341 -0
- pywire/compiler/codegen/template.py +2178 -0
- pywire/compiler/directives/__init__.py +7 -0
- pywire/compiler/directives/base.py +20 -0
- pywire/compiler/directives/component.py +33 -0
- pywire/compiler/directives/context.py +93 -0
- pywire/compiler/directives/layout.py +49 -0
- pywire/compiler/directives/no_spa.py +24 -0
- pywire/compiler/directives/path.py +71 -0
- pywire/compiler/directives/props.py +88 -0
- pywire/compiler/exceptions.py +19 -0
- pywire/compiler/interpolation/__init__.py +6 -0
- pywire/compiler/interpolation/base.py +28 -0
- pywire/compiler/interpolation/jinja.py +272 -0
- pywire/compiler/parser.py +750 -0
- pywire/compiler/paths.py +29 -0
- pywire/compiler/preprocessor.py +43 -0
- pywire/core/wire.py +119 -0
- pywire/py.typed +0 -0
- pywire/runtime/__init__.py +7 -0
- pywire/runtime/aioquic_server.py +194 -0
- pywire/runtime/app.py +901 -0
- pywire/runtime/compile_error_page.py +195 -0
- pywire/runtime/debug.py +203 -0
- pywire/runtime/dev_server.py +434 -0
- pywire/runtime/dev_server.py.broken +268 -0
- pywire/runtime/error_page.py +64 -0
- pywire/runtime/error_renderer.py +23 -0
- pywire/runtime/escape.py +23 -0
- pywire/runtime/files.py +40 -0
- pywire/runtime/helpers.py +97 -0
- pywire/runtime/http_transport.py +253 -0
- pywire/runtime/loader.py +272 -0
- pywire/runtime/logging.py +72 -0
- pywire/runtime/page.py +384 -0
- pywire/runtime/pydantic_integration.py +52 -0
- pywire/runtime/router.py +229 -0
- pywire/runtime/server.py +25 -0
- pywire/runtime/style_collector.py +31 -0
- pywire/runtime/upload_manager.py +76 -0
- pywire/runtime/validation.py +449 -0
- pywire/runtime/websocket.py +665 -0
- pywire/runtime/webtransport_handler.py +195 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
- pywire-0.1.2.dist-info/RECORD +104 -0
- pywire-0.1.1.dist-info/RECORD +0 -9
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Development server with hot reload."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional, Tuple
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
# Force terminal to ensure ANSI codes are generated even when piped to TUI
|
|
10
|
+
console = Console(force_terminal=True, markup=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _import_app(app_str: str) -> Any:
|
|
14
|
+
"""Import application from string."""
|
|
15
|
+
module_name, app_name = app_str.split(":", 1)
|
|
16
|
+
# Ensure current directory is in path (should be from main.py, but safe to add)
|
|
17
|
+
if os.getcwd() not in sys.path:
|
|
18
|
+
sys.path.insert(0, os.getcwd())
|
|
19
|
+
|
|
20
|
+
import importlib
|
|
21
|
+
|
|
22
|
+
module = importlib.import_module(module_name)
|
|
23
|
+
return getattr(module, app_name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _generate_cert() -> Tuple[str, str, bytes]:
|
|
27
|
+
"""Generate self-signed certificate for localhost."""
|
|
28
|
+
import datetime
|
|
29
|
+
import os
|
|
30
|
+
import tempfile
|
|
31
|
+
|
|
32
|
+
from cryptography import x509
|
|
33
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
34
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
35
|
+
from cryptography.x509.oid import NameOID
|
|
36
|
+
|
|
37
|
+
# Use ECDSA P-256 (More standard for QUIC/TLS 1.3 than RSA)
|
|
38
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
39
|
+
|
|
40
|
+
subject = issuer = x509.Name(
|
|
41
|
+
[
|
|
42
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
import ipaddress
|
|
47
|
+
|
|
48
|
+
cert = (
|
|
49
|
+
x509.CertificateBuilder()
|
|
50
|
+
.subject_name(subject)
|
|
51
|
+
.issuer_name(issuer)
|
|
52
|
+
.public_key(key.public_key())
|
|
53
|
+
.serial_number(x509.random_serial_number())
|
|
54
|
+
.not_valid_before(
|
|
55
|
+
# Backdate by 1 hour to handle minor clock skew
|
|
56
|
+
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)
|
|
57
|
+
)
|
|
58
|
+
.not_valid_after(
|
|
59
|
+
# Valid for 10 days (Required for WebTransport serverCertificateHashes)
|
|
60
|
+
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=10)
|
|
61
|
+
)
|
|
62
|
+
.add_extension(
|
|
63
|
+
x509.SubjectAlternativeName(
|
|
64
|
+
[
|
|
65
|
+
x509.DNSName("localhost"),
|
|
66
|
+
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
|
|
67
|
+
x509.IPAddress(ipaddress.IPv6Address("::1")),
|
|
68
|
+
]
|
|
69
|
+
),
|
|
70
|
+
critical=False,
|
|
71
|
+
)
|
|
72
|
+
.sign(key, hashes.SHA256())
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
cert_dir = tempfile.mkdtemp()
|
|
76
|
+
cert_path = os.path.join(cert_dir, "cert.pem")
|
|
77
|
+
key_path = os.path.join(cert_dir, "key.pem")
|
|
78
|
+
|
|
79
|
+
with open(key_path, "wb") as f:
|
|
80
|
+
f.write(
|
|
81
|
+
key.private_bytes(
|
|
82
|
+
encoding=serialization.Encoding.PEM,
|
|
83
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
84
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
with open(cert_path, "wb") as f:
|
|
89
|
+
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
|
90
|
+
|
|
91
|
+
fingerprint = cert.fingerprint(hashes.SHA256())
|
|
92
|
+
|
|
93
|
+
return cert_path, key_path, fingerprint
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def run_dev_server(
|
|
97
|
+
app_str: str,
|
|
98
|
+
host: str,
|
|
99
|
+
port: int,
|
|
100
|
+
ssl_keyfile: Optional[str] = None,
|
|
101
|
+
ssl_certfile: Optional[str] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Run development server with hot reload."""
|
|
104
|
+
import asyncio
|
|
105
|
+
import logging
|
|
106
|
+
import signal
|
|
107
|
+
|
|
108
|
+
from watchfiles import awatch
|
|
109
|
+
from rich.logging import RichHandler
|
|
110
|
+
|
|
111
|
+
# Configure logging to see Hypercorn/aioquic debug output
|
|
112
|
+
# Use RichHandler to ensure colored logs for Uvicorn/Hypercorn
|
|
113
|
+
logging.basicConfig(
|
|
114
|
+
level=logging.INFO,
|
|
115
|
+
format="%(message)s",
|
|
116
|
+
datefmt="[%X]",
|
|
117
|
+
handlers=[RichHandler(console=console, show_path=False, markup=True)],
|
|
118
|
+
)
|
|
119
|
+
logging.getLogger("hypercorn").setLevel(logging.INFO)
|
|
120
|
+
|
|
121
|
+
# Load app to get config
|
|
122
|
+
pywire_app = _import_app(app_str)
|
|
123
|
+
|
|
124
|
+
# Enable dev mode flag to unlock source endpoints
|
|
125
|
+
pywire_app._is_dev_mode = True
|
|
126
|
+
|
|
127
|
+
pages_dir = pywire_app.pages_dir
|
|
128
|
+
|
|
129
|
+
# Enable Dev Error Middleware
|
|
130
|
+
from pywire.runtime.debug import DevErrorMiddleware
|
|
131
|
+
|
|
132
|
+
pywire_app.app = DevErrorMiddleware(pywire_app.app)
|
|
133
|
+
|
|
134
|
+
if not pages_dir.exists():
|
|
135
|
+
console.print(
|
|
136
|
+
f"[bold yellow]Warning[/]: Pages directory '{pages_dir}' does not exist."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Try to import Hypercorn for HTTP/3 support
|
|
140
|
+
try:
|
|
141
|
+
from hypercorn.asyncio import serve
|
|
142
|
+
from hypercorn.config import Config
|
|
143
|
+
|
|
144
|
+
has_http3 = True
|
|
145
|
+
except ImportError:
|
|
146
|
+
has_http3 = False
|
|
147
|
+
|
|
148
|
+
# DEBUG: Force disable HTTP/3 to avoid Hypercorn/aioquic crash (KeyError: 9) on form uploads
|
|
149
|
+
# console.print("[dim]DEBUG: Forcing HTTP/3 disabled for stress testing form uploads.[/]")
|
|
150
|
+
has_http3 = False
|
|
151
|
+
if not has_http3:
|
|
152
|
+
pass
|
|
153
|
+
# console.print("[dim]PyWire: HTTP/3 (WebTransport) disabled. Install 'aioquic' and 'hypercorn' to enable.[/]")
|
|
154
|
+
|
|
155
|
+
# Create shutdown event
|
|
156
|
+
shutdown_event = asyncio.Event()
|
|
157
|
+
|
|
158
|
+
async def _handle_signal() -> None:
|
|
159
|
+
console.print("\n[bold]PyWire: Shutting down...[/]")
|
|
160
|
+
shutdown_event.set()
|
|
161
|
+
|
|
162
|
+
# Register signal handlers
|
|
163
|
+
try:
|
|
164
|
+
loop = asyncio.get_running_loop()
|
|
165
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
166
|
+
loop.add_signal_handler(sig, lambda: asyncio.create_task(_handle_signal()))
|
|
167
|
+
except NotImplementedError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
# Watcher task
|
|
171
|
+
async def watch_changes() -> None:
|
|
172
|
+
try:
|
|
173
|
+
# Determine pywire source directory
|
|
174
|
+
import pywire
|
|
175
|
+
|
|
176
|
+
pywire_src_dir = Path(pywire.__file__).parent
|
|
177
|
+
|
|
178
|
+
# Install logging interceptor for print capture
|
|
179
|
+
from pywire.runtime.logging import install_logging_interceptor
|
|
180
|
+
|
|
181
|
+
install_logging_interceptor()
|
|
182
|
+
|
|
183
|
+
if not pages_dir.exists():
|
|
184
|
+
console.print(
|
|
185
|
+
f"[bold yellow]Warning[/]: Pages directory '{pages_dir}' does not exist."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Use pages_dir from app
|
|
189
|
+
console.print(
|
|
190
|
+
f"[bold cyan]PyWire[/]: Watching [bold]{pages_dir}[/] for changes..."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Also watch the file defining the app if possible?
|
|
194
|
+
# app_str "main:app" -> main.py
|
|
195
|
+
app_module_path = (
|
|
196
|
+
Path(str(sys.modules[pywire_app.__module__].__file__))
|
|
197
|
+
if hasattr(sys.modules.get(pywire_app.__module__), "__file__")
|
|
198
|
+
else None
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
files_to_watch = [pages_dir, pywire_src_dir]
|
|
202
|
+
if app_module_path:
|
|
203
|
+
files_to_watch.append(app_module_path.parent)
|
|
204
|
+
|
|
205
|
+
# Explicitly look for a components directory
|
|
206
|
+
components_dir = pages_dir.parent / "components"
|
|
207
|
+
if components_dir.exists():
|
|
208
|
+
files_to_watch.append(components_dir)
|
|
209
|
+
|
|
210
|
+
async for changes in awatch(*files_to_watch, stop_event=shutdown_event):
|
|
211
|
+
# Check what changed
|
|
212
|
+
library_changed = False
|
|
213
|
+
app_config_changed = False
|
|
214
|
+
|
|
215
|
+
for change_type, file_path in changes:
|
|
216
|
+
path_str = str(file_path)
|
|
217
|
+
if path_str.startswith(str(pywire_src_dir)):
|
|
218
|
+
library_changed = True
|
|
219
|
+
if app_module_path and path_str == str(app_module_path):
|
|
220
|
+
app_config_changed = True
|
|
221
|
+
|
|
222
|
+
if library_changed or app_config_changed:
|
|
223
|
+
console.print(
|
|
224
|
+
"[bold magenta]PyWire[/]: Core/Config change detected. Please restart server manually."
|
|
225
|
+
)
|
|
226
|
+
# We can't easily auto-restart from within the process unless we wrap it
|
|
227
|
+
# But the TUI can handle restarts.
|
|
228
|
+
|
|
229
|
+
# First, recompile changed pages
|
|
230
|
+
should_reload = False
|
|
231
|
+
for change_type, file_path in changes:
|
|
232
|
+
if file_path.endswith(".wire"):
|
|
233
|
+
should_reload = True
|
|
234
|
+
if hasattr(pywire_app, "reload_page"):
|
|
235
|
+
try:
|
|
236
|
+
pywire_app.reload_page(Path(file_path))
|
|
237
|
+
except Exception as e:
|
|
238
|
+
console.print(f"[bold red]Error[/] reloading page: {e}")
|
|
239
|
+
|
|
240
|
+
# Then broadcast reload if needed
|
|
241
|
+
if should_reload:
|
|
242
|
+
console.print(
|
|
243
|
+
f"[bold green]PyWire[/]: Changes detected in {pages_dir}, reloading clients..."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Broadcast reload to WebSocket clients
|
|
247
|
+
if hasattr(pywire_app, "ws_handler"):
|
|
248
|
+
await pywire_app.ws_handler.broadcast_reload()
|
|
249
|
+
|
|
250
|
+
# Broadcast reload to HTTP polling clients
|
|
251
|
+
if hasattr(pywire_app, "http_handler"):
|
|
252
|
+
pywire_app.http_handler.broadcast_reload()
|
|
253
|
+
|
|
254
|
+
# Broadcast to WebTransport clients
|
|
255
|
+
if hasattr(pywire_app, "web_transport_handler"):
|
|
256
|
+
await pywire_app.web_transport_handler.broadcast_reload()
|
|
257
|
+
|
|
258
|
+
except Exception as e:
|
|
259
|
+
if not shutdown_event.is_set():
|
|
260
|
+
console.print(f"Watcher error: {e}")
|
|
261
|
+
import traceback
|
|
262
|
+
|
|
263
|
+
traceback.print_exc()
|
|
264
|
+
|
|
265
|
+
# Certificate Discovery
|
|
266
|
+
# We do this for both Hypercorn (HTTP/3) and Uvicorn (HTTPS) to support local SSL
|
|
267
|
+
cert_path, key_path = ssl_certfile, ssl_keyfile
|
|
268
|
+
|
|
269
|
+
# Ensure .pywire/ exists for dev artifacts
|
|
270
|
+
from pywire.compiler.paths import ensure_pywire_folder
|
|
271
|
+
|
|
272
|
+
dot_pywire = ensure_pywire_folder()
|
|
273
|
+
|
|
274
|
+
if not cert_path or not key_path:
|
|
275
|
+
# Check for existing trusted certificates (e.g. from mkcert) in .pywire or root
|
|
276
|
+
potential_certs = [
|
|
277
|
+
(dot_pywire / "localhost-key.pem", dot_pywire / "localhost.pem"),
|
|
278
|
+
(dot_pywire / "localhost.pem", dot_pywire / "localhost-key.pem"),
|
|
279
|
+
(Path("localhost+2.pem"), Path("localhost+2-key.pem")),
|
|
280
|
+
(Path("localhost.pem"), Path("localhost-key.pem")),
|
|
281
|
+
(Path("cert.pem"), Path("key.pem")),
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
found = False
|
|
285
|
+
for c_file, k_file in potential_certs:
|
|
286
|
+
if c_file.exists() and k_file.exists():
|
|
287
|
+
console.print(
|
|
288
|
+
f"[bold cyan]PyWire[/]: Found local certificates ([bold]{c_file.name}[/]), using them."
|
|
289
|
+
)
|
|
290
|
+
cert_path = str(c_file)
|
|
291
|
+
key_path = str(k_file)
|
|
292
|
+
# Don't inject hash if using trusted certs
|
|
293
|
+
if hasattr(pywire_app.app.state, "webtransport_cert_hash"):
|
|
294
|
+
del pywire_app.app.state.webtransport_cert_hash
|
|
295
|
+
found = True
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
# If not found, try to generate using mkcert if available
|
|
299
|
+
if not found:
|
|
300
|
+
import shutil
|
|
301
|
+
import subprocess
|
|
302
|
+
|
|
303
|
+
if shutil.which("mkcert"):
|
|
304
|
+
console.print(
|
|
305
|
+
"[bold cyan]PyWire[/]: 'mkcert' detected. Generating trusted local certificates..."
|
|
306
|
+
)
|
|
307
|
+
try:
|
|
308
|
+
# Generate certs in .pywire directory
|
|
309
|
+
pem_file = dot_pywire / "localhost.pem"
|
|
310
|
+
key_file = dot_pywire / "localhost-key.pem"
|
|
311
|
+
|
|
312
|
+
subprocess.run(
|
|
313
|
+
[
|
|
314
|
+
"mkcert",
|
|
315
|
+
"-key-file",
|
|
316
|
+
str(key_file),
|
|
317
|
+
"-cert-file",
|
|
318
|
+
str(pem_file),
|
|
319
|
+
"localhost",
|
|
320
|
+
"127.0.0.1",
|
|
321
|
+
"::1",
|
|
322
|
+
],
|
|
323
|
+
check=True,
|
|
324
|
+
capture_output=True, # Don't spam stdout unless error?
|
|
325
|
+
)
|
|
326
|
+
console.print(
|
|
327
|
+
f"[bold cyan]PyWire[/]: Certificates generated ({pem_file.name})."
|
|
328
|
+
)
|
|
329
|
+
console.print(
|
|
330
|
+
"[bold cyan]PyWire[/]: Note: Run 'mkcert -install' once if your browser doesn't "
|
|
331
|
+
"trust the certificate."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
cert_path = str(pem_file)
|
|
335
|
+
key_path = str(key_file)
|
|
336
|
+
|
|
337
|
+
# Cleare hash injection since we expect trust
|
|
338
|
+
if hasattr(pywire_app.app.state, "webtransport_cert_hash"):
|
|
339
|
+
del pywire_app.app.state.webtransport_cert_hash
|
|
340
|
+
|
|
341
|
+
except subprocess.CalledProcessError as e:
|
|
342
|
+
console.print(f"[bold red]PyWire Error[/]: mkcert failed: {e}")
|
|
343
|
+
else:
|
|
344
|
+
# No mkcert, will fallback to ephemeral logic downstream
|
|
345
|
+
console.print(
|
|
346
|
+
"[bold yellow]PyWire Tip[/]: Install 'mkcert' for trusted local HTTPS "
|
|
347
|
+
"(e.g. 'brew install mkcert')."
|
|
348
|
+
)
|
|
349
|
+
console.print(
|
|
350
|
+
"[bold yellow]PyWire Warning[/]: Using ephemeral self-signed certificates (browser will warn)."
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
async with asyncio.TaskGroup() as tg:
|
|
354
|
+
if has_http3:
|
|
355
|
+
try:
|
|
356
|
+
# If still no certs, generate ephemeral ones for WebTransport
|
|
357
|
+
final_cert, final_key = cert_path, key_path
|
|
358
|
+
if not final_cert:
|
|
359
|
+
final_cert, final_key, fingerprint = _generate_cert()
|
|
360
|
+
pywire_app.app.state.webtransport_cert_hash = fingerprint
|
|
361
|
+
|
|
362
|
+
config = Config()
|
|
363
|
+
config.loglevel = "INFO"
|
|
364
|
+
|
|
365
|
+
# Bind dual-stack (IPv4 + IPv6) for localhost
|
|
366
|
+
if host in ["127.0.0.1", "localhost"]:
|
|
367
|
+
config.bind = [f"127.0.0.1:{port}", f"[::1]:{port}"]
|
|
368
|
+
config.quic_bind = [f"127.0.0.1:{port}", f"[::1]:{port}"]
|
|
369
|
+
else:
|
|
370
|
+
config.bind = [f"{host}:{port}"]
|
|
371
|
+
config.quic_bind = [f"{host}:{port}"]
|
|
372
|
+
|
|
373
|
+
config.certfile = final_cert
|
|
374
|
+
config.keyfile = final_key
|
|
375
|
+
config.use_reloader = False
|
|
376
|
+
|
|
377
|
+
display_host = "localhost" if host == "127.0.0.1" else host
|
|
378
|
+
console.print(
|
|
379
|
+
f"[bold cyan]PyWire[/]: Running on [bold cyan]https://{display_host}:{port}[/] (HTTP/3 + WebSocket)"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Serve the starlette app wrapped in PyWire
|
|
383
|
+
tg.create_task(
|
|
384
|
+
serve(pywire_app.app, config, shutdown_trigger=shutdown_event.wait)
|
|
385
|
+
)
|
|
386
|
+
except Exception as e:
|
|
387
|
+
console.print(
|
|
388
|
+
f"[bold red]PyWire Error[/]: Failed to start Hypercorn: {e}"
|
|
389
|
+
)
|
|
390
|
+
import traceback
|
|
391
|
+
|
|
392
|
+
traceback.print_exc()
|
|
393
|
+
console.print(
|
|
394
|
+
"[bold yellow]PyWire[/]: Falling back to Uvicorn (HTTP/2 + WebSocket only)"
|
|
395
|
+
)
|
|
396
|
+
has_http3 = False
|
|
397
|
+
|
|
398
|
+
if not has_http3:
|
|
399
|
+
# Fallback to Uvicorn
|
|
400
|
+
import uvicorn
|
|
401
|
+
|
|
402
|
+
# If explicit SSL provided OR discovered
|
|
403
|
+
ssl_options = {}
|
|
404
|
+
if cert_path and key_path:
|
|
405
|
+
ssl_options["ssl_certfile"] = cert_path
|
|
406
|
+
ssl_options["ssl_keyfile"] = key_path
|
|
407
|
+
|
|
408
|
+
uv_config = uvicorn.Config(
|
|
409
|
+
pywire_app.app,
|
|
410
|
+
host=host,
|
|
411
|
+
port=port,
|
|
412
|
+
reload=False,
|
|
413
|
+
log_level="info",
|
|
414
|
+
use_colors=True, # Force colors for TUI
|
|
415
|
+
**ssl_options, # type: ignore
|
|
416
|
+
)
|
|
417
|
+
server = uvicorn.Server(uv_config)
|
|
418
|
+
|
|
419
|
+
# Disable Uvicorn's signal handlers so we can manage it
|
|
420
|
+
server.install_signal_handlers = lambda: None # type: ignore
|
|
421
|
+
|
|
422
|
+
async def stop_uvicorn() -> None:
|
|
423
|
+
await shutdown_event.wait()
|
|
424
|
+
server.should_exit = True
|
|
425
|
+
|
|
426
|
+
protocol = "https" if cert_path else "http"
|
|
427
|
+
console.print(
|
|
428
|
+
f"[bold cyan]PyWire[/]: Running on [bold cyan]{protocol}://{host}:{port}[/]"
|
|
429
|
+
)
|
|
430
|
+
tg.create_task(server.serve())
|
|
431
|
+
tg.create_task(stop_uvicorn())
|
|
432
|
+
|
|
433
|
+
# Start watcher
|
|
434
|
+
tg.create_task(watch_changes())
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Development server with hot reload."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import uvicorn
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _generate_cert():
|
|
9
|
+
"""Generate self-signed certificate for localhost."""
|
|
10
|
+
from cryptography import x509
|
|
11
|
+
from cryptography.x509.oid import NameOID
|
|
12
|
+
from cryptography.hazmat.primitives import hashes
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
14
|
+
from cryptography.hazmat.primitives import serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
16
|
+
import datetime
|
|
17
|
+
import tempfile
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
# Use ECDSA P-256 (More standard for QUIC/TLS 1.3 than RSA)
|
|
21
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
22
|
+
|
|
23
|
+
subject = issuer = x509.Name([
|
|
24
|
+
x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"),
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
import ipaddress
|
|
28
|
+
|
|
29
|
+
cert = x509.CertificateBuilder().subject_name(
|
|
30
|
+
subject
|
|
31
|
+
).issuer_name(
|
|
32
|
+
issuer
|
|
33
|
+
).public_key(
|
|
34
|
+
key.public_key()
|
|
35
|
+
).serial_number(
|
|
36
|
+
x509.random_serial_number()
|
|
37
|
+
).not_valid_before(
|
|
38
|
+
# Backdate by 1 hour to handle minor clock skew
|
|
39
|
+
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)
|
|
40
|
+
).not_valid_after(
|
|
41
|
+
# Valid for 10 days (Required for WebTransport serverCertificateHashes)
|
|
42
|
+
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=10)
|
|
43
|
+
).add_extension(
|
|
44
|
+
x509.SubjectAlternativeName([
|
|
45
|
+
x509.DNSName(u"localhost"),
|
|
46
|
+
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
|
|
47
|
+
x509.IPAddress(ipaddress.IPv6Address("::1")),
|
|
48
|
+
]),
|
|
49
|
+
critical=False,
|
|
50
|
+
).sign(key, hashes.SHA256())
|
|
51
|
+
|
|
52
|
+
cert_dir = tempfile.mkdtemp()
|
|
53
|
+
cert_path = os.path.join(cert_dir, "cert.pem")
|
|
54
|
+
key_path = os.path.join(cert_dir, "key.pem")
|
|
55
|
+
|
|
56
|
+
with open(key_path, "wb") as f:
|
|
57
|
+
f.write(key.private_bytes(
|
|
58
|
+
encoding=serialization.Encoding.PEM,
|
|
59
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
60
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
with open(cert_path, "wb") as f:
|
|
64
|
+
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
|
65
|
+
|
|
66
|
+
fingerprint = cert.fingerprint(hashes.SHA256())
|
|
67
|
+
|
|
68
|
+
return cert_path, key_path, fingerprint
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def run_dev_server(host: str, port: int, reload: bool, pages_dir: Path):
|
|
72
|
+
"""Run development server with hot reload."""
|
|
73
|
+
from pywire.runtime.server import create_app
|
|
74
|
+
import asyncio
|
|
75
|
+
import signal
|
|
76
|
+
import logging
|
|
77
|
+
from watchfiles import awatch
|
|
78
|
+
|
|
79
|
+
# Configure logging to see Hypercorn/aioquic debug output
|
|
80
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
81
|
+
logging.getLogger("hypercorn.error").setLevel(logging.DEBUG)
|
|
82
|
+
logging.getLogger("hypercorn.access").setLevel(logging.DEBUG)
|
|
83
|
+
logging.getLogger("aioquic").setLevel(logging.DEBUG)
|
|
84
|
+
|
|
85
|
+
# Import aioquic server for native WebTransport support
|
|
86
|
+
try:
|
|
87
|
+
from pywire.runtime.aioquic_server import run_aioquic_server
|
|
88
|
+
import aioquic
|
|
89
|
+
HAS_HTTP3 = True
|
|
90
|
+
print("PyWire: aioquic detected, HTTP/3 + WebTransport enabled", flush=True)
|
|
91
|
+
except ImportError:
|
|
92
|
+
HAS_HTTP3 = False
|
|
93
|
+
print("PyWire: HTTP/3 (WebTransport) disabled. Install 'aioquic' to enable.")
|
|
94
|
+
|
|
95
|
+
# Create shutdown event
|
|
96
|
+
shutdown_event = asyncio.Event()
|
|
97
|
+
|
|
98
|
+
async def _handle_signal():
|
|
99
|
+
print("\nPyWire: Shutting down...")
|
|
100
|
+
shutdown_event.set()
|
|
101
|
+
|
|
102
|
+
# Register signal handlers
|
|
103
|
+
try:
|
|
104
|
+
loop = asyncio.get_running_loop()
|
|
105
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
106
|
+
loop.add_signal_handler(sig, lambda: asyncio.create_task(_handle_signal()))
|
|
107
|
+
except NotImplementedError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# Create app instance
|
|
111
|
+
app = create_app(pages_dir, reload=reload)
|
|
112
|
+
|
|
113
|
+
# Watcher task
|
|
114
|
+
async def watch_changes():
|
|
115
|
+
try:
|
|
116
|
+
async for changes in awatch(pages_dir, stop_event=shutdown_event):
|
|
117
|
+
# First, recompile changed pages
|
|
118
|
+
should_reload = False
|
|
119
|
+
for change_type, file_path in changes:
|
|
120
|
+
if file_path.endswith('.pywire'):
|
|
121
|
+
should_reload = True
|
|
122
|
+
if hasattr(app.state, 'pywire_app'):
|
|
123
|
+
try:
|
|
124
|
+
app.state.pywire_app.reload_page(Path(file_path))
|
|
125
|
+
except Exception as e:
|
|
126
|
+
print(f"Error reloading page: {e}")
|
|
127
|
+
|
|
128
|
+
# Then broadcast reload if needed
|
|
129
|
+
if should_reload:
|
|
130
|
+
print(f"PyWire: Changes detected in {pages_dir}, reloading clients...")
|
|
131
|
+
|
|
132
|
+
# Broadcast reload to WebSocket clients
|
|
133
|
+
if hasattr(app.state, 'ws_handler'):
|
|
134
|
+
await app.state.ws_handler.broadcast_reload()
|
|
135
|
+
|
|
136
|
+
# Broadcast reload to HTTP polling clients
|
|
137
|
+
if hasattr(app.state, 'http_handler'):
|
|
138
|
+
app.state.http_handler.broadcast_reload()
|
|
139
|
+
|
|
140
|
+
# Broadcast to WebTransport clients
|
|
141
|
+
if hasattr(app.state, 'web_transport_handler'):
|
|
142
|
+
await app.state.web_transport_handler.broadcast_reload()
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
if not shutdown_event.is_set():
|
|
146
|
+
print(f"Watcher error: {e}")
|
|
147
|
+
import traceback
|
|
148
|
+
traceback.print_exc()
|
|
149
|
+
|
|
150
|
+
async with asyncio.TaskGroup() as tg:
|
|
151
|
+
if HAS_HTTP3:
|
|
152
|
+
try:
|
|
153
|
+
# Check for existing trusted certificates (e.g. from mkcert)
|
|
154
|
+
# mkcert localhost -> localhost.pem, localhost-key.pem
|
|
155
|
+
# Check for existing trusted certificates (e.g. from mkcert)
|
|
156
|
+
# mkcert localhost -> localhost.pem, localhost-key.pem
|
|
157
|
+
potential_certs = [
|
|
158
|
+
(Path("localhost+2.pem"), Path("localhost+2-key.pem")),
|
|
159
|
+
(Path("localhost.pem"), Path("localhost-key.pem")),
|
|
160
|
+
(Path("cert.pem"), Path("key.pem")),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
cert_path, key_path = None, None
|
|
164
|
+
for c_file, k_file in potential_certs:
|
|
165
|
+
if c_file.exists() and k_file.exists():
|
|
166
|
+
print(f"PyWire: Found local certificates ({c_file}), using them.")
|
|
167
|
+
|
|
168
|
+
# For QUIC/HTTP3, we need the full certificate chain
|
|
169
|
+
# mkcert stores its CA at ~/Library/Application Support/mkcert/rootCA.pem (macOS)
|
|
170
|
+
# or ~/.local/share/mkcert/rootCA.pem (Linux)
|
|
171
|
+
import os
|
|
172
|
+
import tempfile
|
|
173
|
+
|
|
174
|
+
mkcert_ca_paths = [
|
|
175
|
+
Path.home() / "Library" / "Application Support" / "mkcert" / "rootCA.pem", # macOS
|
|
176
|
+
Path.home() / ".local" / "share" / "mkcert" / "rootCA.pem", # Linux
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
ca_cert = None
|
|
180
|
+
for ca_path in mkcert_ca_paths:
|
|
181
|
+
if ca_path.exists():
|
|
182
|
+
ca_cert = ca_path.read_text()
|
|
183
|
+
print(f"PyWire: Found mkcert CA, creating certificate chain for QUIC...")
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
if ca_cert:
|
|
187
|
+
# Create a chain file: leaf cert + CA cert
|
|
188
|
+
leaf_cert = c_file.read_text()
|
|
189
|
+
chain_content = leaf_cert + "\n" + ca_cert
|
|
190
|
+
|
|
191
|
+
# Write to temp file
|
|
192
|
+
chain_dir = tempfile.mkdtemp()
|
|
193
|
+
chain_path = os.path.join(chain_dir, "chain.pem")
|
|
194
|
+
with open(chain_path, "w") as f:
|
|
195
|
+
f.write(chain_content)
|
|
196
|
+
|
|
197
|
+
cert_path = chain_path
|
|
198
|
+
else:
|
|
199
|
+
cert_path = str(c_file)
|
|
200
|
+
|
|
201
|
+
key_path = str(k_file)
|
|
202
|
+
# Don't inject hash if using trusted certs (expected validity > 14 days)
|
|
203
|
+
if hasattr(app.state, 'webtransport_cert_hash'):
|
|
204
|
+
del app.state.webtransport_cert_hash
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
if not cert_path:
|
|
208
|
+
# Generate ephemeral self-signed certs
|
|
209
|
+
cert_path, key_path, fingerprint = _generate_cert()
|
|
210
|
+
app.state.webtransport_cert_hash = fingerprint
|
|
211
|
+
|
|
212
|
+
# Factory function for creating app instances
|
|
213
|
+
def app_factory():
|
|
214
|
+
return app
|
|
215
|
+
|
|
216
|
+
display_host = "localhost" if host == "127.0.0.1" else host
|
|
217
|
+
print(f"PyWire: Running with aioquic (HTTP/3 + WebTransport) on https://{display_host}:{port}", flush=True)
|
|
218
|
+
|
|
219
|
+
# Run aioquic server as background task
|
|
220
|
+
server_task = asyncio.create_task(run_aioquic_server(
|
|
221
|
+
app_factory=app_factory,
|
|
222
|
+
host="::" if host in ["127.0.0.1", "localhost"] else host, # Bind to IPv6 for localhost
|
|
223
|
+
port=port,
|
|
224
|
+
certfile=cert_path,
|
|
225
|
+
keyfile=key_path,
|
|
226
|
+
))
|
|
227
|
+
|
|
228
|
+
# Wait for shutdown or server error
|
|
229
|
+
done, pending = await asyncio.wait(
|
|
230
|
+
[server_task, asyncio.create_task(shutdown_event.wait())],
|
|
231
|
+
return_when=asyncio.FIRST_COMPLETED
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Cancel remaining tasks
|
|
235
|
+
for task in pending:
|
|
236
|
+
task.cancel()
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print(f"PyWire: Failed to start aioquic server: {e}")
|
|
239
|
+
import traceback
|
|
240
|
+
traceback.print_exc()
|
|
241
|
+
print("PyWire: Falling back to Uvicorn (no WebTransport)")
|
|
242
|
+
HAS_HTTP3 = False
|
|
243
|
+
|
|
244
|
+
if not HAS_HTTP3:
|
|
245
|
+
# Fallback to Uvicorn
|
|
246
|
+
import uvicorn
|
|
247
|
+
config = uvicorn.Config(
|
|
248
|
+
app,
|
|
249
|
+
host=host,
|
|
250
|
+
port=port,
|
|
251
|
+
reload=False,
|
|
252
|
+
log_level="info"
|
|
253
|
+
)
|
|
254
|
+
server = uvicorn.Server(config)
|
|
255
|
+
|
|
256
|
+
# Disable Uvicorn's signal handlers so we can manage it
|
|
257
|
+
server.install_signal_handlers = lambda: None
|
|
258
|
+
|
|
259
|
+
async def stop_uvicorn():
|
|
260
|
+
await shutdown_event.wait()
|
|
261
|
+
server.should_exit = True
|
|
262
|
+
|
|
263
|
+
print(f"PyWire: Running with Uvicorn on http://{host}:{port}")
|
|
264
|
+
tg.create_task(server.serve())
|
|
265
|
+
tg.create_task(stop_uvicorn())
|
|
266
|
+
|
|
267
|
+
if reload:
|
|
268
|
+
tg.create_task(watch_changes())
|