Skip to content

Commit 3480475

Browse files
committed
Force user to add slashes to tail and head of path_prefix, and create ReactPyApp
1 parent b23f6b6 commit 3480475

File tree

4 files changed

+47
-45
lines changed

4 files changed

+47
-45
lines changed

src/js/packages/@reactpy/client/src/mount.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export function mountReactPy(props: MountProps) {
77
// WebSocket route for component rendering
88
const wsProtocol = `ws${window.location.protocol === "https:" ? "s" : ""}:`;
99
const wsOrigin = `${wsProtocol}//${window.location.host}`;
10-
const componentUrl = new URL(`${wsOrigin}/${props.pathPrefix}/${props.appendComponentPath || ""}`);
10+
const componentUrl = new URL(`${wsOrigin}${props.pathPrefix}${props.appendComponentPath || ""}`);
1111

1212
// Embed the initial HTTP path into the WebSocket URL
1313
componentUrl.searchParams.append("http_pathname", window.location.pathname);
@@ -19,7 +19,7 @@ export function mountReactPy(props: MountProps) {
1919
const client = new ReactPyClient({
2020
urls: {
2121
componentUrl: componentUrl,
22-
jsModulesPath: `${window.location.origin}/${props.pathPrefix}/modules/`,
22+
jsModulesPath: `${window.location.origin}${props.pathPrefix}modules/`,
2323
queryString: document.location.search,
2424
},
2525
reconnectOptions: {

src/reactpy/backend/middleware.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from servestatic import ServeStaticASGI
1616

1717
from reactpy.backend.types import Connection, Location
18-
from reactpy.backend.utils import check_path, import_components, normalize_url_path
18+
from reactpy.backend.utils import check_path, import_components
1919
from reactpy.config import REACTPY_WEB_MODULES_DIR
2020
from reactpy.core.hooks import ConnectionContext
2121
from reactpy.core.layout import Layout
@@ -37,15 +37,15 @@ def __init__(
3737
root_components: Iterable[str],
3838
*,
3939
# TODO: Add a setting attribute to this class. Or maybe just put a shit ton of kwargs here. Or add a **kwargs that resolves to a TypedDict?
40-
path_prefix: str = "reactpy/",
40+
path_prefix: str = "/reactpy/",
4141
web_modules_dir: Path | None = None,
4242
) -> None:
4343
"""Configure the ASGI app. Anything initialized in this method will be shared across all future requests."""
4444
# URL path attributes
45-
self.path_prefix = normalize_url_path(path_prefix)
46-
self.dispatcher_path = f"/{self.path_prefix}/"
47-
self.web_modules_path = f"/{self.path_prefix}/modules/"
48-
self.static_path = f"/{self.path_prefix}/static/"
45+
self.path_prefix = path_prefix
46+
self.dispatcher_path = self.path_prefix
47+
self.web_modules_path = f"{self.path_prefix}modules/"
48+
self.static_path = f"{self.path_prefix}static/"
4949
self.dispatcher_pattern = re.compile(
5050
f"^{self.dispatcher_path}(?P<dotted_path>[^/]+)/?"
5151
)
@@ -161,6 +161,7 @@ async def run_dispatcher(
161161
else:
162162
raise RuntimeError("No root component provided.")
163163

164+
# TODO: Get HTTP URL from `http_pathname` and `http_query_string`
164165
parsed_url = urllib.parse.urlparse(scope["path"])
165166

166167
await serve_layout(
@@ -169,6 +170,7 @@ async def run_dispatcher(
169170
component(),
170171
value=Connection(
171172
scope=scope,
173+
# TODO: Rename `search` to `query_string`
172174
location=Location(
173175
parsed_url.path,
174176
f"?{parsed_url.query}" if parsed_url.query else "",

src/reactpy/backend/standalone.py

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,36 @@
22
import os
33
import re
44
from collections.abc import Coroutine, Sequence
5+
from dataclasses import dataclass
56
from email.utils import formatdate
67
from logging import getLogger
78
from pathlib import Path
89
from typing import Any, Callable
910

1011
from reactpy import html
1112
from reactpy.backend.middleware import ReactPyMiddleware
12-
from reactpy.backend.utils import dict_to_byte_list, find_and_replace, vdom_head_to_html
13+
from reactpy.backend.utils import dict_to_byte_list, replace_many, vdom_head_to_html
1314
from reactpy.core.types import VdomDict
1415
from reactpy.types import RootComponentConstructor
1516

1617
_logger = getLogger(__name__)
1718

1819

1920
class ReactPy(ReactPyMiddleware):
20-
cached_index_html = ""
21-
etag = ""
22-
last_modified = ""
23-
templates_dir = Path(__file__).parent.parent / "templates"
24-
index_html_path = templates_dir / "index.html"
2521
multiple_root_components = False
2622

2723
def __init__(
2824
self,
2925
root_component: RootComponentConstructor,
3026
*,
31-
path_prefix: str = "reactpy/",
27+
path_prefix: str = "/reactpy/",
3228
web_modules_dir: Path | None = None,
3329
http_headers: dict[str, str | int] | None = None,
3430
html_head: VdomDict | None = None,
3531
html_lang: str = "en",
3632
) -> None:
3733
super().__init__(
38-
app=self.reactpy_app,
34+
app=ReactPyApp(self),
3935
root_components=[],
4036
path_prefix=path_prefix,
4137
web_modules_dir=web_modules_dir,
@@ -46,7 +42,17 @@ def __init__(
4642
self.html_head = html_head or html.head()
4743
self.html_lang = html_lang
4844

49-
async def reactpy_app(
45+
46+
@dataclass
47+
class ReactPyApp:
48+
parent: ReactPy
49+
_cached_index_html = ""
50+
_etag = ""
51+
_last_modified = ""
52+
_templates_dir = Path(__file__).parent.parent / "templates"
53+
_index_html_path = _templates_dir / "index.html"
54+
55+
async def __call__(
5056
self,
5157
scope: dict[str, Any],
5258
receive: Callable[..., Coroutine],
@@ -65,18 +71,18 @@ async def reactpy_app(
6571
return
6672

6773
# Store the HTTP response in memory for performance
68-
if not self.cached_index_html:
74+
if not self._cached_index_html:
6975
self.process_index_html()
7076

7177
# Return headers for all HTTP responses
7278
request_headers = dict(scope["headers"])
7379
response_headers: dict[str, str | int] = {
74-
"etag": self.etag,
75-
"last-modified": self.last_modified,
80+
"etag": self._etag,
81+
"last-modified": self._last_modified,
7682
"access-control-allow-origin": "*",
7783
"cache-control": "max-age=60, public",
78-
"content-length": len(self.cached_index_html),
79-
**self.extra_headers,
84+
"content-length": len(self._cached_index_html),
85+
**self.parent.extra_headers,
8086
}
8187

8288
# Browser is asking for the headers
@@ -91,7 +97,7 @@ async def reactpy_app(
9197
)
9298

9399
# Browser already has the content cached
94-
if request_headers.get(b"if-none-match") == self.etag.encode():
100+
if request_headers.get(b"if-none-match") == self._etag.encode():
95101
response_headers.pop("content-length")
96102
return await http_response(
97103
scope["method"],
@@ -107,37 +113,37 @@ async def reactpy_app(
107113
scope["method"],
108114
send,
109115
200,
110-
self.cached_index_html,
116+
self._cached_index_html,
111117
content_type=b"text/html",
112118
headers=dict_to_byte_list(response_headers),
113119
)
114120

115121
def match_dispatch_path(self, scope: dict) -> bool:
116122
"""Method override to remove `dotted_path` from the dispatcher URL."""
117-
return str(scope["path"]) == self.dispatcher_path
123+
return str(scope["path"]) == self.parent.dispatcher_path
118124

119125
def process_index_html(self):
120126
"""Process the index.html and store the results in memory."""
121-
with open(self.index_html_path, encoding="utf-8") as file_handle:
127+
with open(self._index_html_path, encoding="utf-8") as file_handle:
122128
cached_index_html = file_handle.read()
123129

124-
self.cached_index_html = find_and_replace(
130+
self._cached_index_html = replace_many(
125131
cached_index_html,
126132
{
127-
'from "index.ts"': f'from "{self.static_path}index.js"',
128-
'<html lang="en">': f'<html lang="{self.html_lang}">',
129-
"<head></head>": vdom_head_to_html(self.html_head),
130-
"{path_prefix}": self.path_prefix,
133+
'from "index.ts"': f'from "{self.parent.static_path}index.js"',
134+
'<html lang="en">': f'<html lang="{self.parent.html_lang}">',
135+
"<head></head>": vdom_head_to_html(self.parent.html_head),
136+
"{path_prefix}": self.parent.path_prefix,
131137
"{reconnect_interval}": "750",
132138
"{reconnect_max_interval}": "60000",
133139
"{reconnect_max_retries}": "150",
134140
"{reconnect_backoff_multiplier}": "1.25",
135141
},
136142
)
137143

138-
self.etag = f'"{hashlib.md5(self.cached_index_html.encode(), usedforsecurity=False).hexdigest()}"'
139-
self.last_modified = formatdate(
140-
os.stat(self.index_html_path).st_mtime, usegmt=True
144+
self._etag = f'"{hashlib.md5(self._cached_index_html.encode(), usedforsecurity=False).hexdigest()}"'
145+
self._last_modified = formatdate(
146+
os.stat(self._index_html_path).st_mtime, usegmt=True
141147
)
142148

143149

src/reactpy/backend/utils.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,6 @@
1212
logger = logging.getLogger(__name__)
1313

1414

15-
def normalize_url_path(url: str) -> str:
16-
"""Normalize a URL path."""
17-
new_url = re.sub(r"/+", "/", url)
18-
new_url = new_url.lstrip("/")
19-
new_url = new_url.rstrip("/")
20-
return new_url
21-
22-
2315
def import_dotted_path(dotted_path: str) -> Any:
2416
"""Imports a dotted path and returns the callable."""
2517
module_name, component_name = dotted_path.rsplit(".", 1)
@@ -58,13 +50,15 @@ def check_path(url_path: str) -> str:
5850
return "URL path must not be empty."
5951
if not isinstance(url_path, str):
6052
return "URL path is must be a string."
61-
if not url_path[0].isalnum():
62-
return "URL path must start with an alphanumeric character."
53+
if not url_path.startswith("/"):
54+
return "URL path must start with a forward slash."
55+
if not url_path.endswith("/"):
56+
return "URL path must end with a forward slash."
6357

6458
return ""
6559

6660

67-
def find_and_replace(content: str, replacements: dict[str, str]) -> str:
61+
def replace_many(content: str, replacements: dict[str, str]) -> str:
6862
"""Find and replace several key-values, and throw and error if the substring is not found."""
6963
for key, value in replacements.items():
7064
if key not in content:

0 commit comments

Comments
 (0)