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
pywire/runtime/app.py
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
"""Main ASGI application."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import traceback
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional, Set, cast
|
|
8
|
+
|
|
9
|
+
from starlette.applications import Starlette
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import JSONResponse, PlainTextResponse, Response
|
|
12
|
+
from starlette.routing import Mount, Route, WebSocketRoute
|
|
13
|
+
from starlette.staticfiles import StaticFiles
|
|
14
|
+
|
|
15
|
+
from pywire.runtime.error_page import ErrorPage
|
|
16
|
+
from pywire.runtime.http_transport import HTTPTransportHandler
|
|
17
|
+
from pywire.runtime.router import Router
|
|
18
|
+
from pywire.runtime.upload_manager import upload_manager
|
|
19
|
+
from pywire.runtime.websocket import WebSocketHandler
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PyWire:
|
|
25
|
+
"""Main ASGI application and configuration."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
pages_dir: Optional[str] = None,
|
|
30
|
+
path_based_routing: bool = True,
|
|
31
|
+
enable_pjax: bool = True,
|
|
32
|
+
debug: bool = False,
|
|
33
|
+
enable_webtransport: bool = False,
|
|
34
|
+
static_dir: Optional[str] = None,
|
|
35
|
+
static_path: str = "/static",
|
|
36
|
+
) -> None:
|
|
37
|
+
if pages_dir is None:
|
|
38
|
+
# Auto-discovery
|
|
39
|
+
cwd = Path.cwd()
|
|
40
|
+
potential_paths = [cwd / "pages", cwd / "src" / "pages"]
|
|
41
|
+
|
|
42
|
+
discovered = False
|
|
43
|
+
for path in potential_paths:
|
|
44
|
+
if path.exists() and path.is_dir():
|
|
45
|
+
self.pages_dir = path
|
|
46
|
+
discovered = True
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if not discovered:
|
|
50
|
+
# Default to 'pages' and let it fail/warn later if missing
|
|
51
|
+
self.pages_dir = Path("pages")
|
|
52
|
+
else:
|
|
53
|
+
self.pages_dir = Path(pages_dir)
|
|
54
|
+
|
|
55
|
+
self.pages_dir = self.pages_dir.resolve()
|
|
56
|
+
|
|
57
|
+
# User configured static directory (disabled by default)
|
|
58
|
+
self.static_dir = None
|
|
59
|
+
if static_dir:
|
|
60
|
+
path = Path(static_dir)
|
|
61
|
+
if not path.is_absolute():
|
|
62
|
+
# Try relative to CWD
|
|
63
|
+
potential = Path.cwd() / path
|
|
64
|
+
if not potential.exists():
|
|
65
|
+
# Try src/ fallback
|
|
66
|
+
src_potential = Path.cwd() / "src" / path
|
|
67
|
+
if src_potential.exists():
|
|
68
|
+
potential = src_potential
|
|
69
|
+
self.static_dir = potential.resolve()
|
|
70
|
+
else:
|
|
71
|
+
self.static_dir = path
|
|
72
|
+
|
|
73
|
+
self.static_url_path = static_path
|
|
74
|
+
|
|
75
|
+
self.path_based_routing = path_based_routing
|
|
76
|
+
self.enable_pjax = enable_pjax
|
|
77
|
+
self.debug = debug
|
|
78
|
+
self.enable_webtransport = enable_webtransport
|
|
79
|
+
# Internal flag set by dev_server.py when running via 'pywire dev'
|
|
80
|
+
self._is_dev_mode = False
|
|
81
|
+
|
|
82
|
+
self.router = Router()
|
|
83
|
+
|
|
84
|
+
from pywire.runtime.loader import get_loader
|
|
85
|
+
|
|
86
|
+
self.loader = get_loader()
|
|
87
|
+
|
|
88
|
+
self.ws_handler = WebSocketHandler(self)
|
|
89
|
+
self.http_handler = HTTPTransportHandler(self)
|
|
90
|
+
|
|
91
|
+
# Initialize WebTransport handler
|
|
92
|
+
from pywire.runtime.webtransport_handler import WebTransportHandler
|
|
93
|
+
|
|
94
|
+
self.web_transport_handler = WebTransportHandler(self)
|
|
95
|
+
|
|
96
|
+
# Valid upload tokens
|
|
97
|
+
self.upload_tokens: Set[str] = set()
|
|
98
|
+
|
|
99
|
+
# Compile and register all pages
|
|
100
|
+
self._load_pages()
|
|
101
|
+
|
|
102
|
+
# Static files (PyWire Internal)
|
|
103
|
+
internal_static_dir = Path(__file__).parent.parent / "static"
|
|
104
|
+
|
|
105
|
+
# Prepare exception handlers
|
|
106
|
+
exception_handlers: Dict[int, Any] = {}
|
|
107
|
+
# Always register our handler to check for custom error pages
|
|
108
|
+
exception_handlers[500] = self._handle_500
|
|
109
|
+
|
|
110
|
+
# Build routes list
|
|
111
|
+
routes = [
|
|
112
|
+
# Capabilities endpoint for transport negotiation
|
|
113
|
+
Route("/_pywire/capabilities", self._handle_capabilities, methods=["GET"]),
|
|
114
|
+
# WebSocket transport
|
|
115
|
+
WebSocketRoute("/_pywire/ws", self.ws_handler.handle),
|
|
116
|
+
# HTTP transport endpoints
|
|
117
|
+
Route(
|
|
118
|
+
"/_pywire/session", self.http_handler.create_session, methods=["POST"]
|
|
119
|
+
),
|
|
120
|
+
Route("/_pywire/poll", self.http_handler.poll, methods=["GET"]),
|
|
121
|
+
Route("/_pywire/event", self.http_handler.handle_event, methods=["POST"]),
|
|
122
|
+
# Upload endpoint
|
|
123
|
+
Route("/_pywire/upload", self._handle_upload, methods=["POST"]),
|
|
124
|
+
# Internal Static files
|
|
125
|
+
*(
|
|
126
|
+
[
|
|
127
|
+
Mount(
|
|
128
|
+
"/_pywire/static",
|
|
129
|
+
app=StaticFiles(directory=str(internal_static_dir)),
|
|
130
|
+
name="internal_static",
|
|
131
|
+
)
|
|
132
|
+
]
|
|
133
|
+
if internal_static_dir.exists()
|
|
134
|
+
else []
|
|
135
|
+
),
|
|
136
|
+
]
|
|
137
|
+
if not internal_static_dir.exists():
|
|
138
|
+
logger.warning(
|
|
139
|
+
"Internal static assets not found at '%s'. "
|
|
140
|
+
"Reinstall pywire or verify package data inclusion.",
|
|
141
|
+
internal_static_dir,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Mount User Static Files if configured
|
|
145
|
+
if self.static_dir:
|
|
146
|
+
if not self.static_dir.exists():
|
|
147
|
+
logger.warning(
|
|
148
|
+
f"Configured static directory '{self.static_dir}' does not exist."
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
routes.append(
|
|
152
|
+
Mount(
|
|
153
|
+
self.static_url_path,
|
|
154
|
+
app=StaticFiles(directory=str(self.static_dir)),
|
|
155
|
+
name="static",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Debug endpoints (must be before catch-all)
|
|
160
|
+
# ONLY enable these if BOTH debug=True AND we are in dev mode
|
|
161
|
+
# This prevents source code exposure in 'pywire run' even if debug=True
|
|
162
|
+
if self.debug:
|
|
163
|
+
# We defer the check to the handler or register them but check flag inside?
|
|
164
|
+
# Better to not register them at all if we know _is_dev_mode is False at init?
|
|
165
|
+
# PROBLEM: _is_dev_mode is set AFTER init by dev_server.py.
|
|
166
|
+
# So we register them, but gate them inside the handler, OR we allow dev_server
|
|
167
|
+
# to re-init app? No, dev_server imports app.
|
|
168
|
+
|
|
169
|
+
# Solution: Register them, but check self._is_dev_mode inside the handlers.
|
|
170
|
+
# OR refactor so routes are dynamic? Starlette routes are fixed list usually.
|
|
171
|
+
|
|
172
|
+
# Actually, let's keep them registered if debug=True, but
|
|
173
|
+
# modify _handle_source/_handle_file to checking _is_dev_mode as well inside.
|
|
174
|
+
routes.append(
|
|
175
|
+
Route("/_pywire/source", self._handle_source, methods=["GET"])
|
|
176
|
+
)
|
|
177
|
+
routes.append(
|
|
178
|
+
Route(
|
|
179
|
+
"/_pywire/file/{encoded:path}", self._handle_file, methods=["GET"]
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
# Chrome DevTools automatic workspace folders (M-135+)
|
|
183
|
+
routes.append(
|
|
184
|
+
Route(
|
|
185
|
+
"/.well-known/appspecific/com.chrome.devtools.json",
|
|
186
|
+
self._handle_devtools_json,
|
|
187
|
+
methods=["GET"],
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Default page handler (catch-all, must be last)
|
|
192
|
+
routes.append(
|
|
193
|
+
Route("/{path:path}", self._handle_request, methods=["GET", "POST"])
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Create Starlette app with all transport routes
|
|
197
|
+
self.app = Starlette(routes=routes, exception_handlers=exception_handlers)
|
|
198
|
+
|
|
199
|
+
# Store configuration in app state for runtime access (e.g. by pages)
|
|
200
|
+
self.app.state.enable_pjax = self.enable_pjax
|
|
201
|
+
self.app.state.debug = self.debug
|
|
202
|
+
self.app.state.pywire = self
|
|
203
|
+
|
|
204
|
+
async def _handle_capabilities(self, request: Request) -> JSONResponse:
|
|
205
|
+
"""Return server transport capabilities for client negotiation."""
|
|
206
|
+
return JSONResponse(
|
|
207
|
+
{
|
|
208
|
+
"transports": ["websocket", "http"],
|
|
209
|
+
# WebTransport requires HTTP/3 - only available when running with Hypercorn
|
|
210
|
+
"webtransport": False,
|
|
211
|
+
"version": "0.1.0",
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _get_client_script_url(self) -> str:
|
|
216
|
+
"""Return the appropriate client bundle URL based on server mode.
|
|
217
|
+
|
|
218
|
+
Returns dev bundle when running via 'pywire dev', core bundle otherwise.
|
|
219
|
+
"""
|
|
220
|
+
if self._is_dev_mode:
|
|
221
|
+
return "/_pywire/static/pywire.dev.min.js"
|
|
222
|
+
return "/_pywire/static/pywire.core.min.js"
|
|
223
|
+
|
|
224
|
+
async def _handle_upload(self, request: Request) -> JSONResponse:
|
|
225
|
+
"""Handle file uploads."""
|
|
226
|
+
print(f"DEBUG: Handling upload request for {request.url}")
|
|
227
|
+
try:
|
|
228
|
+
# Check for upload token
|
|
229
|
+
token = request.headers.get("X-Upload-Token")
|
|
230
|
+
if not token or token not in self.upload_tokens:
|
|
231
|
+
return JSONResponse(
|
|
232
|
+
{"error": "Invalid or expired upload token"}, status_code=403
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Fail-fast: Check Content-Length header
|
|
236
|
+
content_length = request.headers.get("content-length")
|
|
237
|
+
if content_length:
|
|
238
|
+
try:
|
|
239
|
+
length = int(content_length)
|
|
240
|
+
# Global safety limit: 10MB (allows for 5MB file + overhead)
|
|
241
|
+
# Real app might configure this or inspect specific field limits after streaming
|
|
242
|
+
if length > 10 * 1024 * 1024:
|
|
243
|
+
logger.warning(
|
|
244
|
+
f"Upload rejected. Content-Length {length} exceeds 10MB limit."
|
|
245
|
+
)
|
|
246
|
+
return JSONResponse(
|
|
247
|
+
{"error": "Payload Too Large"}, status_code=413
|
|
248
|
+
)
|
|
249
|
+
except ValueError:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
form = await request.form()
|
|
253
|
+
response_data = {}
|
|
254
|
+
for field_name, file in form.items():
|
|
255
|
+
if hasattr(file, "filename"): # It's an UploadFile
|
|
256
|
+
# We don't really need the ID if we are just testing upload for now?
|
|
257
|
+
# Wait, saving returns the ID!
|
|
258
|
+
from starlette.datastructures import UploadFile
|
|
259
|
+
|
|
260
|
+
upload_id = upload_manager.save(cast(UploadFile, file))
|
|
261
|
+
response_data[field_name] = upload_id
|
|
262
|
+
|
|
263
|
+
logger.debug(f"Upload successful. Returning: {response_data}")
|
|
264
|
+
return JSONResponse(response_data)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.error(f"Upload failed: {e}", exc_info=True)
|
|
267
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
268
|
+
|
|
269
|
+
async def _handle_source(self, request: Request) -> Response:
|
|
270
|
+
"""Serve source code for debugging."""
|
|
271
|
+
print(f"DEBUG: _handle_source called, debug={self.debug}")
|
|
272
|
+
if not self.debug:
|
|
273
|
+
print("DEBUG: _handle_source returning 404 because debug=False")
|
|
274
|
+
return Response("Not Found", status_code=404)
|
|
275
|
+
|
|
276
|
+
if not self._is_dev_mode:
|
|
277
|
+
print("DEBUG: _handle_source returning 404 because _is_dev_mode=False")
|
|
278
|
+
return Response("Not Found", status_code=404)
|
|
279
|
+
|
|
280
|
+
path_str = request.query_params.get("path")
|
|
281
|
+
print(f"DEBUG: _handle_source path={path_str}")
|
|
282
|
+
if not path_str:
|
|
283
|
+
return Response("Missing path", status_code=400)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
path = Path(path_str).resolve()
|
|
287
|
+
print(
|
|
288
|
+
f"DEBUG: _handle_source resolved path={path}, exists={path.exists()}, "
|
|
289
|
+
f"is_file={path.is_file()}"
|
|
290
|
+
)
|
|
291
|
+
# Security check: Ensure we are only serving files from allowed directories?
|
|
292
|
+
# For a dev tool, we might want to allow viewing any file in the
|
|
293
|
+
# traceback which might include library files.
|
|
294
|
+
# But normally we want to restrict to project and maybe venv.
|
|
295
|
+
# Let's just check it exists and is a file for now.
|
|
296
|
+
if not path.is_file():
|
|
297
|
+
return Response("File not found", status_code=404)
|
|
298
|
+
|
|
299
|
+
content = path.read_text(encoding="utf-8")
|
|
300
|
+
return Response(content, media_type="text/plain")
|
|
301
|
+
except Exception as e:
|
|
302
|
+
print(f"DEBUG: _handle_source exception: {e}")
|
|
303
|
+
return Response(str(e), status_code=500)
|
|
304
|
+
|
|
305
|
+
async def _handle_file(self, request: Request) -> Response:
|
|
306
|
+
"""Serve source file by base64-encoded path (for DevTools source mapping)."""
|
|
307
|
+
if not self.debug or not self._is_dev_mode:
|
|
308
|
+
return Response("Not Found", status_code=404)
|
|
309
|
+
|
|
310
|
+
import base64
|
|
311
|
+
|
|
312
|
+
encoded_path = request.path_params.get("encoded", "")
|
|
313
|
+
|
|
314
|
+
# If the path contains a slash, it means we appended the filename for Chrome's benefit
|
|
315
|
+
# e.g., "BASE64STRING/my_file.py"
|
|
316
|
+
# We only care about the first part
|
|
317
|
+
if "/" in encoded_path:
|
|
318
|
+
encoded = encoded_path.split("/")[0]
|
|
319
|
+
else:
|
|
320
|
+
encoded = encoded_path
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
# Decode the base64 path (URL-safe variant)
|
|
324
|
+
# Restore padding
|
|
325
|
+
padding = 4 - (len(encoded) % 4)
|
|
326
|
+
if padding != 4:
|
|
327
|
+
encoded += "=" * padding
|
|
328
|
+
# Restore standard base64 chars
|
|
329
|
+
encoded = encoded.replace("-", "+").replace("_", "/")
|
|
330
|
+
path_str = base64.b64decode(encoded).decode("utf-8")
|
|
331
|
+
|
|
332
|
+
path = Path(path_str).resolve()
|
|
333
|
+
if not path.is_file():
|
|
334
|
+
return Response("File not found", status_code=404)
|
|
335
|
+
|
|
336
|
+
content = path.read_text(encoding="utf-8")
|
|
337
|
+
# Return as JavaScript so browser DevTools can parse it
|
|
338
|
+
return Response(content, media_type="text/plain")
|
|
339
|
+
except Exception as e:
|
|
340
|
+
print(f"DEBUG: _handle_file exception: {e}")
|
|
341
|
+
return Response(str(e), status_code=500)
|
|
342
|
+
|
|
343
|
+
async def _handle_devtools_json(self, request: Request) -> JSONResponse:
|
|
344
|
+
"""Serve Chrome DevTools project settings for automatic workspace folders."""
|
|
345
|
+
if not self.debug or not self._is_dev_mode:
|
|
346
|
+
return JSONResponse({}, status_code=404)
|
|
347
|
+
|
|
348
|
+
import hashlib
|
|
349
|
+
import uuid
|
|
350
|
+
|
|
351
|
+
# Use current working directory as project root
|
|
352
|
+
project_root = Path.cwd()
|
|
353
|
+
|
|
354
|
+
# Generate a consistent UUID from the project path
|
|
355
|
+
path_hash = hashlib.md5(str(project_root).encode()).hexdigest()
|
|
356
|
+
project_uuid = str(uuid.UUID(path_hash[:32]))
|
|
357
|
+
|
|
358
|
+
return JSONResponse(
|
|
359
|
+
{"workspace": {"root": str(project_root.resolve()), "uuid": project_uuid}}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _load_pages(self) -> None:
|
|
363
|
+
"""Discover and compile all .pywire files."""
|
|
364
|
+
# Scan pages directory
|
|
365
|
+
# We need to sort files to ensure deterministic order but scanning is recursive
|
|
366
|
+
self._scan_directory(self.pages_dir)
|
|
367
|
+
|
|
368
|
+
# Explicitly check for __error__.wire in root pages dir
|
|
369
|
+
# (It is skipped by _scan_directory because it starts with _)
|
|
370
|
+
error_page_path = self.pages_dir / "__error__.wire"
|
|
371
|
+
if error_page_path.exists():
|
|
372
|
+
try:
|
|
373
|
+
root_layout = None
|
|
374
|
+
if (self.pages_dir / "__layout__.wire").exists():
|
|
375
|
+
root_layout = str((self.pages_dir / "__layout__.wire").resolve())
|
|
376
|
+
|
|
377
|
+
page_class = self.loader.load(
|
|
378
|
+
error_page_path, implicit_layout=root_layout
|
|
379
|
+
)
|
|
380
|
+
self.router.add_route("/__error__", page_class)
|
|
381
|
+
self.router.add_route("/__error__", page_class)
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logger.error(
|
|
384
|
+
f"Failed to load error page {error_page_path}: {e}", exc_info=True
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _scan_directory(
|
|
388
|
+
self, dir_path: Path, layout_path: Optional[str] = None, url_prefix: str = ""
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Recursively scan directory for pages and layouts."""
|
|
391
|
+
current_layout = layout_path
|
|
392
|
+
|
|
393
|
+
# Priority: __layout__.wire ONLY
|
|
394
|
+
potential_layout = dir_path / "__layout__.wire"
|
|
395
|
+
|
|
396
|
+
if potential_layout.exists():
|
|
397
|
+
# Compile layout first (it might use the parent layout!)
|
|
398
|
+
try:
|
|
399
|
+
# Layouts can inherit from parent layouts too
|
|
400
|
+
self.loader.load(potential_layout, implicit_layout=layout_path)
|
|
401
|
+
current_layout = str(potential_layout.resolve())
|
|
402
|
+
except Exception as e:
|
|
403
|
+
logger.error(
|
|
404
|
+
f"Failed to load layout {potential_layout}: {e}", exc_info=True
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# 2. Iterate identifiers
|
|
408
|
+
# Sort to ensure index processed or consistent order
|
|
409
|
+
try:
|
|
410
|
+
entries = sorted(list(dir_path.iterdir()))
|
|
411
|
+
except FileNotFoundError:
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
for entry in entries:
|
|
415
|
+
if entry.name.startswith("_") or entry.name.startswith("."):
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
if entry.is_dir():
|
|
419
|
+
# Determine new prefix
|
|
420
|
+
# Check if it's a param directory [param]
|
|
421
|
+
name = entry.name
|
|
422
|
+
new_segment = name
|
|
423
|
+
|
|
424
|
+
# Check for [param] syntax
|
|
425
|
+
param_match = re.match(r"^\[(.*?)\]$", name)
|
|
426
|
+
if param_match:
|
|
427
|
+
param_name = param_match.group(1)
|
|
428
|
+
# Convert to routing syntax :{name} (or whatever Router supports)
|
|
429
|
+
# Router supports :name or {name}
|
|
430
|
+
new_segment = f"{{{param_name}}}"
|
|
431
|
+
|
|
432
|
+
new_prefix = (url_prefix + "/" + new_segment).replace("//", "/")
|
|
433
|
+
self._scan_directory(entry, current_layout, new_prefix)
|
|
434
|
+
|
|
435
|
+
elif entry.is_file() and entry.suffix == ".wire":
|
|
436
|
+
if entry.name == "layout.wire":
|
|
437
|
+
# Previously supported layout file, now ignored (or treated
|
|
438
|
+
# as normal page? No, starts with l)
|
|
439
|
+
# Wait, layout.pywire doesn't start with _. So it would be registered as /layout
|
|
440
|
+
# We should probably explicitly IGNORE it if we want strictness?
|
|
441
|
+
# The prompt says: "absolutely NOT layout.pywire".
|
|
442
|
+
# If it's not a layout, is it a page? Usually layout.pywire
|
|
443
|
+
# has slots and shouldn't be a page.
|
|
444
|
+
# Let's Skip it to be safe/clean.
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
# Determine route path
|
|
448
|
+
name = entry.stem # filename without .wire
|
|
449
|
+
|
|
450
|
+
route_segment = name
|
|
451
|
+
if name == "index":
|
|
452
|
+
route_segment = ""
|
|
453
|
+
else:
|
|
454
|
+
# Check for [param] in filename
|
|
455
|
+
param_match = re.match(r"^\[(.*?)\]$", name)
|
|
456
|
+
if param_match:
|
|
457
|
+
param_name = param_match.group(1)
|
|
458
|
+
route_segment = f"{{{param_name}}}"
|
|
459
|
+
|
|
460
|
+
route_path = (url_prefix + "/" + route_segment).replace("//", "/")
|
|
461
|
+
|
|
462
|
+
# Strip trailing slash for index pages (unless root)
|
|
463
|
+
if route_path != "/" and route_path.endswith("/"):
|
|
464
|
+
route_path = route_path.rstrip("/")
|
|
465
|
+
|
|
466
|
+
if not route_path:
|
|
467
|
+
route_path = "/"
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
# Load page with implicit layout
|
|
471
|
+
page_class = self.loader.load(entry, implicit_layout=current_layout)
|
|
472
|
+
|
|
473
|
+
# Register routes
|
|
474
|
+
# 1. explicit !path overrides implicit routing?
|
|
475
|
+
# Generally yes. If !path exists, we might add those IN ADDITION or INSTEAD.
|
|
476
|
+
# Current logic in add_page inspects __routes__ (from !path).
|
|
477
|
+
# If present, use that. If not, use implicit route_path.
|
|
478
|
+
|
|
479
|
+
if hasattr(page_class, "__routes__") and page_class.__routes__:
|
|
480
|
+
# User specified explicit paths
|
|
481
|
+
self.router.add_page(page_class)
|
|
482
|
+
elif hasattr(page_class, "__route__") and page_class.__route__:
|
|
483
|
+
# Should not happen as __route__ is derived from __routes__ usually
|
|
484
|
+
self.router.add_page(page_class)
|
|
485
|
+
elif self.path_based_routing:
|
|
486
|
+
# No explicit !path, use file-based route ONLY if enabled
|
|
487
|
+
self.router.add_route(route_path, page_class)
|
|
488
|
+
|
|
489
|
+
except Exception as e:
|
|
490
|
+
logger.error(f"Failed to load page {entry}: {e}", exc_info=True)
|
|
491
|
+
self._register_error_page(entry, e)
|
|
492
|
+
|
|
493
|
+
def _register_error_page(self, file_path: Path, error: Exception) -> None:
|
|
494
|
+
"""Register an error page for a failed file."""
|
|
495
|
+
# Try to infer route from file path/content
|
|
496
|
+
# 1. Start with path relative to pages_dir
|
|
497
|
+
try:
|
|
498
|
+
rel_path = file_path.relative_to(self.pages_dir)
|
|
499
|
+
|
|
500
|
+
# Basic route inference from path
|
|
501
|
+
route_path = "/" + str(rel_path.with_suffix("")).replace("index", "").strip(
|
|
502
|
+
"/"
|
|
503
|
+
)
|
|
504
|
+
if not route_path:
|
|
505
|
+
route_path = "/"
|
|
506
|
+
|
|
507
|
+
# Also try to regex extract !path directives from file content
|
|
508
|
+
# to handle custom routes properly even if compilation fails
|
|
509
|
+
try:
|
|
510
|
+
content = file_path.read_text()
|
|
511
|
+
# Look for !path "..." or !path '...'
|
|
512
|
+
# This is a simple regex, might need refinement
|
|
513
|
+
path_directives = re.findall(r'!path\s+[\'"]([^\'"]+)[\'"]', content)
|
|
514
|
+
|
|
515
|
+
routes_to_register = []
|
|
516
|
+
if path_directives:
|
|
517
|
+
routes_to_register = path_directives
|
|
518
|
+
else:
|
|
519
|
+
routes_to_register = [route_path]
|
|
520
|
+
|
|
521
|
+
# Use a ModeAwareErrorPage that checks debug/dev mode at RENDER time
|
|
522
|
+
# This is necessary because _is_dev_mode is set AFTER __init__ by dev_server.py
|
|
523
|
+
from pywire.runtime.compile_error_page import CompileErrorPage
|
|
524
|
+
from pywire.runtime.page import BasePage
|
|
525
|
+
|
|
526
|
+
for route in routes_to_register:
|
|
527
|
+
# Capture error and file_path in closure
|
|
528
|
+
captured_error = error
|
|
529
|
+
captured_file_path = str(file_path)
|
|
530
|
+
captured_app = self # Reference to PyWire app for mode checking
|
|
531
|
+
|
|
532
|
+
class ModeAwareErrorPage(BasePage):
|
|
533
|
+
"""Error page that decides whether to show details or trigger 500."""
|
|
534
|
+
|
|
535
|
+
def __init__(
|
|
536
|
+
self, request: Request, *args: Any, **kwargs: Any
|
|
537
|
+
) -> None:
|
|
538
|
+
# Store for parent __init__
|
|
539
|
+
super().__init__(request, *args, **kwargs)
|
|
540
|
+
|
|
541
|
+
async def render(self, init: bool = True) -> Any:
|
|
542
|
+
# Check mode at render time (not registration time!)
|
|
543
|
+
# This allows dev_server.py to set _is_dev_mode after app init
|
|
544
|
+
if captured_app.debug or getattr(
|
|
545
|
+
captured_app, "_is_dev_mode", False
|
|
546
|
+
):
|
|
547
|
+
# DEV MODE: Show detailed CompileErrorPage
|
|
548
|
+
detail_page = CompileErrorPage(
|
|
549
|
+
self.request,
|
|
550
|
+
captured_error,
|
|
551
|
+
file_path=captured_file_path,
|
|
552
|
+
)
|
|
553
|
+
return await detail_page.render()
|
|
554
|
+
else:
|
|
555
|
+
# PROD MODE: Raise to trigger _handle_500
|
|
556
|
+
raise RuntimeError("Page failed to load")
|
|
557
|
+
|
|
558
|
+
ModeAwareErrorPage.__file_path__ = captured_file_path
|
|
559
|
+
self.router.add_route(route, ModeAwareErrorPage)
|
|
560
|
+
|
|
561
|
+
except Exception:
|
|
562
|
+
# Fallback to basic path if regex fails
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
except Exception as e:
|
|
566
|
+
logger.error(f"Failed to register error page for {file_path}: {e}")
|
|
567
|
+
|
|
568
|
+
def _get_implicit_route(self, file_path: Path) -> Optional[str]:
|
|
569
|
+
"""Calculate implicit route path from file path."""
|
|
570
|
+
try:
|
|
571
|
+
rel_path = file_path.relative_to(self.pages_dir)
|
|
572
|
+
except ValueError:
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
segments = []
|
|
576
|
+
for i, part in enumerate(rel_path.parts):
|
|
577
|
+
if part.startswith("_") or part.startswith("."):
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
name = part
|
|
581
|
+
is_file = i == len(rel_path.parts) - 1
|
|
582
|
+
|
|
583
|
+
if is_file:
|
|
584
|
+
if not name.endswith(".wire"):
|
|
585
|
+
return None
|
|
586
|
+
if name == "layout.wire":
|
|
587
|
+
return None
|
|
588
|
+
name = Path(name).stem
|
|
589
|
+
|
|
590
|
+
segment = name
|
|
591
|
+
if name == "index":
|
|
592
|
+
segment = ""
|
|
593
|
+
|
|
594
|
+
param_match = re.match(r"^\[(.*?)\]$", name)
|
|
595
|
+
if param_match:
|
|
596
|
+
param_name = param_match.group(1)
|
|
597
|
+
segment = f"{{{param_name}}}"
|
|
598
|
+
|
|
599
|
+
segments.append(segment)
|
|
600
|
+
|
|
601
|
+
route_path = "/" + "/".join(segments)
|
|
602
|
+
while "//" in route_path:
|
|
603
|
+
route_path = route_path.replace("//", "/")
|
|
604
|
+
|
|
605
|
+
if route_path != "/" and route_path.endswith("/"):
|
|
606
|
+
route_path = route_path.rstrip("/")
|
|
607
|
+
|
|
608
|
+
if not route_path:
|
|
609
|
+
route_path = "/"
|
|
610
|
+
|
|
611
|
+
return route_path
|
|
612
|
+
|
|
613
|
+
def _resolve_implicit_layout(self, page_path: Path) -> Optional[str]:
|
|
614
|
+
"""Resolve the implicit layout path for a given page."""
|
|
615
|
+
# Traverse up from page directory to pages_dir
|
|
616
|
+
current_dir = page_path.parent
|
|
617
|
+
|
|
618
|
+
# Ensure we don't traverse above pages_dir
|
|
619
|
+
try:
|
|
620
|
+
# Check if page is within pages_dir
|
|
621
|
+
current_dir.relative_to(self.pages_dir)
|
|
622
|
+
except ValueError:
|
|
623
|
+
# Page is outside pages_dir? Should not happen normally.
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
while True:
|
|
627
|
+
# Check for layout files
|
|
628
|
+
layout = current_dir / "__layout__.wire"
|
|
629
|
+
|
|
630
|
+
if layout.exists():
|
|
631
|
+
# Don't use layout if it is the file itself (e.g. reloading a layout file)
|
|
632
|
+
if layout.resolve() == page_path.resolve():
|
|
633
|
+
pass
|
|
634
|
+
else:
|
|
635
|
+
return str(layout.resolve())
|
|
636
|
+
|
|
637
|
+
if current_dir == self.pages_dir:
|
|
638
|
+
break
|
|
639
|
+
|
|
640
|
+
current_dir = current_dir.parent
|
|
641
|
+
|
|
642
|
+
# Safety check: stop at root
|
|
643
|
+
if current_dir == current_dir.parent:
|
|
644
|
+
break
|
|
645
|
+
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
def reload_page(self, path: Path) -> bool:
|
|
649
|
+
"""Reload and recompile a specific page and its dependents."""
|
|
650
|
+
# Invalidate cache for this file and dependents
|
|
651
|
+
invalidated_paths = self.loader.invalidate_cache(path)
|
|
652
|
+
|
|
653
|
+
# Always include the original path even if not in cache (to trigger load)
|
|
654
|
+
str_path = str(path.resolve())
|
|
655
|
+
if str_path not in invalidated_paths:
|
|
656
|
+
invalidated_paths.add(str_path)
|
|
657
|
+
|
|
658
|
+
for file_path_str in invalidated_paths:
|
|
659
|
+
file_path = Path(file_path_str)
|
|
660
|
+
|
|
661
|
+
is_in_pages = False
|
|
662
|
+
try:
|
|
663
|
+
file_path.relative_to(self.pages_dir)
|
|
664
|
+
is_in_pages = True
|
|
665
|
+
except ValueError:
|
|
666
|
+
is_in_pages = False
|
|
667
|
+
|
|
668
|
+
try:
|
|
669
|
+
# Resolve implicit layout for re-compilation
|
|
670
|
+
implicit_layout = self._resolve_implicit_layout(file_path)
|
|
671
|
+
|
|
672
|
+
# Recompile
|
|
673
|
+
new_page_class = self.loader.load(
|
|
674
|
+
file_path, implicit_layout=implicit_layout
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
self.router.remove_routes_for_file(str(file_path))
|
|
678
|
+
|
|
679
|
+
# Special handling for __error__.wire
|
|
680
|
+
if file_path.name == "__error__.wire":
|
|
681
|
+
self.router.add_route("/__error__", new_page_class)
|
|
682
|
+
elif is_in_pages:
|
|
683
|
+
self.router.add_page(new_page_class)
|
|
684
|
+
|
|
685
|
+
# Re-apply implicit routing if not explicitly defined
|
|
686
|
+
has_explicit = hasattr(new_page_class, "__routes__") or hasattr(
|
|
687
|
+
new_page_class, "__route__"
|
|
688
|
+
)
|
|
689
|
+
if not has_explicit and self.path_based_routing:
|
|
690
|
+
route_path = self._get_implicit_route(file_path)
|
|
691
|
+
if route_path:
|
|
692
|
+
self.router.add_route(route_path, new_page_class)
|
|
693
|
+
|
|
694
|
+
print(f"Reloaded: {file_path}")
|
|
695
|
+
|
|
696
|
+
except Exception as e:
|
|
697
|
+
logger.error(f"Failed to reload {file_path}: {e}", exc_info=True)
|
|
698
|
+
|
|
699
|
+
# If it was a page, show error
|
|
700
|
+
if is_in_pages or file_path.name == "__error__.wire":
|
|
701
|
+
self.router.remove_routes_for_file(str(file_path))
|
|
702
|
+
self._register_error_page(file_path, e)
|
|
703
|
+
|
|
704
|
+
# If original file failed, re-raise because the watcher expects it
|
|
705
|
+
if str(file_path) == str_path:
|
|
706
|
+
raise e
|
|
707
|
+
return True
|
|
708
|
+
|
|
709
|
+
async def _handle_500(self, request: Request, exc: Exception) -> Response:
|
|
710
|
+
"""Handle 500 errors with custom page if available."""
|
|
711
|
+
# Try to find /__error__ page
|
|
712
|
+
match = self.router.match("/__error__")
|
|
713
|
+
|
|
714
|
+
if match:
|
|
715
|
+
try:
|
|
716
|
+
page_class, params, variant_name = match
|
|
717
|
+
# Minimal context
|
|
718
|
+
page = page_class(request, params, {}, path={"main": True}, url=None)
|
|
719
|
+
# Inject error code
|
|
720
|
+
page.error_code = 500
|
|
721
|
+
# Inject exception details if debug mode?
|
|
722
|
+
if self.debug:
|
|
723
|
+
page.error_detail = str(exc)
|
|
724
|
+
page.error_trace = traceback.format_exc()
|
|
725
|
+
|
|
726
|
+
response = await page.render()
|
|
727
|
+
# Force 500 status
|
|
728
|
+
response.status_code = 500
|
|
729
|
+
return response
|
|
730
|
+
except Exception as e:
|
|
731
|
+
# If 500 page fails, fall back
|
|
732
|
+
print(f"Error rendering 500 page: {e}")
|
|
733
|
+
pass
|
|
734
|
+
|
|
735
|
+
# If no custom page or it failed:
|
|
736
|
+
if self.debug:
|
|
737
|
+
# Re-raise to let Starlette/Server show debug traceback
|
|
738
|
+
raise exc
|
|
739
|
+
|
|
740
|
+
return PlainTextResponse("Internal Server Error", status_code=500)
|
|
741
|
+
|
|
742
|
+
async def _handle_request(self, request: Request) -> Response:
|
|
743
|
+
"""Handle HTTP request."""
|
|
744
|
+
# Check for uploads first
|
|
745
|
+
# (This was handled in Route declarations, but uploads go to /_pywire/upload)
|
|
746
|
+
|
|
747
|
+
path = request.url.path
|
|
748
|
+
match = self.router.match(path)
|
|
749
|
+
if not match:
|
|
750
|
+
# Try custom __error__
|
|
751
|
+
match_error = self.router.match("/__error__")
|
|
752
|
+
|
|
753
|
+
if match_error:
|
|
754
|
+
page_class, params, variant_name = match_error
|
|
755
|
+
# Render 404/error page
|
|
756
|
+
# Note: We pass original request so URL is preserved?
|
|
757
|
+
# Yes, user checking request.url on 404 page might want to know what failed.
|
|
758
|
+
|
|
759
|
+
# Construct params/query
|
|
760
|
+
query = dict(request.query_params)
|
|
761
|
+
|
|
762
|
+
# Path info
|
|
763
|
+
path_info = {}
|
|
764
|
+
if hasattr(page_class, "__routes__"):
|
|
765
|
+
for name in page_class.__routes__.keys():
|
|
766
|
+
path_info[name] = name == variant_name
|
|
767
|
+
elif hasattr(page_class, "__route__"):
|
|
768
|
+
path_info["main"] = True
|
|
769
|
+
|
|
770
|
+
from pywire.runtime.router import URLHelper
|
|
771
|
+
|
|
772
|
+
url_helper = None
|
|
773
|
+
if hasattr(page_class, "__routes__"):
|
|
774
|
+
url_helper = URLHelper(page_class.__routes__)
|
|
775
|
+
|
|
776
|
+
try:
|
|
777
|
+
page = page_class(
|
|
778
|
+
request, {}, query, path=path_info, url=url_helper
|
|
779
|
+
)
|
|
780
|
+
# Inject error code
|
|
781
|
+
page.error_code = 404
|
|
782
|
+
response = await page.render()
|
|
783
|
+
response.status_code = 404
|
|
784
|
+
return response
|
|
785
|
+
except Exception as e:
|
|
786
|
+
print(f"Failed to render custom error page {page_class}: {e}")
|
|
787
|
+
import traceback
|
|
788
|
+
|
|
789
|
+
traceback.print_exc()
|
|
790
|
+
pass # Fallback
|
|
791
|
+
|
|
792
|
+
# Default 404 with client script
|
|
793
|
+
page = ErrorPage(
|
|
794
|
+
request, "404 Not Found", f"The path '{path}' could not be found."
|
|
795
|
+
)
|
|
796
|
+
response = await page.render()
|
|
797
|
+
response.status_code = 404
|
|
798
|
+
return response
|
|
799
|
+
|
|
800
|
+
page_class, params, variant_name = match
|
|
801
|
+
# ... (params, query, path_info, url_helper construction)
|
|
802
|
+
# Build query params
|
|
803
|
+
query = dict(request.query_params)
|
|
804
|
+
|
|
805
|
+
# Build path info dict
|
|
806
|
+
path_info = {}
|
|
807
|
+
if hasattr(page_class, "__routes__"):
|
|
808
|
+
for name in page_class.__routes__.keys():
|
|
809
|
+
path_info[name] = name == variant_name
|
|
810
|
+
elif hasattr(page_class, "__route__"):
|
|
811
|
+
path_info["main"] = True
|
|
812
|
+
|
|
813
|
+
# Build URL helper
|
|
814
|
+
from pywire.runtime.router import URLHelper
|
|
815
|
+
|
|
816
|
+
url_helper = None
|
|
817
|
+
if hasattr(page_class, "__routes__"):
|
|
818
|
+
url_helper = URLHelper(page_class.__routes__)
|
|
819
|
+
|
|
820
|
+
# Instantiate page
|
|
821
|
+
page = page_class(request, params, query, path=path_info, url=url_helper)
|
|
822
|
+
|
|
823
|
+
# Check if this is an event request
|
|
824
|
+
if request.method == "POST" and "X-PyWire-Event" in request.headers:
|
|
825
|
+
# Handle event
|
|
826
|
+
try:
|
|
827
|
+
event_data = await request.json()
|
|
828
|
+
update = await page.handle_event(
|
|
829
|
+
event_data.get("handler", ""), event_data
|
|
830
|
+
)
|
|
831
|
+
if isinstance(update, dict):
|
|
832
|
+
return JSONResponse(update)
|
|
833
|
+
response = cast(Response, update)
|
|
834
|
+
except Exception as e:
|
|
835
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
836
|
+
else:
|
|
837
|
+
# Normal render
|
|
838
|
+
response = await page.render()
|
|
839
|
+
|
|
840
|
+
# Script injection is now handled by the compiler (generator.py)
|
|
841
|
+
# to ensure it's present in both dev and production.
|
|
842
|
+
|
|
843
|
+
# Inject WebTransport certificate hash if available (Dev Mode)
|
|
844
|
+
if isinstance(response, Response) and response.media_type == "text/html":
|
|
845
|
+
body = bytes(response.body).decode("utf-8")
|
|
846
|
+
injections = []
|
|
847
|
+
|
|
848
|
+
# WebTransport Hash
|
|
849
|
+
if hasattr(request.app.state, "webtransport_cert_hash"):
|
|
850
|
+
cert_hash = list(request.app.state.webtransport_cert_hash)
|
|
851
|
+
injections.append(
|
|
852
|
+
f"<script>window.PYWIRE_CERT_HASH = {cert_hash};</script>"
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
# Upload Token Injection
|
|
856
|
+
if getattr(page, "__has_uploads__", False):
|
|
857
|
+
import secrets
|
|
858
|
+
|
|
859
|
+
token = secrets.token_urlsafe(32)
|
|
860
|
+
self.upload_tokens.add(token)
|
|
861
|
+
# Token meta tag
|
|
862
|
+
injections.append(
|
|
863
|
+
f'<meta name="pywire-upload-token" content="{token}">'
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
if injections:
|
|
867
|
+
injection_str = "\n".join(injections)
|
|
868
|
+
if "</body>" in body:
|
|
869
|
+
body = body.replace("</body>", injection_str + "</body>")
|
|
870
|
+
else:
|
|
871
|
+
body += injection_str
|
|
872
|
+
response = Response(body, media_type="text/html")
|
|
873
|
+
|
|
874
|
+
return response
|
|
875
|
+
|
|
876
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
877
|
+
"""ASGI interface."""
|
|
878
|
+
print(f"DEBUG: Scope type: {scope['type']}")
|
|
879
|
+
if scope["type"] == "webtransport":
|
|
880
|
+
await self.web_transport_handler.handle(scope, receive, send)
|
|
881
|
+
return
|
|
882
|
+
|
|
883
|
+
await self.app(scope, receive, send)
|
|
884
|
+
|
|
885
|
+
# --- Extensible Hooks ---
|
|
886
|
+
|
|
887
|
+
async def on_ws_connect(self, websocket: Any) -> bool:
|
|
888
|
+
"""
|
|
889
|
+
Hook called before WebSocket upgrade.
|
|
890
|
+
Return False to reject connection.
|
|
891
|
+
"""
|
|
892
|
+
return True
|
|
893
|
+
|
|
894
|
+
def get_user(self, request_or_websocket: Any) -> Any:
|
|
895
|
+
"""
|
|
896
|
+
Hook to populate page.user from request/websocket.
|
|
897
|
+
Override to return user from session/JWT.
|
|
898
|
+
"""
|
|
899
|
+
if "user" in request_or_websocket.scope:
|
|
900
|
+
return request_or_websocket.user
|
|
901
|
+
return None
|