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.
Files changed (101) hide show
  1. pywire/__init__.py +2 -0
  2. pywire/cli/__init__.py +1 -0
  3. pywire/cli/generators.py +48 -0
  4. pywire/cli/main.py +309 -0
  5. pywire/cli/tui.py +563 -0
  6. pywire/cli/validate.py +26 -0
  7. pywire/client/.prettierignore +8 -0
  8. pywire/client/.prettierrc +7 -0
  9. pywire/client/build.mjs +73 -0
  10. pywire/client/eslint.config.js +46 -0
  11. pywire/client/package.json +39 -0
  12. pywire/client/pnpm-lock.yaml +2971 -0
  13. pywire/client/src/core/app.ts +263 -0
  14. pywire/client/src/core/dom-updater.test.ts +78 -0
  15. pywire/client/src/core/dom-updater.ts +321 -0
  16. pywire/client/src/core/index.ts +5 -0
  17. pywire/client/src/core/transport-manager.test.ts +179 -0
  18. pywire/client/src/core/transport-manager.ts +159 -0
  19. pywire/client/src/core/transports/base.ts +122 -0
  20. pywire/client/src/core/transports/http.ts +142 -0
  21. pywire/client/src/core/transports/index.ts +13 -0
  22. pywire/client/src/core/transports/websocket.ts +97 -0
  23. pywire/client/src/core/transports/webtransport.ts +149 -0
  24. pywire/client/src/dev/dev-app.ts +93 -0
  25. pywire/client/src/dev/error-trace.test.ts +97 -0
  26. pywire/client/src/dev/error-trace.ts +76 -0
  27. pywire/client/src/dev/index.ts +4 -0
  28. pywire/client/src/dev/status-overlay.ts +63 -0
  29. pywire/client/src/events/handler.test.ts +318 -0
  30. pywire/client/src/events/handler.ts +454 -0
  31. pywire/client/src/pywire.core.ts +22 -0
  32. pywire/client/src/pywire.dev.ts +27 -0
  33. pywire/client/tsconfig.json +17 -0
  34. pywire/client/vitest.config.ts +15 -0
  35. pywire/compiler/__init__.py +6 -0
  36. pywire/compiler/ast_nodes.py +304 -0
  37. pywire/compiler/attributes/__init__.py +6 -0
  38. pywire/compiler/attributes/base.py +24 -0
  39. pywire/compiler/attributes/conditional.py +37 -0
  40. pywire/compiler/attributes/events.py +55 -0
  41. pywire/compiler/attributes/form.py +37 -0
  42. pywire/compiler/attributes/loop.py +75 -0
  43. pywire/compiler/attributes/reactive.py +34 -0
  44. pywire/compiler/build.py +28 -0
  45. pywire/compiler/build_artifacts.py +342 -0
  46. pywire/compiler/codegen/__init__.py +5 -0
  47. pywire/compiler/codegen/attributes/__init__.py +6 -0
  48. pywire/compiler/codegen/attributes/base.py +19 -0
  49. pywire/compiler/codegen/attributes/events.py +35 -0
  50. pywire/compiler/codegen/directives/__init__.py +6 -0
  51. pywire/compiler/codegen/directives/base.py +16 -0
  52. pywire/compiler/codegen/directives/path.py +53 -0
  53. pywire/compiler/codegen/generator.py +2341 -0
  54. pywire/compiler/codegen/template.py +2178 -0
  55. pywire/compiler/directives/__init__.py +7 -0
  56. pywire/compiler/directives/base.py +20 -0
  57. pywire/compiler/directives/component.py +33 -0
  58. pywire/compiler/directives/context.py +93 -0
  59. pywire/compiler/directives/layout.py +49 -0
  60. pywire/compiler/directives/no_spa.py +24 -0
  61. pywire/compiler/directives/path.py +71 -0
  62. pywire/compiler/directives/props.py +88 -0
  63. pywire/compiler/exceptions.py +19 -0
  64. pywire/compiler/interpolation/__init__.py +6 -0
  65. pywire/compiler/interpolation/base.py +28 -0
  66. pywire/compiler/interpolation/jinja.py +272 -0
  67. pywire/compiler/parser.py +750 -0
  68. pywire/compiler/paths.py +29 -0
  69. pywire/compiler/preprocessor.py +43 -0
  70. pywire/core/wire.py +119 -0
  71. pywire/py.typed +0 -0
  72. pywire/runtime/__init__.py +7 -0
  73. pywire/runtime/aioquic_server.py +194 -0
  74. pywire/runtime/app.py +901 -0
  75. pywire/runtime/compile_error_page.py +195 -0
  76. pywire/runtime/debug.py +203 -0
  77. pywire/runtime/dev_server.py +434 -0
  78. pywire/runtime/dev_server.py.broken +268 -0
  79. pywire/runtime/error_page.py +64 -0
  80. pywire/runtime/error_renderer.py +23 -0
  81. pywire/runtime/escape.py +23 -0
  82. pywire/runtime/files.py +40 -0
  83. pywire/runtime/helpers.py +97 -0
  84. pywire/runtime/http_transport.py +253 -0
  85. pywire/runtime/loader.py +272 -0
  86. pywire/runtime/logging.py +72 -0
  87. pywire/runtime/page.py +384 -0
  88. pywire/runtime/pydantic_integration.py +52 -0
  89. pywire/runtime/router.py +229 -0
  90. pywire/runtime/server.py +25 -0
  91. pywire/runtime/style_collector.py +31 -0
  92. pywire/runtime/upload_manager.py +76 -0
  93. pywire/runtime/validation.py +449 -0
  94. pywire/runtime/websocket.py +665 -0
  95. pywire/runtime/webtransport_handler.py +195 -0
  96. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
  97. pywire-0.1.2.dist-info/RECORD +104 -0
  98. pywire-0.1.1.dist-info/RECORD +0 -9
  99. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
  100. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
  101. {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())