From 252489e358a24f24f390fcbc9793747f67df915e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 17 Jul 2023 22:26:18 -0700 Subject: [PATCH 01/22] everything besides component_dispatch_app --- src/py/reactpy/reactpy/backend/asgi.py | 259 ++++ src/py/reactpy/reactpy/backend/mimetypes.py | 1203 +++++++++++++++++++ 2 files changed, 1462 insertions(+) create mode 100644 src/py/reactpy/reactpy/backend/asgi.py create mode 100644 src/py/reactpy/reactpy/backend/mimetypes.py diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py new file mode 100644 index 000000000..b03802783 --- /dev/null +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -0,0 +1,259 @@ +import logging +import mimetypes +import os +import re +from pathlib import Path +from typing import Sequence + +import aiofiles +from asgiref.compatibility import guarantee_single_callable + +from reactpy.backend._common import ( + CLIENT_BUILD_DIR, + traversal_safe_path, + vdom_head_elements_to_html, +) +from reactpy.backend.mimetypes import DEFAULT_MIME_TYPES +from reactpy.config import REACTPY_WEB_MODULES_DIR +from reactpy.core.types import VdomDict + +DEFAULT_STATIC_PATH = f"{os.getcwd()}/static" +DEFAULT_BLOCK_SIZE = 8192 +_logger = logging.getLogger(__name__) + + +class ReactPy: + def __init__( + self, + application=None, + dispatcher_path: str = "^reactpy/stream/([^/]+)/?", + js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", + static_path: str | None = "^reactpy/static/([^/]+)/?", + static_dir: str | None = DEFAULT_STATIC_PATH, + head: Sequence[VdomDict] | VdomDict | str = "", + ) -> None: + self.user_app = guarantee_single_callable(application) + self.dispatch_path = re.compile(dispatcher_path) + self.js_modules_path = re.compile(js_modules_path) + self.static_path = re.compile(static_path) + self.static_dir = static_dir + self.all_paths = re.compile( + "|".join( + path for path in [dispatcher_path, js_modules_path, static_path] if path + ) + ) + self.head = vdom_head_elements_to_html(head) + self._cached_index_html = "" + + async def __call__(self, scope, receive, send) -> None: + """The ASGI callable. This determines whether ReactPy should route the the + request to ourselves or to the user application.""" + + # Determine if ReactPy should handle the request + if not self.user_app or re.match(self.all_paths, scope["path"]): + # Dispatch a Python component + if scope["type"] == "websocket" and re.match( + self.dispatch_path, scope["path"] + ): + await self.component_dispatch_app(scope, receive, send) + return + + # User tried to use an unsupported HTTP method + if scope["method"] not in ("GET", "HEAD"): + await simple_response( + scope, send, status=405, content="Method Not Allowed" + ) + return + + # Serve a JS web module + if scope["type"] == "http" and re.match( + self.js_modules_path, scope["path"] + ): + await self.js_modules_app(scope, receive, send) + return + + # Serve a static file + if scope["type"] == "http" and re.match(self.static_path, scope["path"]): + await self.static_file_app(scope, receive, send) + return + + # Serve index.html + if scope["type"] == "http": + await self.index_html_app(scope, receive, send) + return + + # Serve the user's application + else: + await self.user_app(scope, receive, send) + + async def component_dispatch_app(self, scope, receive, send) -> None: + """The ASGI application for ReactPy Python components.""" + + async def js_modules_app(self, scope, receive, send) -> None: + """The ASGI application for ReactPy web modules.""" + + if not REACTPY_WEB_MODULES_DIR.current: + raise RuntimeError("No web modules directory configured") + + # Get the relative file path from the URL + file_url_path = re.match(self.js_modules_path, scope["path"])[1] + + # Make sure the user hasn't tried to escape the web modules directory + try: + file_path = traversal_safe_path( + REACTPY_WEB_MODULES_DIR.current, + REACTPY_WEB_MODULES_DIR.current, + file_url_path, + ) + except ValueError: + await simple_response(send, 403, "Forbidden") + return + + # Serve the file + await file_response(scope, send, file_path) + + async def static_file_app(self, scope, receive, send) -> None: + """The ASGI application for ReactPy static files.""" + + if self.static_dir is None: + raise RuntimeError("No static directory configured") + + # Get the relative file path from the URL + file_url_path = re.match(self.static_path, scope["path"])[1] + + # Make sure the user hasn't tried to escape the static directory + try: + file_path = traversal_safe_path( + self.static_dir, self.static_dir, file_url_path + ) + except ValueError: + await simple_response(send, 403, "Forbidden") + return + + # Serve the file + await file_response(scope, send, file_path) + + async def index_html_app(self, scope, receive, send) -> None: + """The ASGI application for ReactPy index.html.""" + + # TODO: We want to respect the if-modified-since header, but currently can't + # due to the fact that our HTML is not statically rendered. + file_path = CLIENT_BUILD_DIR / "index.html" + if not self._cached_index_html: + async with aiofiles.open(file_path, "rb") as file_handle: + self._cached_index_html = str(await file_handle.read()).format( + __head__=self.head + ) + + # Head requests don't need a body + if scope["method"] == "HEAD": + await simple_response( + send, + 200, + "", + content_type=b"text/html", + headers=[(b"cache-control", b"no-cache")], + ) + return + + # Send the index.html + await simple_response( + send, + 200, + self._cached_index_html, + content_type=b"text/html", + headers=[(b"cache-control", b"no-cache")], + ) + + +async def simple_response( + send, + code: int, + message: str, + content_type: bytes = b"text/plain", + headers: Sequence = (), +) -> None: + """Send a simple response.""" + + await send( + { + "type": "http.response.start", + "status": code, + "headers": [(b"content-type", content_type, *headers)], + } + ) + await send({"type": "http.response.body", "body": message.encode()}) + + +async def file_response(scope, send, file_path: Path) -> None: + """Send a file in chunks.""" + + # Make sure the file exists + if not os.path.exists(file_path): + await simple_response(send, 404, "File not found.") + return + + # Make sure it's a file + if not os.path.isfile(file_path): + await simple_response(send, 400, "Not a file.") + return + + # Check if the file is already cached by the client + modified_since = await get_val_from_header(scope, b"if-modified-since") + if modified_since and modified_since > os.path.getmtime(file_path): + await simple_response(send, 304, "Not modified.") + return + + # Get the file's MIME type + mime_type = ( + DEFAULT_MIME_TYPES.get(file_path.rsplit(".")[1], None) + or mimetypes.guess_type(file_path, strict=False)[0] + ) + if mime_type is None: + mime_type = "text/plain" + _logger.error(f"Could not determine MIME type for {file_path}.") + + # Send the file in chunks + async with aiofiles.open(file_path, "rb") as file_handle: + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + (b"content-type", mime_type.encode()), + (b"last-modified", str(os.path.getmtime(file_path)).encode()), + ], + } + ) + + # Head requests don't need a body + if scope["method"] == "HEAD": + return + + while True: + chunk = await file_handle.read(DEFAULT_BLOCK_SIZE) + more_body = bool(chunk) + await send( + { + "type": "http.response.body", + "body": chunk, + "more_body": more_body, + } + ) + if not more_body: + break + + +async def get_val_from_header( + scope: dict, key: str, default: str | None = None +) -> str | None: + """Get a value from a scope's headers.""" + + return await anext( + ( + value.decode() + for header_key, value in scope["headers"] + if header_key == key.encode() + ), + default, + ) diff --git a/src/py/reactpy/reactpy/backend/mimetypes.py b/src/py/reactpy/reactpy/backend/mimetypes.py new file mode 100644 index 000000000..9c8a97088 --- /dev/null +++ b/src/py/reactpy/reactpy/backend/mimetypes.py @@ -0,0 +1,1203 @@ +DEFAULT_MIME_TYPES = { + "123": "application/vnd.lotus-1-2-3", + "1km": "application/vnd.1000minds.decision-model+xml", + "3dml": "text/vnd.in3d.3dml", + "3ds": "image/x-3ds", + "3g2": "video/3gpp2", + "3gp": "video/3gpp", + "3gpp": "video/3gpp", + "3mf": "model/3mf", + "7z": "application/x-7z-compressed", + "aab": "application/x-authorware-bin", + "aac": "audio/x-aac", + "aam": "application/x-authorware-map", + "aas": "application/x-authorware-seg", + "abw": "application/x-abiword", + "ac": "application/vnd.nokia.n-gage.ac+xml", + "acc": "application/vnd.americandynamics.acc", + "ace": "application/x-ace-compressed", + "acu": "application/vnd.acucobol", + "acutc": "application/vnd.acucorp", + "adp": "audio/adpcm", + "adts": "audio/aac", + "aep": "application/vnd.audiograph", + "afm": "application/x-font-type1", + "afp": "application/vnd.ibm.modcap", + "age": "application/vnd.age", + "ahead": "application/vnd.ahead.space", + "ai": "application/postscript", + "aif": "audio/x-aiff", + "aifc": "audio/x-aiff", + "aiff": "audio/x-aiff", + "air": "application/vnd.adobe.air-application-installer-package+zip", + "ait": "application/vnd.dvb.ait", + "ami": "application/vnd.amiga.ami", + "aml": "application/automationml-aml+xml", + "amlx": "application/automationml-amlx+zip", + "amr": "audio/amr", + "apk": "application/vnd.android.package-archive", + "apng": "image/apng", + "appcache": "text/cache-manifest", + "appinstaller": "application/appinstaller", + "application": "application/x-ms-application", + "appx": "application/appx", + "appxbundle": "application/appxbundle", + "apr": "application/vnd.lotus-approach", + "arc": "application/x-freearc", + "arj": "application/x-arj", + "asc": "application/pgp-signature", + "asf": "video/x-ms-asf", + "asm": "text/x-asm", + "aso": "application/vnd.accpac.simply.aso", + "asx": "video/x-ms-asf", + "atc": "application/vnd.acucorp", + "atom": "application/atom+xml", + "atomcat": "application/atomcat+xml", + "atomdeleted": "application/atomdeleted+xml", + "atomsvc": "application/atomsvc+xml", + "atx": "application/vnd.antix.game-component", + "au": "audio/basic", + "avci": "image/avci", + "avcs": "image/avcs", + "avi": "video/x-msvideo", + "avif": "image/avif", + "aw": "application/applixware", + "azf": "application/vnd.airzip.filesecure.azf", + "azs": "application/vnd.airzip.filesecure.azs", + "azv": "image/vnd.airzip.accelerator.azv", + "azw": "application/vnd.amazon.ebook", + "b16": "image/vnd.pco.b16", + "bat": "application/x-msdownload", + "bcpio": "application/x-bcpio", + "bdf": "application/x-font-bdf", + "bdm": "application/vnd.syncml.dm+wbxml", + "bdoc": "application/x-bdoc", + "bed": "application/vnd.realvnc.bed", + "bh2": "application/vnd.fujitsu.oasysprs", + "bin": "application/octet-stream", + "blb": "application/x-blorb", + "blorb": "application/x-blorb", + "bmi": "application/vnd.bmi", + "bmml": "application/vnd.balsamiq.bmml+xml", + "bmp": "image/x-ms-bmp", + "book": "application/vnd.framemaker", + "box": "application/vnd.previewsystems.box", + "boz": "application/x-bzip2", + "bpk": "application/octet-stream", + "bsp": "model/vnd.valve.source.compiled-map", + "btf": "image/prs.btif", + "btif": "image/prs.btif", + "buffer": "application/octet-stream", + "bz": "application/x-bzip", + "bz2": "application/x-bzip2", + "c": "text/x-c", + "c11amc": "application/vnd.cluetrust.cartomobile-config", + "c11amz": "application/vnd.cluetrust.cartomobile-config-pkg", + "c4d": "application/vnd.clonk.c4group", + "c4f": "application/vnd.clonk.c4group", + "c4g": "application/vnd.clonk.c4group", + "c4p": "application/vnd.clonk.c4group", + "c4u": "application/vnd.clonk.c4group", + "cab": "application/vnd.ms-cab-compressed", + "caf": "audio/x-caf", + "cap": "application/vnd.tcpdump.pcap", + "car": "application/vnd.curl.car", + "cat": "application/vnd.ms-pki.seccat", + "cb7": "application/x-cbr", + "cba": "application/x-cbr", + "cbr": "application/x-cbr", + "cbt": "application/x-cbr", + "cbz": "application/x-cbr", + "cc": "text/x-c", + "cco": "application/x-cocoa", + "cct": "application/x-director", + "ccxml": "application/ccxml+xml", + "cdbcmsg": "application/vnd.contact.cmsg", + "cdf": "application/x-netcdf", + "cdfx": "application/cdfx+xml", + "cdkey": "application/vnd.mediastation.cdkey", + "cdmia": "application/cdmi-capability", + "cdmic": "application/cdmi-container", + "cdmid": "application/cdmi-domain", + "cdmio": "application/cdmi-object", + "cdmiq": "application/cdmi-queue", + "cdx": "chemical/x-cdx", + "cdxml": "application/vnd.chemdraw+xml", + "cdy": "application/vnd.cinderella", + "cer": "application/pkix-cert", + "cfs": "application/x-cfs-compressed", + "cgm": "image/cgm", + "chat": "application/x-chat", + "chm": "application/vnd.ms-htmlhelp", + "chrt": "application/vnd.kde.kchart", + "cif": "chemical/x-cif", + "cii": "application/vnd.anser-web-certificate-issue-initiation", + "cil": "application/vnd.ms-artgalry", + "cjs": "application/node", + "cla": "application/vnd.claymore", + "class": "application/java-vm", + "cld": "model/vnd.cld", + "clkk": "application/vnd.crick.clicker.keyboard", + "clkp": "application/vnd.crick.clicker.palette", + "clkt": "application/vnd.crick.clicker.template", + "clkw": "application/vnd.crick.clicker.wordbank", + "clkx": "application/vnd.crick.clicker", + "clp": "application/x-msclip", + "cmc": "application/vnd.cosmocaller", + "cmdf": "chemical/x-cmdf", + "cml": "chemical/x-cml", + "cmp": "application/vnd.yellowriver-custom-menu", + "cmx": "image/x-cmx", + "cod": "application/vnd.rim.cod", + "coffee": "text/coffeescript", + "com": "application/x-msdownload", + "conf": "text/plain", + "cpio": "application/x-cpio", + "cpl": "application/cpl+xml", + "cpp": "text/x-c", + "cpt": "application/mac-compactpro", + "crd": "application/x-mscardfile", + "crl": "application/pkix-crl", + "crt": "application/x-x509-ca-cert", + "crx": "application/x-chrome-extension", + "cryptonote": "application/vnd.rig.cryptonote", + "csh": "application/x-csh", + "csl": "application/vnd.citationstyles.style+xml", + "csml": "chemical/x-csml", + "csp": "application/vnd.commonspace", + "css": "text/css", + "cst": "application/x-director", + "csv": "text/csv", + "cu": "application/cu-seeme", + "curl": "text/vnd.curl", + "cwl": "application/cwl", + "cww": "application/prs.cww", + "cxt": "application/x-director", + "cxx": "text/x-c", + "dae": "model/vnd.collada+xml", + "daf": "application/vnd.mobius.daf", + "dart": "application/vnd.dart", + "dataless": "application/vnd.fdsn.seed", + "davmount": "application/davmount+xml", + "dbf": "application/vnd.dbf", + "dbk": "application/docbook+xml", + "dcr": "application/x-director", + "dcurl": "text/vnd.curl.dcurl", + "dd2": "application/vnd.oma.dd2+xml", + "ddd": "application/vnd.fujixerox.ddd", + "ddf": "application/vnd.syncml.dmddf+xml", + "dds": "image/vnd.ms-dds", + "deb": "application/x-debian-package", + "def": "text/plain", + "deploy": "application/octet-stream", + "der": "application/x-x509-ca-cert", + "dfac": "application/vnd.dreamfactory", + "dgc": "application/x-dgc-compressed", + "dib": "image/bmp", + "dic": "text/x-c", + "dir": "application/x-director", + "dis": "application/vnd.mobius.dis", + "disposition-notification": "message/disposition-notification", + "dist": "application/octet-stream", + "distz": "application/octet-stream", + "djv": "image/vnd.djvu", + "djvu": "image/vnd.djvu", + "dll": "application/x-msdownload", + "dmg": "application/x-apple-diskimage", + "dmp": "application/vnd.tcpdump.pcap", + "dms": "application/octet-stream", + "dna": "application/vnd.dna", + "doc": "application/msword", + "docm": "application/vnd.ms-word.document.macroenabled.12", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "dot": "application/msword", + "dotm": "application/vnd.ms-word.template.macroenabled.12", + "dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "dp": "application/vnd.osgi.dp", + "dpg": "application/vnd.dpgraph", + "dpx": "image/dpx", + "dra": "audio/vnd.dra", + "drle": "image/dicom-rle", + "dsc": "text/prs.lines.tag", + "dssc": "application/dssc+der", + "dtb": "application/x-dtbook+xml", + "dtd": "application/xml-dtd", + "dts": "audio/vnd.dts", + "dtshd": "audio/vnd.dts.hd", + "dump": "application/octet-stream", + "dvb": "video/vnd.dvb.file", + "dvi": "application/x-dvi", + "dwd": "application/atsc-dwd+xml", + "dwf": "model/vnd.dwf", + "dwg": "image/vnd.dwg", + "dxf": "image/vnd.dxf", + "dxp": "application/vnd.spotfire.dxp", + "dxr": "application/x-director", + "ear": "application/java-archive", + "ecelp4800": "audio/vnd.nuera.ecelp4800", + "ecelp7470": "audio/vnd.nuera.ecelp7470", + "ecelp9600": "audio/vnd.nuera.ecelp9600", + "ecma": "application/ecmascript", + "edm": "application/vnd.novadigm.edm", + "edx": "application/vnd.novadigm.edx", + "efif": "application/vnd.picsel", + "ei6": "application/vnd.pg.osasli", + "elc": "application/octet-stream", + "emf": "image/emf", + "eml": "message/rfc822", + "emma": "application/emma+xml", + "emotionml": "application/emotionml+xml", + "emz": "application/x-msmetafile", + "eol": "audio/vnd.digital-winds", + "eot": "application/vnd.ms-fontobject", + "eps": "application/postscript", + "epub": "application/epub+zip", + "es3": "application/vnd.eszigno3+xml", + "esa": "application/vnd.osgi.subsystem", + "esf": "application/vnd.epson.esf", + "et3": "application/vnd.eszigno3+xml", + "etx": "text/x-setext", + "eva": "application/x-eva", + "evy": "application/x-envoy", + "exe": "application/x-msdownload", + "exi": "application/exi", + "exp": "application/express", + "exr": "image/aces", + "ext": "application/vnd.novadigm.ext", + "ez": "application/andrew-inset", + "ez2": "application/vnd.ezpix-album", + "ez3": "application/vnd.ezpix-package", + "f": "text/x-fortran", + "f4v": "video/x-f4v", + "f77": "text/x-fortran", + "f90": "text/x-fortran", + "fbs": "image/vnd.fastbidsheet", + "fcdt": "application/vnd.adobe.formscentral.fcdt", + "fcs": "application/vnd.isac.fcs", + "fdf": "application/vnd.fdf", + "fdt": "application/fdt+xml", + "fe_launch": "application/vnd.denovo.fcselayout-link", + "fg5": "application/vnd.fujitsu.oasysgp", + "fgd": "application/x-director", + "fh": "image/x-freehand", + "fh4": "image/x-freehand", + "fh5": "image/x-freehand", + "fh7": "image/x-freehand", + "fhc": "image/x-freehand", + "fig": "application/x-xfig", + "fits": "image/fits", + "flac": "audio/x-flac", + "fli": "video/x-fli", + "flo": "application/vnd.micrografx.flo", + "flv": "video/x-flv", + "flw": "application/vnd.kde.kivio", + "flx": "text/vnd.fmi.flexstor", + "fly": "text/vnd.fly", + "fm": "application/vnd.framemaker", + "fnc": "application/vnd.frogans.fnc", + "fo": "application/vnd.software602.filler.form+xml", + "for": "text/x-fortran", + "fpx": "image/vnd.fpx", + "frame": "application/vnd.framemaker", + "fsc": "application/vnd.fsc.weblaunch", + "fst": "image/vnd.fst", + "ftc": "application/vnd.fluxtime.clip", + "fti": "application/vnd.anser-web-funds-transfer-initiation", + "fvt": "video/vnd.fvt", + "fxp": "application/vnd.adobe.fxp", + "fxpl": "application/vnd.adobe.fxp", + "fzs": "application/vnd.fuzzysheet", + "g2w": "application/vnd.geoplan", + "g3": "image/g3fax", + "g3w": "application/vnd.geospace", + "gac": "application/vnd.groove-account", + "gam": "application/x-tads", + "gbr": "application/rpki-ghostbusters", + "gca": "application/x-gca-compressed", + "gdl": "model/vnd.gdl", + "gdoc": "application/vnd.google-apps.document", + "ged": "text/vnd.familysearch.gedcom", + "geo": "application/vnd.dynageo", + "geojson": "application/geo+json", + "gex": "application/vnd.geometry-explorer", + "ggb": "application/vnd.geogebra.file", + "ggt": "application/vnd.geogebra.tool", + "ghf": "application/vnd.groove-help", + "gif": "image/gif", + "gim": "application/vnd.groove-identity-message", + "glb": "model/gltf-binary", + "gltf": "model/gltf+json", + "gml": "application/gml+xml", + "gmx": "application/vnd.gmx", + "gnumeric": "application/x-gnumeric", + "gph": "application/vnd.flographit", + "gpx": "application/gpx+xml", + "gqf": "application/vnd.grafeq", + "gqs": "application/vnd.grafeq", + "gram": "application/srgs", + "gramps": "application/x-gramps-xml", + "gre": "application/vnd.geometry-explorer", + "grv": "application/vnd.groove-injector", + "grxml": "application/srgs+xml", + "gsf": "application/x-font-ghostscript", + "gsheet": "application/vnd.google-apps.spreadsheet", + "gslides": "application/vnd.google-apps.presentation", + "gtar": "application/x-gtar", + "gtm": "application/vnd.groove-tool-message", + "gtw": "model/vnd.gtw", + "gv": "text/vnd.graphviz", + "gxf": "application/gxf", + "gxt": "application/vnd.geonext", + "gz": "application/gzip", + "h": "text/x-c", + "h261": "video/h261", + "h263": "video/h263", + "h264": "video/h264", + "hal": "application/vnd.hal+xml", + "hbci": "application/vnd.hbci", + "hbs": "text/x-handlebars-template", + "hdd": "application/x-virtualbox-hdd", + "hdf": "application/x-hdf", + "heic": "image/heic", + "heics": "image/heic-sequence", + "heif": "image/heif", + "heifs": "image/heif-sequence", + "hej2": "image/hej2k", + "held": "application/atsc-held+xml", + "hh": "text/x-c", + "hjson": "application/hjson", + "hlp": "application/winhlp", + "hpgl": "application/vnd.hp-hpgl", + "hpid": "application/vnd.hp-hpid", + "hps": "application/vnd.hp-hps", + "hqx": "application/mac-binhex40", + "hsj2": "image/hsj2", + "htc": "text/x-component", + "htke": "application/vnd.kenameaapp", + "htm": "text/html", + "html": "text/html", + "hvd": "application/vnd.yamaha.hv-dic", + "hvp": "application/vnd.yamaha.hv-voice", + "hvs": "application/vnd.yamaha.hv-script", + "i2g": "application/vnd.intergeo", + "icc": "application/vnd.iccprofile", + "ice": "x-conference/x-cooltalk", + "icm": "application/vnd.iccprofile", + "ico": "image/x-icon", + "ics": "text/calendar", + "ief": "image/ief", + "ifb": "text/calendar", + "ifm": "application/vnd.shana.informed.formdata", + "iges": "model/iges", + "igl": "application/vnd.igloader", + "igm": "application/vnd.insors.igm", + "igs": "model/iges", + "igx": "application/vnd.micrografx.igx", + "iif": "application/vnd.shana.informed.interchange", + "img": "application/octet-stream", + "imp": "application/vnd.accpac.simply.imp", + "ims": "application/vnd.ms-ims", + "in": "text/plain", + "ini": "text/plain", + "ink": "application/inkml+xml", + "inkml": "application/inkml+xml", + "install": "application/x-install-instructions", + "iota": "application/vnd.astraea-software.iota", + "ipfix": "application/ipfix", + "ipk": "application/vnd.shana.informed.package", + "irm": "application/vnd.ibm.rights-management", + "irp": "application/vnd.irepository.package+xml", + "iso": "application/x-iso9660-image", + "itp": "application/vnd.shana.informed.formtemplate", + "its": "application/its+xml", + "ivp": "application/vnd.immervision-ivp", + "ivu": "application/vnd.immervision-ivu", + "jad": "text/vnd.sun.j2me.app-descriptor", + "jade": "text/jade", + "jam": "application/vnd.jam", + "jar": "application/java-archive", + "jardiff": "application/x-java-archive-diff", + "java": "text/x-java-source", + "jhc": "image/jphc", + "jisp": "application/vnd.jisp", + "jls": "image/jls", + "jlt": "application/vnd.hp-jlyt", + "jng": "image/x-jng", + "jnlp": "application/x-java-jnlp-file", + "joda": "application/vnd.joost.joda-archive", + "jp2": "image/jp2", + "jpe": "image/jpeg", + "jpeg": "image/jpeg", + "jpf": "image/jpx", + "jpg": "image/jpeg", + "jpg2": "image/jp2", + "jpgm": "video/jpm", + "jpgv": "video/jpeg", + "jph": "image/jph", + "jpm": "video/jpm", + "jpx": "image/jpx", + "js": "text/javascript", + "json": "application/json", + "json5": "application/json5", + "jsonld": "application/ld+json", + "jsonml": "application/jsonml+json", + "jsx": "text/jsx", + "jt": "model/jt", + "jxr": "image/jxr", + "jxra": "image/jxra", + "jxrs": "image/jxrs", + "jxs": "image/jxs", + "jxsc": "image/jxsc", + "jxsi": "image/jxsi", + "jxss": "image/jxss", + "kar": "audio/midi", + "karbon": "application/vnd.kde.karbon", + "kdbx": "application/x-keepass2", + "key": "application/x-iwork-keynote-sffkey", + "kfo": "application/vnd.kde.kformula", + "kia": "application/vnd.kidspiration", + "kml": "application/vnd.google-earth.kml+xml", + "kmz": "application/vnd.google-earth.kmz", + "kne": "application/vnd.kinar", + "knp": "application/vnd.kinar", + "kon": "application/vnd.kde.kontour", + "kpr": "application/vnd.kde.kpresenter", + "kpt": "application/vnd.kde.kpresenter", + "kpxx": "application/vnd.ds-keypoint", + "ksp": "application/vnd.kde.kspread", + "ktr": "application/vnd.kahootz", + "ktx": "image/ktx", + "ktx2": "image/ktx2", + "ktz": "application/vnd.kahootz", + "kwd": "application/vnd.kde.kword", + "kwt": "application/vnd.kde.kword", + "lasxml": "application/vnd.las.las+xml", + "latex": "application/x-latex", + "lbd": "application/vnd.llamagraphics.life-balance.desktop", + "lbe": "application/vnd.llamagraphics.life-balance.exchange+xml", + "les": "application/vnd.hhe.lesson-player", + "less": "text/less", + "lgr": "application/lgr+xml", + "lha": "application/x-lzh-compressed", + "link66": "application/vnd.route66.link66+xml", + "list": "text/plain", + "list3820": "application/vnd.ibm.modcap", + "listafp": "application/vnd.ibm.modcap", + "litcoffee": "text/coffeescript", + "lnk": "application/x-ms-shortcut", + "log": "text/plain", + "lostxml": "application/lost+xml", + "lrf": "application/octet-stream", + "lrm": "application/vnd.ms-lrm", + "ltf": "application/vnd.frogans.ltf", + "lua": "text/x-lua", + "luac": "application/x-lua-bytecode", + "lvp": "audio/vnd.lucent.voice", + "lwp": "application/vnd.lotus-wordpro", + "lzh": "application/x-lzh-compressed", + "m13": "application/x-msmediaview", + "m14": "application/x-msmediaview", + "m1v": "video/mpeg", + "m21": "application/mp21", + "m2a": "audio/mpeg", + "m2v": "video/mpeg", + "m3a": "audio/mpeg", + "m3u": "audio/x-mpegurl", + "m3u8": "application/vnd.apple.mpegurl", + "m4a": "audio/x-m4a", + "m4p": "application/mp4", + "m4s": "video/iso.segment", + "m4u": "video/vnd.mpegurl", + "m4v": "video/x-m4v", + "ma": "application/mathematica", + "mads": "application/mads+xml", + "maei": "application/mmt-aei+xml", + "mag": "application/vnd.ecowin.chart", + "maker": "application/vnd.framemaker", + "man": "text/troff", + "manifest": "text/cache-manifest", + "map": "application/json", + "mar": "application/octet-stream", + "markdown": "text/markdown", + "mathml": "application/mathml+xml", + "mb": "application/mathematica", + "mbk": "application/vnd.mobius.mbk", + "mbox": "application/mbox", + "mc1": "application/vnd.medcalcdata", + "mcd": "application/vnd.mcd", + "mcurl": "text/vnd.curl.mcurl", + "md": "text/markdown", + "mdb": "application/x-msaccess", + "mdi": "image/vnd.ms-modi", + "mdx": "text/mdx", + "me": "text/troff", + "mesh": "model/mesh", + "meta4": "application/metalink4+xml", + "metalink": "application/metalink+xml", + "mets": "application/mets+xml", + "mfm": "application/vnd.mfmp", + "mft": "application/rpki-manifest", + "mgp": "application/vnd.osgeo.mapguide.package", + "mgz": "application/vnd.proteus.magazine", + "mid": "audio/midi", + "midi": "audio/midi", + "mie": "application/x-mie", + "mif": "application/vnd.mif", + "mime": "message/rfc822", + "mj2": "video/mj2", + "mjp2": "video/mj2", + "mjs": "text/javascript", + "mk3d": "video/x-matroska", + "mka": "audio/x-matroska", + "mkd": "text/x-markdown", + "mks": "video/x-matroska", + "mkv": "video/x-matroska", + "mlp": "application/vnd.dolby.mlp", + "mmd": "application/vnd.chipnuts.karaoke-mmd", + "mmf": "application/vnd.smaf", + "mml": "text/mathml", + "mmr": "image/vnd.fujixerox.edmics-mmr", + "mng": "video/x-mng", + "mny": "application/x-msmoney", + "mobi": "application/x-mobipocket-ebook", + "mods": "application/mods+xml", + "mov": "video/quicktime", + "movie": "video/x-sgi-movie", + "mp2": "audio/mpeg", + "mp21": "application/mp21", + "mp2a": "audio/mpeg", + "mp3": "audio/mpeg", + "mp4": "video/mp4", + "mp4a": "audio/mp4", + "mp4s": "application/mp4", + "mp4v": "video/mp4", + "mpc": "application/vnd.mophun.certificate", + "mpd": "application/dash+xml", + "mpe": "video/mpeg", + "mpeg": "video/mpeg", + "mpf": "application/media-policy-dataset+xml", + "mpg": "video/mpeg", + "mpg4": "video/mp4", + "mpga": "audio/mpeg", + "mpkg": "application/vnd.apple.installer+xml", + "mpm": "application/vnd.blueice.multipass", + "mpn": "application/vnd.mophun.application", + "mpp": "application/vnd.ms-project", + "mpt": "application/vnd.ms-project", + "mpy": "application/vnd.ibm.minipay", + "mqy": "application/vnd.mobius.mqy", + "mrc": "application/marc", + "mrcx": "application/marcxml+xml", + "ms": "text/troff", + "mscml": "application/mediaservercontrol+xml", + "mseed": "application/vnd.fdsn.mseed", + "mseq": "application/vnd.mseq", + "msf": "application/vnd.epson.msf", + "msg": "application/vnd.ms-outlook", + "msh": "model/mesh", + "msi": "application/x-msdownload", + "msix": "application/msix", + "msixbundle": "application/msixbundle", + "msl": "application/vnd.mobius.msl", + "msm": "application/octet-stream", + "msp": "application/octet-stream", + "msty": "application/vnd.muvee.style", + "mtl": "model/mtl", + "mts": "model/vnd.mts", + "mus": "application/vnd.musician", + "musd": "application/mmt-usd+xml", + "musicxml": "application/vnd.recordare.musicxml+xml", + "mvb": "application/x-msmediaview", + "mvt": "application/vnd.mapbox-vector-tile", + "mwf": "application/vnd.mfer", + "mxf": "application/mxf", + "mxl": "application/vnd.recordare.musicxml", + "mxmf": "audio/mobile-xmf", + "mxml": "application/xv+xml", + "mxs": "application/vnd.triscape.mxs", + "mxu": "video/vnd.mpegurl", + "n-gage": "application/vnd.nokia.n-gage.symbian.install", + "n3": "text/n3", + "nb": "application/mathematica", + "nbp": "application/vnd.wolfram.player", + "nc": "application/x-netcdf", + "ncx": "application/x-dtbncx+xml", + "nfo": "text/x-nfo", + "ngdat": "application/vnd.nokia.n-gage.data", + "nitf": "application/vnd.nitf", + "nlu": "application/vnd.neurolanguage.nlu", + "nml": "application/vnd.enliven", + "nnd": "application/vnd.noblenet-directory", + "nns": "application/vnd.noblenet-sealer", + "nnw": "application/vnd.noblenet-web", + "npx": "image/vnd.net-fpx", + "nq": "application/n-quads", + "nsc": "application/x-conference", + "nsf": "application/vnd.lotus-notes", + "nt": "application/n-triples", + "ntf": "application/vnd.nitf", + "numbers": "application/x-iwork-numbers-sffnumbers", + "nzb": "application/x-nzb", + "oa2": "application/vnd.fujitsu.oasys2", + "oa3": "application/vnd.fujitsu.oasys3", + "oas": "application/vnd.fujitsu.oasys", + "obd": "application/x-msbinder", + "obgx": "application/vnd.openblox.game+xml", + "obj": "model/obj", + "oda": "application/oda", + "odb": "application/vnd.oasis.opendocument.database", + "odc": "application/vnd.oasis.opendocument.chart", + "odf": "application/vnd.oasis.opendocument.formula", + "odft": "application/vnd.oasis.opendocument.formula-template", + "odg": "application/vnd.oasis.opendocument.graphics", + "odi": "application/vnd.oasis.opendocument.image", + "odm": "application/vnd.oasis.opendocument.text-master", + "odp": "application/vnd.oasis.opendocument.presentation", + "ods": "application/vnd.oasis.opendocument.spreadsheet", + "odt": "application/vnd.oasis.opendocument.text", + "oga": "audio/ogg", + "ogex": "model/vnd.opengex", + "ogg": "audio/ogg", + "ogv": "video/ogg", + "ogx": "application/ogg", + "omdoc": "application/omdoc+xml", + "onepkg": "application/onenote", + "onetmp": "application/onenote", + "onetoc": "application/onenote", + "onetoc2": "application/onenote", + "opf": "application/oebps-package+xml", + "opml": "text/x-opml", + "oprc": "application/vnd.palm", + "opus": "audio/ogg", + "org": "text/x-org", + "osf": "application/vnd.yamaha.openscoreformat", + "osfpvg": "application/vnd.yamaha.openscoreformat.osfpvg+xml", + "osm": "application/vnd.openstreetmap.data+xml", + "otc": "application/vnd.oasis.opendocument.chart-template", + "otf": "font/otf", + "otg": "application/vnd.oasis.opendocument.graphics-template", + "oth": "application/vnd.oasis.opendocument.text-web", + "oti": "application/vnd.oasis.opendocument.image-template", + "otp": "application/vnd.oasis.opendocument.presentation-template", + "ots": "application/vnd.oasis.opendocument.spreadsheet-template", + "ott": "application/vnd.oasis.opendocument.text-template", + "ova": "application/x-virtualbox-ova", + "ovf": "application/x-virtualbox-ovf", + "owl": "application/rdf+xml", + "oxps": "application/oxps", + "oxt": "application/vnd.openofficeorg.extension", + "p": "text/x-pascal", + "p10": "application/pkcs10", + "p12": "application/x-pkcs12", + "p7b": "application/x-pkcs7-certificates", + "p7c": "application/pkcs7-mime", + "p7m": "application/pkcs7-mime", + "p7r": "application/x-pkcs7-certreqresp", + "p7s": "application/pkcs7-signature", + "p8": "application/pkcs8", + "pac": "application/x-ns-proxy-autoconfig", + "pages": "application/x-iwork-pages-sffpages", + "pas": "text/x-pascal", + "paw": "application/vnd.pawaafile", + "pbd": "application/vnd.powerbuilder6", + "pbm": "image/x-portable-bitmap", + "pcap": "application/vnd.tcpdump.pcap", + "pcf": "application/x-font-pcf", + "pcl": "application/vnd.hp-pcl", + "pclxl": "application/vnd.hp-pclxl", + "pct": "image/x-pict", + "pcurl": "application/vnd.curl.pcurl", + "pcx": "image/x-pcx", + "pdb": "application/x-pilot", + "pde": "text/x-processing", + "pdf": "application/pdf", + "pem": "application/x-x509-ca-cert", + "pfa": "application/x-font-type1", + "pfb": "application/x-font-type1", + "pfm": "application/x-font-type1", + "pfr": "application/font-tdpfr", + "pfx": "application/x-pkcs12", + "pgm": "image/x-portable-graymap", + "pgn": "application/x-chess-pgn", + "pgp": "application/pgp-encrypted", + "php": "application/x-httpd-php", + "pic": "image/x-pict", + "pkg": "application/octet-stream", + "pki": "application/pkixcmp", + "pkipath": "application/pkix-pkipath", + "pkpass": "application/vnd.apple.pkpass", + "pl": "application/x-perl", + "plb": "application/vnd.3gpp.pic-bw-large", + "plc": "application/vnd.mobius.plc", + "plf": "application/vnd.pocketlearn", + "pls": "application/pls+xml", + "pm": "application/x-perl", + "pml": "application/vnd.ctc-posml", + "png": "image/png", + "pnm": "image/x-portable-anymap", + "portpkg": "application/vnd.macports.portpkg", + "pot": "application/vnd.ms-powerpoint", + "potm": "application/vnd.ms-powerpoint.template.macroenabled.12", + "potx": "application/vnd.openxmlformats-officedocument.presentationml.template", + "ppam": "application/vnd.ms-powerpoint.addin.macroenabled.12", + "ppd": "application/vnd.cups-ppd", + "ppm": "image/x-portable-pixmap", + "pps": "application/vnd.ms-powerpoint", + "ppsm": "application/vnd.ms-powerpoint.slideshow.macroenabled.12", + "ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "ppt": "application/vnd.ms-powerpoint", + "pptm": "application/vnd.ms-powerpoint.presentation.macroenabled.12", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "pqa": "application/vnd.palm", + "prc": "model/prc", + "pre": "application/vnd.lotus-freelance", + "prf": "application/pics-rules", + "provx": "application/provenance+xml", + "ps": "application/postscript", + "psb": "application/vnd.3gpp.pic-bw-small", + "psd": "image/vnd.adobe.photoshop", + "psf": "application/x-font-linux-psf", + "pskcxml": "application/pskc+xml", + "pti": "image/prs.pti", + "ptid": "application/vnd.pvi.ptid1", + "pub": "application/x-mspublisher", + "pvb": "application/vnd.3gpp.pic-bw-var", + "pwn": "application/vnd.3m.post-it-notes", + "pya": "audio/vnd.ms-playready.media.pya", + "pyo": "model/vnd.pytha.pyox", + "pyox": "model/vnd.pytha.pyox", + "pyv": "video/vnd.ms-playready.media.pyv", + "qam": "application/vnd.epson.quickanime", + "qbo": "application/vnd.intu.qbo", + "qfx": "application/vnd.intu.qfx", + "qps": "application/vnd.publishare-delta-tree", + "qt": "video/quicktime", + "qwd": "application/vnd.quark.quarkxpress", + "qwt": "application/vnd.quark.quarkxpress", + "qxb": "application/vnd.quark.quarkxpress", + "qxd": "application/vnd.quark.quarkxpress", + "qxl": "application/vnd.quark.quarkxpress", + "qxt": "application/vnd.quark.quarkxpress", + "ra": "audio/x-realaudio", + "ram": "audio/x-pn-realaudio", + "raml": "application/raml+yaml", + "rapd": "application/route-apd+xml", + "rar": "application/x-rar-compressed", + "ras": "image/x-cmu-raster", + "rcprofile": "application/vnd.ipunplugged.rcprofile", + "rdf": "application/rdf+xml", + "rdz": "application/vnd.data-vision.rdz", + "relo": "application/p2p-overlay+xml", + "rep": "application/vnd.businessobjects", + "res": "application/x-dtbresource+xml", + "rgb": "image/x-rgb", + "rif": "application/reginfo+xml", + "rip": "audio/vnd.rip", + "ris": "application/x-research-info-systems", + "rl": "application/resource-lists+xml", + "rlc": "image/vnd.fujixerox.edmics-rlc", + "rld": "application/resource-lists-diff+xml", + "rm": "application/vnd.rn-realmedia", + "rmi": "audio/midi", + "rmp": "audio/x-pn-realaudio-plugin", + "rms": "application/vnd.jcp.javame.midlet-rms", + "rmvb": "application/vnd.rn-realmedia-vbr", + "rnc": "application/relax-ng-compact-syntax", + "rng": "application/xml", + "roa": "application/rpki-roa", + "roff": "text/troff", + "rp9": "application/vnd.cloanto.rp9", + "rpm": "application/x-redhat-package-manager", + "rpss": "application/vnd.nokia.radio-presets", + "rpst": "application/vnd.nokia.radio-preset", + "rq": "application/sparql-query", + "rs": "application/rls-services+xml", + "rsat": "application/atsc-rsat+xml", + "rsd": "application/rsd+xml", + "rsheet": "application/urc-ressheet+xml", + "rss": "application/rss+xml", + "rtf": "text/rtf", + "rtx": "text/richtext", + "run": "application/x-makeself", + "rusd": "application/route-usd+xml", + "s": "text/x-asm", + "s3m": "audio/s3m", + "saf": "application/vnd.yamaha.smaf-audio", + "sass": "text/x-sass", + "sbml": "application/sbml+xml", + "sc": "application/vnd.ibm.secure-container", + "scd": "application/x-msschedule", + "scm": "application/vnd.lotus-screencam", + "scq": "application/scvp-cv-request", + "scs": "application/scvp-cv-response", + "scss": "text/x-scss", + "scurl": "text/vnd.curl.scurl", + "sda": "application/vnd.stardivision.draw", + "sdc": "application/vnd.stardivision.calc", + "sdd": "application/vnd.stardivision.impress", + "sdkd": "application/vnd.solent.sdkm+xml", + "sdkm": "application/vnd.solent.sdkm+xml", + "sdp": "application/sdp", + "sdw": "application/vnd.stardivision.writer", + "sea": "application/x-sea", + "see": "application/vnd.seemail", + "seed": "application/vnd.fdsn.seed", + "sema": "application/vnd.sema", + "semd": "application/vnd.semd", + "semf": "application/vnd.semf", + "senmlx": "application/senml+xml", + "sensmlx": "application/sensml+xml", + "ser": "application/java-serialized-object", + "setpay": "application/set-payment-initiation", + "setreg": "application/set-registration-initiation", + "sfd-hdstx": "application/vnd.hydrostatix.sof-data", + "sfs": "application/vnd.spotfire.sfs", + "sfv": "text/x-sfv", + "sgi": "image/sgi", + "sgl": "application/vnd.stardivision.writer-global", + "sgm": "text/sgml", + "sgml": "text/sgml", + "sh": "application/x-sh", + "shar": "application/x-shar", + "shex": "text/shex", + "shf": "application/shf+xml", + "shtml": "text/html", + "sid": "image/x-mrsid-image", + "sieve": "application/sieve", + "sig": "application/pgp-signature", + "sil": "audio/silk", + "silo": "model/mesh", + "sis": "application/vnd.symbian.install", + "sisx": "application/vnd.symbian.install", + "sit": "application/x-stuffit", + "sitx": "application/x-stuffitx", + "siv": "application/sieve", + "skd": "application/vnd.koan", + "skm": "application/vnd.koan", + "skp": "application/vnd.koan", + "skt": "application/vnd.koan", + "sldm": "application/vnd.ms-powerpoint.slide.macroenabled.12", + "sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide", + "slim": "text/slim", + "slm": "text/slim", + "sls": "application/route-s-tsid+xml", + "slt": "application/vnd.epson.salt", + "sm": "application/vnd.stepmania.stepchart", + "smf": "application/vnd.stardivision.math", + "smi": "application/smil+xml", + "smil": "application/smil+xml", + "smv": "video/x-smv", + "smzip": "application/vnd.stepmania.package", + "snd": "audio/basic", + "snf": "application/x-font-snf", + "so": "application/octet-stream", + "spc": "application/x-pkcs7-certificates", + "spdx": "text/spdx", + "spf": "application/vnd.yamaha.smaf-phrase", + "spl": "application/x-futuresplash", + "spot": "text/vnd.in3d.spot", + "spp": "application/scvp-vp-response", + "spq": "application/scvp-vp-request", + "spx": "audio/ogg", + "sql": "application/x-sql", + "src": "application/x-wais-source", + "srt": "application/x-subrip", + "sru": "application/sru+xml", + "srx": "application/sparql-results+xml", + "ssdl": "application/ssdl+xml", + "sse": "application/vnd.kodak-descriptor", + "ssf": "application/vnd.epson.ssf", + "ssml": "application/ssml+xml", + "st": "application/vnd.sailingtracker.track", + "stc": "application/vnd.sun.xml.calc.template", + "std": "application/vnd.sun.xml.draw.template", + "stf": "application/vnd.wt.stf", + "sti": "application/vnd.sun.xml.impress.template", + "stk": "application/hyperstudio", + "stl": "model/stl", + "stpx": "model/step+xml", + "stpxz": "model/step-xml+zip", + "stpz": "model/step+zip", + "str": "application/vnd.pg.format", + "stw": "application/vnd.sun.xml.writer.template", + "styl": "text/stylus", + "stylus": "text/stylus", + "sub": "text/vnd.dvb.subtitle", + "sus": "application/vnd.sus-calendar", + "susp": "application/vnd.sus-calendar", + "sv4cpio": "application/x-sv4cpio", + "sv4crc": "application/x-sv4crc", + "svc": "application/vnd.dvb.service", + "svd": "application/vnd.svd", + "svg": "image/svg+xml", + "svgz": "image/svg+xml", + "swa": "application/x-director", + "swf": "application/x-shockwave-flash", + "swi": "application/vnd.aristanetworks.swi", + "swidtag": "application/swid+xml", + "sxc": "application/vnd.sun.xml.calc", + "sxd": "application/vnd.sun.xml.draw", + "sxg": "application/vnd.sun.xml.writer.global", + "sxi": "application/vnd.sun.xml.impress", + "sxm": "application/vnd.sun.xml.math", + "sxw": "application/vnd.sun.xml.writer", + "t": "text/troff", + "t3": "application/x-t3vm-image", + "t38": "image/t38", + "taglet": "application/vnd.mynfc", + "tao": "application/vnd.tao.intent-module-archive", + "tap": "image/vnd.tencent.tap", + "tar": "application/x-tar", + "tcap": "application/vnd.3gpp2.tcap", + "tcl": "application/x-tcl", + "td": "application/urc-targetdesc+xml", + "teacher": "application/vnd.smart.teacher", + "tei": "application/tei+xml", + "teicorpus": "application/tei+xml", + "tex": "application/x-tex", + "texi": "application/x-texinfo", + "texinfo": "application/x-texinfo", + "text": "text/plain", + "tfi": "application/thraud+xml", + "tfm": "application/x-tex-tfm", + "tfx": "image/tiff-fx", + "tga": "image/x-tga", + "thmx": "application/vnd.ms-officetheme", + "tif": "image/tiff", + "tiff": "image/tiff", + "tk": "application/x-tcl", + "tmo": "application/vnd.tmobile-livetv", + "toml": "application/toml", + "torrent": "application/x-bittorrent", + "tpl": "application/vnd.groove-tool-template", + "tpt": "application/vnd.trid.tpt", + "tr": "text/troff", + "tra": "application/vnd.trueapp", + "trig": "application/trig", + "trm": "application/x-msterminal", + "ts": "video/mp2t", + "tsd": "application/timestamped-data", + "tsv": "text/tab-separated-values", + "ttc": "font/collection", + "ttf": "font/ttf", + "ttl": "text/turtle", + "ttml": "application/ttml+xml", + "twd": "application/vnd.simtech-mindmapper", + "twds": "application/vnd.simtech-mindmapper", + "txd": "application/vnd.genomatix.tuxedo", + "txf": "application/vnd.mobius.txf", + "txt": "text/plain", + "u32": "application/x-authorware-bin", + "u3d": "model/u3d", + "u8dsn": "message/global-delivery-status", + "u8hdr": "message/global-headers", + "u8mdn": "message/global-disposition-notification", + "u8msg": "message/global", + "ubj": "application/ubjson", + "udeb": "application/x-debian-package", + "ufd": "application/vnd.ufdl", + "ufdl": "application/vnd.ufdl", + "ulx": "application/x-glulx", + "umj": "application/vnd.umajin", + "unityweb": "application/vnd.unity", + "uo": "application/vnd.uoml+xml", + "uoml": "application/vnd.uoml+xml", + "uri": "text/uri-list", + "uris": "text/uri-list", + "urls": "text/uri-list", + "usda": "model/vnd.usda", + "usdz": "model/vnd.usdz+zip", + "ustar": "application/x-ustar", + "utz": "application/vnd.uiq.theme", + "uu": "text/x-uuencode", + "uva": "audio/vnd.dece.audio", + "uvd": "application/vnd.dece.data", + "uvf": "application/vnd.dece.data", + "uvg": "image/vnd.dece.graphic", + "uvh": "video/vnd.dece.hd", + "uvi": "image/vnd.dece.graphic", + "uvm": "video/vnd.dece.mobile", + "uvp": "video/vnd.dece.pd", + "uvs": "video/vnd.dece.sd", + "uvt": "application/vnd.dece.ttml+xml", + "uvu": "video/vnd.uvvu.mp4", + "uvv": "video/vnd.dece.video", + "uvva": "audio/vnd.dece.audio", + "uvvd": "application/vnd.dece.data", + "uvvf": "application/vnd.dece.data", + "uvvg": "image/vnd.dece.graphic", + "uvvh": "video/vnd.dece.hd", + "uvvi": "image/vnd.dece.graphic", + "uvvm": "video/vnd.dece.mobile", + "uvvp": "video/vnd.dece.pd", + "uvvs": "video/vnd.dece.sd", + "uvvt": "application/vnd.dece.ttml+xml", + "uvvu": "video/vnd.uvvu.mp4", + "uvvv": "video/vnd.dece.video", + "uvvx": "application/vnd.dece.unspecified", + "uvvz": "application/vnd.dece.zip", + "uvx": "application/vnd.dece.unspecified", + "uvz": "application/vnd.dece.zip", + "vbox": "application/x-virtualbox-vbox", + "vbox-extpack": "application/x-virtualbox-vbox-extpack", + "vcard": "text/vcard", + "vcd": "application/x-cdlink", + "vcf": "text/x-vcard", + "vcg": "application/vnd.groove-vcard", + "vcs": "text/x-vcalendar", + "vcx": "application/vnd.vcx", + "vdi": "application/x-virtualbox-vdi", + "vds": "model/vnd.sap.vds", + "vhd": "application/x-virtualbox-vhd", + "vis": "application/vnd.visionary", + "viv": "video/vnd.vivo", + "vmdk": "application/x-virtualbox-vmdk", + "vob": "video/x-ms-vob", + "vor": "application/vnd.stardivision.writer", + "vox": "application/x-authorware-bin", + "vrml": "model/vrml", + "vsd": "application/vnd.visio", + "vsf": "application/vnd.vsf", + "vss": "application/vnd.visio", + "vst": "application/vnd.visio", + "vsw": "application/vnd.visio", + "vtf": "image/vnd.valve.source.texture", + "vtt": "text/vtt", + "vtu": "model/vnd.vtu", + "vxml": "application/voicexml+xml", + "w3d": "application/x-director", + "wad": "application/x-doom", + "wadl": "application/vnd.sun.wadl+xml", + "war": "application/java-archive", + "wasm": "application/wasm", + "wav": "audio/x-wav", + "wax": "audio/x-ms-wax", + "wbmp": "image/vnd.wap.wbmp", + "wbs": "application/vnd.criticaltools.wbs+xml", + "wbxml": "application/vnd.wap.wbxml", + "wcm": "application/vnd.ms-works", + "wdb": "application/vnd.ms-works", + "wdp": "image/vnd.ms-photo", + "weba": "audio/webm", + "webapp": "application/x-web-app-manifest+json", + "webm": "video/webm", + "webmanifest": "application/manifest+json", + "webp": "image/webp", + "wg": "application/vnd.pmi.widget", + "wgsl": "text/wgsl", + "wgt": "application/widget", + "wif": "application/watcherinfo+xml", + "wks": "application/vnd.ms-works", + "wm": "video/x-ms-wm", + "wma": "audio/x-ms-wma", + "wmd": "application/x-ms-wmd", + "wmf": "image/wmf", + "wml": "text/vnd.wap.wml", + "wmlc": "application/vnd.wap.wmlc", + "wmls": "text/vnd.wap.wmlscript", + "wmlsc": "application/vnd.wap.wmlscriptc", + "wmv": "video/x-ms-wmv", + "wmx": "video/x-ms-wmx", + "wmz": "application/x-msmetafile", + "woff": "font/woff", + "woff2": "font/woff2", + "wpd": "application/vnd.wordperfect", + "wpl": "application/vnd.ms-wpl", + "wps": "application/vnd.ms-works", + "wqd": "application/vnd.wqd", + "wri": "application/x-mswrite", + "wrl": "model/vrml", + "wsc": "message/vnd.wfa.wsc", + "wsdl": "application/wsdl+xml", + "wspolicy": "application/wspolicy+xml", + "wtb": "application/vnd.webturbo", + "wvx": "video/x-ms-wvx", + "x32": "application/x-authorware-bin", + "x3d": "model/x3d+xml", + "x3db": "model/x3d+fastinfoset", + "x3dbz": "model/x3d+binary", + "x3dv": "model/x3d-vrml", + "x3dvz": "model/x3d+vrml", + "x3dz": "model/x3d+xml", + "x_b": "model/vnd.parasolid.transmit.binary", + "x_t": "model/vnd.parasolid.transmit.text", + "xaml": "application/xaml+xml", + "xap": "application/x-silverlight-app", + "xar": "application/vnd.xara", + "xav": "application/xcap-att+xml", + "xbap": "application/x-ms-xbap", + "xbd": "application/vnd.fujixerox.docuworks.binder", + "xbm": "image/x-xbitmap", + "xca": "application/xcap-caps+xml", + "xcs": "application/calendar+xml", + "xdf": "application/xcap-diff+xml", + "xdm": "application/vnd.syncml.dm+xml", + "xdp": "application/vnd.adobe.xdp+xml", + "xdssc": "application/dssc+xml", + "xdw": "application/vnd.fujixerox.docuworks", + "xel": "application/xcap-el+xml", + "xenc": "application/xenc+xml", + "xer": "application/patch-ops-error+xml", + "xfdf": "application/xfdf", + "xfdl": "application/vnd.xfdl", + "xht": "application/xhtml+xml", + "xhtm": "application/vnd.pwg-xhtml-print+xml", + "xhtml": "application/xhtml+xml", + "xhvml": "application/xv+xml", + "xif": "image/vnd.xiff", + "xla": "application/vnd.ms-excel", + "xlam": "application/vnd.ms-excel.addin.macroenabled.12", + "xlc": "application/vnd.ms-excel", + "xlf": "application/xliff+xml", + "xlm": "application/vnd.ms-excel", + "xls": "application/vnd.ms-excel", + "xlsb": "application/vnd.ms-excel.sheet.binary.macroenabled.12", + "xlsm": "application/vnd.ms-excel.sheet.macroenabled.12", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xlt": "application/vnd.ms-excel", + "xltm": "application/vnd.ms-excel.template.macroenabled.12", + "xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "xlw": "application/vnd.ms-excel", + "xm": "audio/xm", + "xml": "text/xml", + "xns": "application/xcap-ns+xml", + "xo": "application/vnd.olpc-sugar", + "xop": "application/xop+xml", + "xpi": "application/x-xpinstall", + "xpl": "application/xproc+xml", + "xpm": "image/x-xpixmap", + "xpr": "application/vnd.is-xpr", + "xps": "application/vnd.ms-xpsdocument", + "xpw": "application/vnd.intercon.formnet", + "xpx": "application/vnd.intercon.formnet", + "xsd": "application/xml", + "xsf": "application/prs.xsf+xml", + "xsl": "application/xslt+xml", + "xslt": "application/xslt+xml", + "xsm": "application/vnd.syncml+xml", + "xspf": "application/xspf+xml", + "xul": "application/vnd.mozilla.xul+xml", + "xvm": "application/xv+xml", + "xvml": "application/xv+xml", + "xwd": "image/x-xwindowdump", + "xyz": "chemical/x-xyz", + "xz": "application/x-xz", + "yaml": "text/yaml", + "yang": "application/yang", + "yin": "application/yin+xml", + "yml": "text/yaml", + "ymp": "text/x-suse-ymp", + "z1": "application/x-zmachine", + "z2": "application/x-zmachine", + "z3": "application/x-zmachine", + "z4": "application/x-zmachine", + "z5": "application/x-zmachine", + "z6": "application/x-zmachine", + "z7": "application/x-zmachine", + "z8": "application/x-zmachine", + "zaz": "application/vnd.zzazz.deck+xml", + "zip": "application/zip", + "zir": "application/vnd.zul", + "zirz": "application/vnd.zul", + "zmm": "application/vnd.handheld-entertainment+xml", +} From 2546cfa5ef0a2710eedcd77809675eb351c57ed5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 02:57:23 -0700 Subject: [PATCH 02/22] Unfinished component dispatcher --- src/py/reactpy/reactpy/backend/asgi.py | 55 +++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index b03802783..2119bd7bf 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -1,11 +1,14 @@ +import asyncio import logging import mimetypes import os import re +import urllib.parse +from collections.abc import Sequence from pathlib import Path -from typing import Sequence import aiofiles +import orjson from asgiref.compatibility import guarantee_single_callable from reactpy.backend._common import ( @@ -13,8 +16,12 @@ traversal_safe_path, vdom_head_elements_to_html, ) +from reactpy.backend.hooks import ConnectionContext from reactpy.backend.mimetypes import DEFAULT_MIME_TYPES +from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_WEB_MODULES_DIR +from reactpy.core.layout import Layout +from reactpy.core.serve import serve_layout from reactpy.core.types import VdomDict DEFAULT_STATIC_PATH = f"{os.getcwd()}/static" @@ -89,6 +96,43 @@ async def __call__(self, scope, receive, send) -> None: async def component_dispatch_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy Python components.""" + self._reactpy_recv_queue: asyncio.Queue = asyncio.Queue() + parsed_url = urllib.parse.urlparse(scope["path"]) + + # TODO: Get the component via URL attached to template tag + parsed_url_query = urllib.parse.parse_qs(parsed_url.query) + component = lambda _: None + + while True: + event = await receive() + + if event["type"] == "websocket.connect": + await send({"type": "websocket.accept"}) + + await serve_layout( + Layout( + ConnectionContext( + component(), + value=Connection( + scope=scope, + location=Location( + parsed_url.path, + f"?{parsed_url.query}" if parsed_url.query else "", + ), + carrier=self, + ), + ) + ), + send_json(send), + self._reactpy_recv_queue.get, + ) + + if event["type"] == "websocket.disconnect": + break + + if event["type"] == "websocket.receive": + await self._reactpy_recv_queue.put(orjson.loads(event["text"])) + async def js_modules_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy web modules.""" @@ -166,6 +210,15 @@ async def index_html_app(self, scope, receive, send) -> None: ) +def send_json(send) -> None: + """Use orjson to send JSON over an ASGI websocket.""" + + async def _send_json(value) -> None: + await send({"type": "websocket.send", "text": orjson.dumps(value)}) + + return _send_json + + async def simple_response( send, code: int, From 16ada3382e00a7dcd3c3c74e15bef1d00844eb10 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 16:51:17 -0700 Subject: [PATCH 03/22] threading for disk calls --- src/py/reactpy/reactpy/backend/asgi.py | 13 ++++++++----- src/py/reactpy/reactpy/backend/mimetypes.py | 7 ++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 2119bd7bf..a66be2326 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -242,25 +242,28 @@ async def file_response(scope, send, file_path: Path) -> None: """Send a file in chunks.""" # Make sure the file exists - if not os.path.exists(file_path): + if not await asyncio.to_thread(os.path.exists, file_path): await simple_response(send, 404, "File not found.") return # Make sure it's a file - if not os.path.isfile(file_path): + if not await asyncio.to_thread(os.path.isfile, file_path): await simple_response(send, 400, "Not a file.") return # Check if the file is already cached by the client modified_since = await get_val_from_header(scope, b"if-modified-since") - if modified_since and modified_since > os.path.getmtime(file_path): + if modified_since and modified_since > await asyncio.to_thread( + os.path.getmtime, file_path + ): await simple_response(send, 304, "Not modified.") return # Get the file's MIME type mime_type = ( - DEFAULT_MIME_TYPES.get(file_path.rsplit(".")[1], None) - or mimetypes.guess_type(file_path, strict=False)[0] + MIME_TYPES.get(file_path.rsplit(".")[1], None) + # Fallback to guess_type to allow for the user to define custom MIME types on their system + or (await asyncio.to_thread(mimetypes.guess_type, file_path, strict=False))[0] ) if mime_type is None: mime_type = "text/plain" diff --git a/src/py/reactpy/reactpy/backend/mimetypes.py b/src/py/reactpy/reactpy/backend/mimetypes.py index 9c8a97088..051d5c88e 100644 --- a/src/py/reactpy/reactpy/backend/mimetypes.py +++ b/src/py/reactpy/reactpy/backend/mimetypes.py @@ -1,4 +1,9 @@ -DEFAULT_MIME_TYPES = { +""" +We ship our own mime types to ensure consistent behavior across platforms. +This dictionary is based on: https://github.com/micnic/mime.json +""" + +MIME_TYPES = { "123": "application/vnd.lotus-1-2-3", "1km": "application/vnd.1000minds.decision-model+xml", "3dml": "text/vnd.in3d.3dml", From bde42aa67bcc6d7b2efc501ecf7d6382178385ec Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:35:09 -0700 Subject: [PATCH 04/22] More robust route handling --- src/py/reactpy/reactpy/backend/asgi.py | 38 +++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index a66be2326..dc3e6a983 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -6,6 +6,7 @@ import urllib.parse from collections.abc import Sequence from pathlib import Path +from typing import Coroutine import aiofiles import orjson @@ -17,12 +18,12 @@ vdom_head_elements_to_html, ) from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.mimetypes import DEFAULT_MIME_TYPES +from reactpy.backend.mimetypes import MIME_TYPES from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_WEB_MODULES_DIR from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.core.types import VdomDict +from reactpy.core.types import ComponentConstructor, VdomDict DEFAULT_STATIC_PATH = f"{os.getcwd()}/static" DEFAULT_BLOCK_SIZE = 8192 @@ -32,14 +33,28 @@ class ReactPy: def __init__( self, - application=None, + app_or_component: ComponentConstructor | Coroutine, + *, dispatcher_path: str = "^reactpy/stream/([^/]+)/?", js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", static_path: str | None = "^reactpy/static/([^/]+)/?", static_dir: str | None = DEFAULT_STATIC_PATH, head: Sequence[VdomDict] | VdomDict | str = "", ) -> None: - self.user_app = guarantee_single_callable(application) + self.component = ( + app_or_component + if isinstance(app_or_component, ComponentConstructor) + else None + ) + self.user_app = ( + guarantee_single_callable(app_or_component) + if not self.component and asyncio.iscoroutinefunction(app_or_component) + else None + ) + if not self.component and not self.user_app: + raise TypeError( + "The first argument to `ReactPy` must be a component or an ASGI application." + ) self.dispatch_path = re.compile(dispatcher_path) self.js_modules_path = re.compile(js_modules_path) self.static_path = re.compile(static_path) @@ -66,32 +81,35 @@ async def __call__(self, scope, receive, send) -> None: return # User tried to use an unsupported HTTP method - if scope["method"] not in ("GET", "HEAD"): + if scope["type"] == "http" and scope["method"] not in ("GET", "HEAD"): await simple_response( scope, send, status=405, content="Method Not Allowed" ) return - # Serve a JS web module + # Route requests to our JS web module app if scope["type"] == "http" and re.match( self.js_modules_path, scope["path"] ): await self.js_modules_app(scope, receive, send) return - # Serve a static file + # Route requests to our static file server app if scope["type"] == "http" and re.match(self.static_path, scope["path"]): await self.static_file_app(scope, receive, send) return - # Serve index.html - if scope["type"] == "http": + # Route all other requests to serve a component (user is in standalone mode) + if scope["type"] == "http" and self.component: await self.index_html_app(scope, receive, send) return # Serve the user's application - else: + if self.user_app: await self.user_app(scope, receive, send) + return + + _logger.error("ReactPy appears to be misconfigured. Request not handled.") async def component_dispatch_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy Python components.""" From fa34c318034572e5d1ec28d28229a93b5ba18269 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:35:23 -0700 Subject: [PATCH 05/22] fix carrier --- src/py/reactpy/reactpy/backend/asgi.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index dc3e6a983..8c4af6cad 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -137,7 +137,11 @@ async def component_dispatch_app(self, scope, receive, send) -> None: parsed_url.path, f"?{parsed_url.query}" if parsed_url.query else "", ), - carrier=self, + carrier={ + "scope": scope, + "send": send, + "receive": receive, + }, ), ) ), From a936c66acd5d7c97b249ff638b185e1a65ba9998 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:38:35 -0700 Subject: [PATCH 06/22] Plan ahead for new websocket URL pattern --- src/py/reactpy/reactpy/backend/asgi.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 8c4af6cad..61f25fa95 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -35,7 +35,7 @@ def __init__( self, app_or_component: ComponentConstructor | Coroutine, *, - dispatcher_path: str = "^reactpy/stream/([^/]+)/?", + dispatcher_path: str = "^reactpy/([^/]+)/?", js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", static_path: str | None = "^reactpy/static/([^/]+)/?", static_dir: str | None = DEFAULT_STATIC_PATH, @@ -117,9 +117,9 @@ async def component_dispatch_app(self, scope, receive, send) -> None: self._reactpy_recv_queue: asyncio.Queue = asyncio.Queue() parsed_url = urllib.parse.urlparse(scope["path"]) - # TODO: Get the component via URL attached to template tag - parsed_url_query = urllib.parse.parse_qs(parsed_url.query) - component = lambda _: None + # If in standalone mode, serve the user provided component. + # In middleware mode, get the component from the URL. + component = self.component or re.match(self.dispatch_path, scope["path"])[1] while True: event = await receive() From a936c86fd05e97fe97e000988185fe879d4ff660 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 21:52:38 -0700 Subject: [PATCH 07/22] better path stuff --- src/py/reactpy/reactpy/backend/_common.py | 14 ++--- src/py/reactpy/reactpy/backend/asgi.py | 62 +++++++++++-------- .../tests/test_backend/test__common.py | 6 +- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 17983a033..8a321b409 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -71,18 +71,17 @@ async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> N def safe_client_build_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" - return traversal_safe_path( - CLIENT_BUILD_DIR, - *("index.html" if path in ("", "/") else path).split("/"), + return safe_join_path( + CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/") ) def safe_web_modules_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" - return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) + return safe_join_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) -def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: +def safe_join_path(root: str | Path, *unsafe: str | Path) -> Path: """Raise a ``ValueError`` if the ``unsafe`` path resolves outside the root dir.""" root = os.path.abspath(root) @@ -92,8 +91,9 @@ def traversal_safe_path(root: str | Path, *unsafe: str | Path) -> Path: if os.path.commonprefix([root, path]) != root: # If the common prefix is not root directory we resolved outside the root dir - msg = "Unsafe path" - raise ValueError(msg) + raise ValueError( + f"Unsafe path detected. Path '{path}' is outside root directory '{root}'" + ) return Path(path) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 61f25fa95..262c2a945 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -4,9 +4,8 @@ import os import re import urllib.parse -from collections.abc import Sequence +from collections.abc import Coroutine, Sequence from pathlib import Path -from typing import Coroutine import aiofiles import orjson @@ -14,7 +13,7 @@ from reactpy.backend._common import ( CLIENT_BUILD_DIR, - traversal_safe_path, + safe_join_path, vdom_head_elements_to_html, ) from reactpy.backend.hooks import ConnectionContext @@ -25,7 +24,6 @@ from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor, VdomDict -DEFAULT_STATIC_PATH = f"{os.getcwd()}/static" DEFAULT_BLOCK_SIZE = 8192 _logger = logging.getLogger(__name__) @@ -38,7 +36,7 @@ def __init__( dispatcher_path: str = "^reactpy/([^/]+)/?", js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", static_path: str | None = "^reactpy/static/([^/]+)/?", - static_dir: str | None = DEFAULT_STATIC_PATH, + static_dir: Path | str | None = None, head: Sequence[VdomDict] | VdomDict | str = "", ) -> None: self.component = ( @@ -56,8 +54,8 @@ def __init__( "The first argument to `ReactPy` must be a component or an ASGI application." ) self.dispatch_path = re.compile(dispatcher_path) - self.js_modules_path = re.compile(js_modules_path) - self.static_path = re.compile(static_path) + self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None + self.static_path = re.compile(static_path) if static_path else None self.static_dir = static_dir self.all_paths = re.compile( "|".join( @@ -88,14 +86,20 @@ async def __call__(self, scope, receive, send) -> None: return # Route requests to our JS web module app - if scope["type"] == "http" and re.match( - self.js_modules_path, scope["path"] + if ( + scope["type"] == "http" + and self.js_modules_path + and re.match(self.js_modules_path, scope["path"]) ): await self.js_modules_app(scope, receive, send) return # Route requests to our static file server app - if scope["type"] == "http" and re.match(self.static_path, scope["path"]): + if ( + scope["type"] == "http" + and self.static_path + and re.match(self.static_path, scope["path"]) + ): await self.static_file_app(scope, receive, send) return @@ -159,45 +163,42 @@ async def js_modules_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy web modules.""" if not REACTPY_WEB_MODULES_DIR.current: - raise RuntimeError("No web modules directory configured") - - # Get the relative file path from the URL - file_url_path = re.match(self.js_modules_path, scope["path"])[1] + raise RuntimeError("No web modules directory configured.") # Make sure the user hasn't tried to escape the web modules directory try: - file_path = traversal_safe_path( + abs_file_path = safe_join_path( REACTPY_WEB_MODULES_DIR.current, REACTPY_WEB_MODULES_DIR.current, - file_url_path, + re.match(self.js_modules_path, scope["path"])[1], ) except ValueError: await simple_response(send, 403, "Forbidden") return # Serve the file - await file_response(scope, send, file_path) + await file_response(scope, send, abs_file_path) async def static_file_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy static files.""" - if self.static_dir is None: - raise RuntimeError("No static directory configured") - - # Get the relative file path from the URL - file_url_path = re.match(self.static_path, scope["path"])[1] + if not self.static_dir: + raise RuntimeError( + "Static files cannot be served without defining `static_dir`." + ) # Make sure the user hasn't tried to escape the static directory try: - file_path = traversal_safe_path( - self.static_dir, self.static_dir, file_url_path + abs_file_path = safe_join_path( + self.static_dir, + re.match(self.static_path, scope["path"])[1], ) except ValueError: await simple_response(send, 403, "Forbidden") return # Serve the file - await file_response(scope, send, file_path) + await file_response(scope, send, abs_file_path) async def index_html_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy index.html.""" @@ -289,7 +290,9 @@ async def file_response(scope, send, file_path: Path) -> None: ) if mime_type is None: mime_type = "text/plain" - _logger.error(f"Could not determine MIME type for {file_path}.") + _logger.error( + f"Could not determine MIME type for {file_path}. Defaulting to 'text/plain'." + ) # Send the file in chunks async with aiofiles.open(file_path, "rb") as file_handle: @@ -299,7 +302,12 @@ async def file_response(scope, send, file_path: Path) -> None: "status": 200, "headers": [ (b"content-type", mime_type.encode()), - (b"last-modified", str(os.path.getmtime(file_path)).encode()), + ( + b"last-modified", + str( + await asyncio.to_thread(os.path.getmtime, file_path) + ).encode(), + ), ], } ) diff --git a/src/py/reactpy/tests/test_backend/test__common.py b/src/py/reactpy/tests/test_backend/test__common.py index 248bf9451..869c7e287 100644 --- a/src/py/reactpy/tests/test_backend/test__common.py +++ b/src/py/reactpy/tests/test_backend/test__common.py @@ -3,7 +3,7 @@ from reactpy import html from reactpy.backend._common import ( CommonOptions, - traversal_safe_path, + safe_join_path, vdom_head_elements_to_html, ) @@ -25,8 +25,8 @@ def test_common_options_url_prefix_starts_with_slash(): ], ) def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): - with pytest.raises(ValueError, match="Unsafe path"): - traversal_safe_path(tmp_path, *bad_path.split("/")) + with pytest.raises(ValueError): + safe_join_path(tmp_path, *bad_path.split("/")) @pytest.mark.parametrize( From 727f3f03603e3230bc374c037a4461949644fbd0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 22:31:51 -0700 Subject: [PATCH 08/22] use etag instead of modified header --- src/py/reactpy/reactpy/backend/asgi.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 262c2a945..4e3c7e187 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -275,8 +275,8 @@ async def file_response(scope, send, file_path: Path) -> None: return # Check if the file is already cached by the client - modified_since = await get_val_from_header(scope, b"if-modified-since") - if modified_since and modified_since > await asyncio.to_thread( + etag = await get_val_from_header(scope, b"ETag") + if etag and etag != await asyncio.to_thread( os.path.getmtime, file_path ): await simple_response(send, 304, "Not modified.") @@ -303,7 +303,7 @@ async def file_response(scope, send, file_path: Path) -> None: "headers": [ (b"content-type", mime_type.encode()), ( - b"last-modified", + b"ETag", str( await asyncio.to_thread(os.path.getmtime, file_path) ).encode(), From daaa23594a759d69bc68547ba7d2603edf0ef0a9 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 23:02:41 -0700 Subject: [PATCH 09/22] prepare recv queue for potential threading --- src/py/reactpy/reactpy/backend/asgi.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 4e3c7e187..da67bb98c 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -118,7 +118,6 @@ async def __call__(self, scope, receive, send) -> None: async def component_dispatch_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy Python components.""" - self._reactpy_recv_queue: asyncio.Queue = asyncio.Queue() parsed_url = urllib.parse.urlparse(scope["path"]) # If in standalone mode, serve the user provided component. @@ -130,7 +129,7 @@ async def component_dispatch_app(self, scope, receive, send) -> None: if event["type"] == "websocket.connect": await send({"type": "websocket.accept"}) - + self.recv_queue: asyncio.Queue = asyncio.Queue() await serve_layout( Layout( ConnectionContext( @@ -150,14 +149,14 @@ async def component_dispatch_app(self, scope, receive, send) -> None: ) ), send_json(send), - self._reactpy_recv_queue.get, + self.recv_queue.get, ) if event["type"] == "websocket.disconnect": break if event["type"] == "websocket.receive": - await self._reactpy_recv_queue.put(orjson.loads(event["text"])) + await self.recv_queue.put(orjson.loads(event["text"])) async def js_modules_app(self, scope, receive, send) -> None: """The ASGI application for ReactPy web modules.""" From 3d974307b4a39cd5aeea2843e4e956ca01f61afd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 00:25:08 -0700 Subject: [PATCH 10/22] refactoring --- src/js/app/index.html | 1 - src/py/reactpy/reactpy/backend/asgi.py | 235 ++++++++++++------------- 2 files changed, 108 insertions(+), 128 deletions(-) diff --git a/src/js/app/index.html b/src/js/app/index.html index e94280368..906bcfe3a 100644 --- a/src/js/app/index.html +++ b/src/js/app/index.html @@ -6,7 +6,6 @@ import { app } from "./src/index"; app(document.getElementById("app")); - {__head__}
diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index da67bb98c..4b6ff8549 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -51,7 +51,7 @@ def __init__( ) if not self.component and not self.user_app: raise TypeError( - "The first argument to `ReactPy` must be a component or an ASGI application." + "The first argument to ReactPy(...) must be a component or an ASGI application." ) self.dispatch_path = re.compile(dispatcher_path) self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None @@ -64,93 +64,63 @@ def __init__( ) self.head = vdom_head_elements_to_html(head) self._cached_index_html = "" + self.connected = False async def __call__(self, scope, receive, send) -> None: """The ASGI callable. This determines whether ReactPy should route the the request to ourselves or to the user application.""" - # Determine if ReactPy should handle the request if not self.user_app or re.match(self.all_paths, scope["path"]): - # Dispatch a Python component - if scope["type"] == "websocket" and re.match( - self.dispatch_path, scope["path"] - ): - await self.component_dispatch_app(scope, receive, send) - return - - # User tried to use an unsupported HTTP method - if scope["type"] == "http" and scope["method"] not in ("GET", "HEAD"): - await simple_response( - scope, send, status=405, content="Method Not Allowed" - ) - return - - # Route requests to our JS web module app - if ( - scope["type"] == "http" - and self.js_modules_path - and re.match(self.js_modules_path, scope["path"]) - ): - await self.js_modules_app(scope, receive, send) - return - - # Route requests to our static file server app - if ( - scope["type"] == "http" - and self.static_path - and re.match(self.static_path, scope["path"]) - ): - await self.static_file_app(scope, receive, send) - return - - # Route all other requests to serve a component (user is in standalone mode) - if scope["type"] == "http" and self.component: - await self.index_html_app(scope, receive, send) - return + await self.reactpy_app(scope, receive, send) + return # Serve the user's application - if self.user_app: - await self.user_app(scope, receive, send) - return + await self.user_app(scope, receive, send) _logger.error("ReactPy appears to be misconfigured. Request not handled.") - async def component_dispatch_app(self, scope, receive, send) -> None: - """The ASGI application for ReactPy Python components.""" + async def reactpy_app(self, scope, receive, send) -> None: + """Determine what type of request this is and route it to the appropriate + ReactPy ASGI sub-application.""" - parsed_url = urllib.parse.urlparse(scope["path"]) + # Only HTTP and WebSocket requests are supported + if scope["type"] not in {"http", "websocket"}: + return - # If in standalone mode, serve the user provided component. - # In middleware mode, get the component from the URL. - component = self.component or re.match(self.dispatch_path, scope["path"])[1] + # Dispatch a Python component + if scope["type"] == "websocket" and re.match(self.dispatch_path, scope["path"]): + await self.component_dispatch_app(scope, receive, send) + return + + # Only HTTP GET and HEAD requests are supported + if scope["method"] not in {"GET", "HEAD"}: + await http_response(scope, send, 405, "Method Not Allowed") + return + + # JS modules app + if self.js_modules_path and re.match(self.js_modules_path, scope["path"]): + await self.js_modules_app(scope, receive, send) + return + + # Static file app + if self.static_path and re.match(self.static_path, scope["path"]): + await self.static_file_app(scope, receive, send) + return + + # Standalone app: Serve a single component using index.html + if self.component: + await self.standalone_app(scope, receive, send) + return + async def component_dispatch_app(self, scope, receive, send) -> None: + """ASGI app for rendering ReactPy Python components.""" while True: event = await receive() - if event["type"] == "websocket.connect": + if event["type"] == "websocket.connect" and not self.connected: + self.connected = True await send({"type": "websocket.accept"}) - self.recv_queue: asyncio.Queue = asyncio.Queue() - await serve_layout( - Layout( - ConnectionContext( - component(), - value=Connection( - scope=scope, - location=Location( - parsed_url.path, - f"?{parsed_url.query}" if parsed_url.query else "", - ), - carrier={ - "scope": scope, - "send": send, - "receive": receive, - }, - ), - ) - ), - send_json(send), - self.recv_queue.get, - ) + await self.run_dispatcher(scope, receive, send) if event["type"] == "websocket.disconnect": break @@ -159,8 +129,7 @@ async def component_dispatch_app(self, scope, receive, send) -> None: await self.recv_queue.put(orjson.loads(event["text"])) async def js_modules_app(self, scope, receive, send) -> None: - """The ASGI application for ReactPy web modules.""" - + """ASGI app for ReactPy web modules.""" if not REACTPY_WEB_MODULES_DIR.current: raise RuntimeError("No web modules directory configured.") @@ -172,15 +141,14 @@ async def js_modules_app(self, scope, receive, send) -> None: re.match(self.js_modules_path, scope["path"])[1], ) except ValueError: - await simple_response(send, 403, "Forbidden") + await http_response(scope, send, 403, "Forbidden") return # Serve the file await file_response(scope, send, abs_file_path) async def static_file_app(self, scope, receive, send) -> None: - """The ASGI application for ReactPy static files.""" - + """ASGI app for ReactPy static files.""" if not self.static_dir: raise RuntimeError( "Static files cannot be served without defining `static_dir`." @@ -193,17 +161,14 @@ async def static_file_app(self, scope, receive, send) -> None: re.match(self.static_path, scope["path"])[1], ) except ValueError: - await simple_response(send, 403, "Forbidden") + await http_response(scope, send, 403, "Forbidden") return # Serve the file await file_response(scope, send, abs_file_path) - async def index_html_app(self, scope, receive, send) -> None: - """The ASGI application for ReactPy index.html.""" - - # TODO: We want to respect the if-modified-since header, but currently can't - # due to the fact that our HTML is not statically rendered. + async def standalone_app(self, scope, receive, send) -> None: + """ASGI app for ReactPy standalone mode.""" file_path = CLIENT_BUILD_DIR / "index.html" if not self._cached_index_html: async with aiofiles.open(file_path, "rb") as file_handle: @@ -211,24 +176,46 @@ async def index_html_app(self, scope, receive, send) -> None: __head__=self.head ) - # Head requests don't need a body - if scope["method"] == "HEAD": - await simple_response( - send, - 200, - "", - content_type=b"text/html", - headers=[(b"cache-control", b"no-cache")], - ) - return - # Send the index.html - await simple_response( + await http_response( + scope, send, 200, self._cached_index_html, content_type=b"text/html", - headers=[(b"cache-control", b"no-cache")], + headers=[ + (b"content-length", len(self._cached_index_html)), + (b"etag", hash(self._cached_index_html)), + ], + ) + + async def run_dispatcher(self, scope, receive, send): + # If in standalone mode, serve the user provided component. + # In middleware mode, get the component from the URL. + component = self.component or re.match(self.dispatch_path, scope["path"])[1] + parsed_url = urllib.parse.urlparse(scope["path"]) + self.recv_queue: asyncio.Queue = asyncio.Queue() + + await serve_layout( + Layout( + ConnectionContext( + component(), + value=Connection( + scope=scope, + location=Location( + parsed_url.path, + f"?{parsed_url.query}" if parsed_url.query else "", + ), + carrier={ + "scope": scope, + "send": send, + "receive": receive, + }, + ), + ) + ), + send_json(send), + self.recv_queue.get, ) @@ -241,7 +228,8 @@ async def _send_json(value) -> None: return _send_json -async def simple_response( +async def http_response( + scope, send, code: int, message: str, @@ -249,7 +237,6 @@ async def simple_response( headers: Sequence = (), ) -> None: """Send a simple response.""" - await send( { "type": "http.response.start", @@ -257,28 +244,28 @@ async def simple_response( "headers": [(b"content-type", content_type, *headers)], } ) - await send({"type": "http.response.body", "body": message.encode()}) + # Head requests don't need a body + if scope["method"] != "HEAD": + await send({"type": "http.response.body", "body": message.encode()}) async def file_response(scope, send, file_path: Path) -> None: """Send a file in chunks.""" - # Make sure the file exists if not await asyncio.to_thread(os.path.exists, file_path): - await simple_response(send, 404, "File not found.") + await http_response(scope, send, 404, "File not found.") return # Make sure it's a file if not await asyncio.to_thread(os.path.isfile, file_path): - await simple_response(send, 400, "Not a file.") + await http_response(scope, send, 400, "Not a file.") return # Check if the file is already cached by the client - etag = await get_val_from_header(scope, b"ETag") - if etag and etag != await asyncio.to_thread( - os.path.getmtime, file_path - ): - await simple_response(send, 304, "Not modified.") + etag = await get_val_from_header(scope, b"etag") + modification_time = await asyncio.to_thread(os.path.getmtime, file_path) + if etag and etag != modification_time: + await http_response(scope, send, 304, "Not modified.") return # Get the file's MIME type @@ -294,6 +281,7 @@ async def file_response(scope, send, file_path: Path) -> None: ) # Send the file in chunks + file_size = await asyncio.to_thread(os.path.getsize, file_path) async with aiofiles.open(file_path, "rb") as file_handle: await send( { @@ -301,39 +289,32 @@ async def file_response(scope, send, file_path: Path) -> None: "status": 200, "headers": [ (b"content-type", mime_type.encode()), - ( - b"ETag", - str( - await asyncio.to_thread(os.path.getmtime, file_path) - ).encode(), - ), + (b"etag", modification_time), + (b"content-length", file_size), ], } ) # Head requests don't need a body - if scope["method"] == "HEAD": - return - - while True: - chunk = await file_handle.read(DEFAULT_BLOCK_SIZE) - more_body = bool(chunk) - await send( - { - "type": "http.response.body", - "body": chunk, - "more_body": more_body, - } - ) - if not more_body: - break + if scope["method"] != "HEAD": + while True: + chunk = await file_handle.read(DEFAULT_BLOCK_SIZE) + more_body = bool(chunk) + await send( + { + "type": "http.response.body", + "body": chunk, + "more_body": more_body, + } + ) + if not more_body: + break async def get_val_from_header( scope: dict, key: str, default: str | None = None ) -> str | None: """Get a value from a scope's headers.""" - return await anext( ( value.decode() From f16e4130174c54cffda41c9c03a3f0d117b84682 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 00:59:27 -0700 Subject: [PATCH 11/22] add backhaul thread --- src/py/reactpy/reactpy/backend/asgi.py | 32 ++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 4b6ff8549..016113f83 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -6,6 +6,7 @@ import urllib.parse from collections.abc import Coroutine, Sequence from pathlib import Path +from threading import Thread import aiofiles import orjson @@ -26,6 +27,16 @@ DEFAULT_BLOCK_SIZE = 8192 _logger = logging.getLogger(__name__) +_backhaul_loop = asyncio.new_event_loop() + + +def start_backhaul_loop(): + """Starts the asyncio event loop that will perform component rendering tasks.""" + asyncio.set_event_loop(_backhaul_loop) + _backhaul_loop.run_forever() + + +_backhaul_thread = Thread(target=start_backhaul_loop, daemon=True) class ReactPy: @@ -38,6 +49,7 @@ def __init__( static_path: str | None = "^reactpy/static/([^/]+)/?", static_dir: Path | str | None = None, head: Sequence[VdomDict] | VdomDict | str = "", + backhaul_thread: bool = True, ) -> None: self.component = ( app_or_component @@ -65,6 +77,10 @@ def __init__( self.head = vdom_head_elements_to_html(head) self._cached_index_html = "" self.connected = False + self.backhaul_thread = backhaul_thread + self.dispatcher_future = None + if self.backhaul_thread and not _backhaul_thread.is_alive(): + _backhaul_thread.start() async def __call__(self, scope, receive, send) -> None: """The ASGI callable. This determines whether ReactPy should route the the @@ -120,13 +136,25 @@ async def component_dispatch_app(self, scope, receive, send) -> None: if event["type"] == "websocket.connect" and not self.connected: self.connected = True await send({"type": "websocket.accept"}) - await self.run_dispatcher(scope, receive, send) + run_dispatcher = self.run_dispatcher(scope, receive, send) + if self.backhaul_thread: + self.dispatcher_future = asyncio.run_coroutine_threadsafe( + run_dispatcher, _backhaul_loop + ) + else: + await run_dispatcher if event["type"] == "websocket.disconnect": + if self.dispatcher_future: + self.dispatcher_future.cancel() break if event["type"] == "websocket.receive": - await self.recv_queue.put(orjson.loads(event["text"])) + recv_queue_put = self.recv_queue.put(orjson.loads(event["text"])) + if self.backhaul_thread: + asyncio.run_coroutine_threadsafe(recv_queue_put, _backhaul_loop) + else: + await recv_queue_put async def js_modules_app(self, scope, receive, send) -> None: """ASGI app for ReactPy web modules.""" From 291b5387ed58741ec8964e55271e8e2212692ad1 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 01:51:57 -0700 Subject: [PATCH 12/22] remove icon from default head --- src/py/reactpy/reactpy/backend/_common.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 97141a118..1e0c17cb5 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -117,16 +117,7 @@ def vdom_head_elements_to_html(head: Sequence[VdomDict] | VdomDict | str) -> str class CommonOptions: """Options for ReactPy's built-in backed server implementations""" - head: Sequence[VdomDict] | VdomDict | str = ( - html.title("ReactPy"), - html.link( - { - "rel": "icon", - "href": "/_reactpy/assets/reactpy-logo.ico", - "type": "image/x-icon", - } - ), - ) + head: Sequence[VdomDict] | VdomDict | str = (html.title("ReactPy App"),) """Add elements to the ```` of the application. For example, this can be used to customize the title of the page, link extra From 0b5ba460459a55683aa2c4f700bed966a7f23530 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 04:44:27 -0700 Subject: [PATCH 13/22] small bug fixes --- src/py/reactpy/reactpy/backend/asgi.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 016113f83..d0ae2c86e 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -78,7 +78,7 @@ def __init__( self._cached_index_html = "" self.connected = False self.backhaul_thread = backhaul_thread - self.dispatcher_future = None + self.dispatcher_future_or_task = None if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() @@ -138,15 +138,15 @@ async def component_dispatch_app(self, scope, receive, send) -> None: await send({"type": "websocket.accept"}) run_dispatcher = self.run_dispatcher(scope, receive, send) if self.backhaul_thread: - self.dispatcher_future = asyncio.run_coroutine_threadsafe( + self.dispatcher_future_or_task = asyncio.run_coroutine_threadsafe( run_dispatcher, _backhaul_loop ) else: - await run_dispatcher + self.dispatcher_future_or_task = asyncio.create_task(run_dispatcher) if event["type"] == "websocket.disconnect": - if self.dispatcher_future: - self.dispatcher_future.cancel() + if self.dispatcher_future_or_task: + self.dispatcher_future_or_task.cancel() break if event["type"] == "websocket.receive": @@ -164,7 +164,6 @@ async def js_modules_app(self, scope, receive, send) -> None: # Make sure the user hasn't tried to escape the web modules directory try: abs_file_path = safe_join_path( - REACTPY_WEB_MODULES_DIR.current, REACTPY_WEB_MODULES_DIR.current, re.match(self.js_modules_path, scope["path"])[1], ) From fc99a65e8210a465b6b0fa31b22540501de5d88d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 04:59:45 -0700 Subject: [PATCH 14/22] fix another typo --- src/py/reactpy/reactpy/backend/asgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index d0ae2c86e..dabdbba03 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -268,7 +268,7 @@ async def http_response( { "type": "http.response.start", "status": code, - "headers": [(b"content-type", content_type, *headers)], + "headers": [(b"content-type", content_type), *headers], } ) # Head requests don't need a body From 139ba9864652476c2d4905ec174dce3571948055 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 05:27:08 -0700 Subject: [PATCH 15/22] Update asgi.py --- src/py/reactpy/reactpy/backend/asgi.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index dabdbba03..80e8830e5 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -93,8 +93,6 @@ async def __call__(self, scope, receive, send) -> None: # Serve the user's application await self.user_app(scope, receive, send) - _logger.error("ReactPy appears to be misconfigured. Request not handled.") - async def reactpy_app(self, scope, receive, send) -> None: """Determine what type of request this is and route it to the appropriate ReactPy ASGI sub-application.""" From b3505903d7d73881e31aee2004a452cb002eebe7 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 05:31:25 -0700 Subject: [PATCH 16/22] Update asgi.py --- src/py/reactpy/reactpy/backend/asgi.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 80e8830e5..ed46ce965 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -78,7 +78,7 @@ def __init__( self._cached_index_html = "" self.connected = False self.backhaul_thread = backhaul_thread - self.dispatcher_future_or_task = None + self.dispatcher = None if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() @@ -136,15 +136,15 @@ async def component_dispatch_app(self, scope, receive, send) -> None: await send({"type": "websocket.accept"}) run_dispatcher = self.run_dispatcher(scope, receive, send) if self.backhaul_thread: - self.dispatcher_future_or_task = asyncio.run_coroutine_threadsafe( + self.dispatcher = asyncio.run_coroutine_threadsafe( run_dispatcher, _backhaul_loop ) else: - self.dispatcher_future_or_task = asyncio.create_task(run_dispatcher) + self.dispatcher = asyncio.create_task(run_dispatcher) if event["type"] == "websocket.disconnect": - if self.dispatcher_future_or_task: - self.dispatcher_future_or_task.cancel() + if self.dispatcher: + self.dispatcher.cancel() break if event["type"] == "websocket.receive": From 473cdfd714a3407fd30b8089d560ee9de8603034 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:11:09 -0700 Subject: [PATCH 17/22] customizable block size --- src/js/app/public/assets/reactpy-logo.ico | Bin 14916 -> 0 bytes src/py/reactpy/reactpy/backend/_common.py | 2 +- src/py/reactpy/reactpy/backend/asgi.py | 17 ++++++++--------- 3 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 src/js/app/public/assets/reactpy-logo.ico diff --git a/src/js/app/public/assets/reactpy-logo.ico b/src/js/app/public/assets/reactpy-logo.ico deleted file mode 100644 index 62be5f5ba7e159e3977d1ae42d4bea1734854c1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14916 zcma)jWmFtIu;?xpgE$A97d)X_Uwdvv?Me-oQ5WX=9mA-d)c$P+wIuN zndWVyXI9yHTMmrOy_bfo!+PuenlmhrV@_EN^?yYd#|R5j?4^ofgw`d~qLR{T?sQ*6 z|AUQDgn!WZ(JO$hW&{gS{0Xh=u@{A0pF%i1v=Zz`0VR2o$OPN!p0M!){tr>!_QWxM4@p=$h1(>y~Iu76ExGV6} zO*e1ab=J^QEmIMy2E%gyJod@Q8!Kly{a;QS@tbCqa|T>~8@r%p#I;xlM $m}?CxVFeHnuy@?ys#c<`3H3_w^C4A~phV oPxTL3 z3)SRqhJPECQR3=BWQQP#yjlTdi6m=OZc{4q-+RwmI>kP9z<7&jC+f4WJtHoFADED1 zLxJptD4PfqV%?eWTX4WaQ1LquH^BO2&4v0>AP{3YX&lfl2Ia8-Eyhg{+d12NZLouL z+k)zKM?B$1GG+ph)mkYQ jZtPN+csOPI))z6eKTggJxisK!~RBqD0My6=n2 z%3Yt=LN0F6z{)S) 43) zJk^ScKg5TyM}JrmEd^WVC3ZE<^E`s%GL@dGpNuB&FuH%O*?ZchGPh#6UmN9l0Ou53 z{pB18cBrE#G(GhHP~uz6-VRW2DAB=v&l{gKl(JfD^{J^k1#hWO+tTL$?bZ@!+;MoBYIV_41&Uh01AyaHWW|@k)4G;? zi1o05*?#RmI5gb-g0UEQYaoCA{&S;(!C;^<#S>0ebt1qwog*ibpeZ?dEnbpDbhc#& z?#CZB1L3Hl+}x^V$ttGuRZz&ruA~P+IY-}<6HNX0if%6f>8he&icA^ak`yxOZweM^ z_5`wJn2rS>{dF6uSK<9-o9NMF?L 9Ivg`*)oL}_i zNVs21y~XqX)?zIGX+Q0Q!-ydQ64=`>^ )UN` z^R)RXMkjU!hu4~mUX-9RBaHj#-|j|qqXdR=#t hmc+=ZeDV;TZFg cgf2i0q42&AQova|?d1jCt ioNPbTi3s*wQu@@0bGG&zbo%9Cp!>!`iMIO;WLTzBgwxhG*4pbQDzov& qopb$@@TC3WwqB~L zF5Lts`2|KbHV`c~&F~b!VrLe--h-hhGP=-w0#8?){YK!_-}goc&$6jV)#=G(k8PQo z9VZ~_f{s+ALDh}A8T^m>b@!!QAiWXeLAptdW #Ho{-0@0 zgu32;%QkJ80W1bKs~7}YsH7ZO;hANdkRqB3UkE}NRn|^;Mg(v8;qSn?1T(+Q(25fU zyAeCMPI!x>MCSgO^I{aUDiN3!o$-L3agAf|3HO0E7-O!@c!|wGf^bHU?0a1WgQ-0i zTyfD3n!{N`+8~ R1gmOICvav6e*67x<_Sl`pUALEPutM-c54-fY?uC3Q-2|QxBT_+ zxyg#ePFj3CP5}AYdq;gjwS5o5)-n79;K;+^g8rtm;y2Ox2rU!dFiXkI2Pm!<+e|;h z{FjvyAK5x$SRwAd5x2s1zZhI;ZNdig)e%e+k_iU!CE_cvE7eT^?_w6U|2WpNNaK`KD-HD@SY|RlvptFuuFybI*R0&iEs8Y}16nybEpkHPGeT zDu5r`IyBrJ@E0yr=!}sob*dJQVyPvSQkjOm ulJsfXj6+_6#NqSkzA+}c`vyxH&C?OV83L$Nh@8!`+9QL&2S&}r717DA z
$2L19zaYI@{V)q$a0z#2@9-GT0@W1da zTiu?im-y2?GLZYRyLy8JmqOkANl1LrYC7h+cI21mQ`7YHD>!3Z9Zb3Gxk)ufbIS33 zlAVRTyOXmlh;W?CmO8rC)Glz~M3aW1;$m=IgS`ohy0yK{uip&gA%C(y#s#`iV@DiC z;88jLv@aJI{3!9K52L#G#e=(e{+%?JniN(!6QaCd^%;LEzQcc}RmC>!d*U@ kjSFN@5g6H>6ogE}5EFTt{>V4_;=d87ExoCc9gTX%MskU~pppE9U@dcN v38g`?i_F{o?rui-?sU=wb)@`5ANZzH%V+E0)h?gNfr<9{=)LmYAV ;% zK=BsM$?BKE)K?w2s|ovFlQwGQg5SdK!_cT%P{MEW$Li;Gw|(`I?>!+F%Ey{65+5Xp z?5P1 QJKl_a -RvK2s+jX^H`wRIq4YA=N1!}u$|%$nfoVdb^p z?u{?-_l(m?Rt%@>ZeF4(WR|d`JxOE^TYdaahL*k!^WlAsFbo~^vCA|s$){ zFe?`59VhSv1ZH3YzO`L5wloQq%Wy2k$lfe)6Cd`1V`sgyg|!d6MQ>naO*Ei(xWsaY zKXo8XH#AD95@l;G9(rXQcUyI&^zTDPqi2aVLx0Gj0mQ8mM?XBDqQELIZJ0P8!v?{+ z{LM%&fH7}WRzfn0Zk5I(hHHlqBz1+(VPirh$5$`p8)hw-54e3iv<3d`CPB=D4cicR zt;xKsG|f+cBQS?(7`feq{2Z$ZoG#@3slPyA{IV vjHQ zH7v_8m{2r+fS@hMtK4A!Vxru4pzu%q?XN)Xv~B8pQd^7P09SpaT>s1K!`zmQfZ%19 zq$e-VI i@M!A@rUEZ&a_x(3fy4-PzxY+k{lV^!2BP z7EQ9qpiC9oP@jSU#Rv@7j gWf }AIVk}<)>Ox7nc4;~TO8fLTHKcLDVIOe`ZuCW$n9#L9J2*~ZwM;Nd4Z$1%iDYkvM$ zdg0Fxyjwhk$c|_4fgi2|BNd;G^k_S!f+3@|UG_9au(|zw+M_#|h0N1-d6!VOc}a~O z2$lv?sWvX33~lxg@D}HU#4CEYbjBQ5(|8BTF`#vK%myHB^apN2=X@8qLjZm%#y?xo zoeyTV)P|S7(wm^)4f^-_Zv$xcr%i^pWEI96Ce#1%9d>CCO`VxZ6WnuOOUfN^h=Cvg zYI#DMnEA_>1dLq-+#G<`yeDR4Lu#Ams0w3}LVr$*?D=%4(v`N5e1Tftd^Y7k6Dg~0 z5cx`|v^#a^;R#o=T?xcT0c9@_L{8^tM^27ri8;WBp65wtydoR)en|4UkY8TxPMr*h zs{||Ndx49yF}dkVe@{AlZ|Y*_R|HcQ+-GjPd{)|WnJ !NQ)+?s@Ow zdVt)#=1!k`Hr4iads4Y
G z-eu!NU=1qK1E)NYzpQyYbbNjw{$Rm_^bq!rko-H^x^inH_Uq2`{jTNJG(rs7l+BrC zk@p7ze>;^AztybU>KKwU)z9_)26b=rqcOKWtoe7k0gK_1f)|ktH0{wm<`)H@H L;@2E|V5DwaebYIc))_Q9`!air7e{9J7;c zpBOY`nhx8FN7y%4zZtaItC9M9%g)BsVOmByy~}0J)4&zsSJg6hmnO5rG-&Kh#*ZM? z!{dLK7-myUjAIBq{yHMI=9%+v6={EWX7*<=k0KqTn9UTQ(@%;1LVR?Vs~8+Th&Ig$ zWI>-tCcmvT$MlFN&?Xc<_*{=pfA{ebrCTIZ-{a$~V$+verfmDbEPovrgZ;L^;ta)T ziz5_VNL=Ek8fy(v$|9#6PjWy*+E5)?&q1E&juJ<^MluDr&C|zNpKTW+*L*;sJDaM{ zT5%QH^n;QJLyuQUqntVVpM2JFEBM4aU#3utrBLI78U~Oy>N)PMr6OF~PKd-T?GAQN z`*SHjHO_UN#1 z=d}W*<|3I4 zh_)G~JF8t%cyZ>{Wx@u>#zj(&_+}N^+1R41E0-0p!9?fqX=~i2U`%k7jp7oACVqRp zP)=DsL4(#+Ga5@}gk}ausx>#~jO5*z4Q@@=R63E>v$=%g2a53Tk49+v``f~RG;x*( zKUUkm |SJQ(`o`<<5}usG6{4q%&$5~uqlNG1r%Tl)@Q?_g*qFs A&8~O}R7Q{TL{YXGlszc-Onz1D*0;&BXlTad8vD&jhT6Vqgmyi^ zU|>Ze*8uj#cqMVry1mS{N{{VDdB+z|D_g%GHz6K0Y|ib8A*-)i)ZM}erq^NF85`=z z@p1p!{+oXXPmeZv+!VEmrzmCUn=_{GRGC=XXt>p_Wzxsb@b5Q@qg{WhnrhRDVNGi5 z;QD@SvV2Z0eB4pwkqC&+bomXElmZLETWhKNM;&HM^!gFbHpfl>&CU1ok7KzwCr}t- zYSeD_G$e1(J@RGhM)g;w>^y^5!VM-%uZj4H`9$sl3`uS*M(MJ-Y**<=i`sHQJ_hcM zs^=n{R #9-ldK0GU(0M$*V@BAUzG6xS|Xq;Rg-ikS+> zy$RLXsq^2N0rk4;2FB3W4 sb))HA=y;mReh8F&?KFIH9*nFVkwbzzx()cb zzIn}!cb+X;ztJP_VmJlU9WtJJ!-Rc>+}=l%fwKGILWNG^=JZXMFP!O*O6DHgrD)wo z&q2jR)sAgydWB1?0+hgC2)UNwc;7V%F4jWj`5Ws3lZe`+uy*@T$N?nti)ycnSC@hj z9!yPV!cT=)dk{WoUtIAb)u6NJ(}IW@pipbB@`!6lEbX=lhIf8R60eFk7U-T$C@(ab zR;jcrhkLMVKY&QHm>)0ST>z71jr5gGjfXhNL-8wMLu$nFiEnq_H@j(-@%Mmx(aRO% zk^6*%cgDu{_wuT%x~nD$n`$#A2(U}4s(Zm#0GM_tsOyM^H0Z@pzqJUZnPO>+?JAPh zew9NLMPNFsO* Q=_b5Ri?zh5&W9tn6irIpM@2vu1>bzAh1 zH05_w+CAk`IWu|>zB_)hJfU{*hblx=Lk4gF(gsb<@d+Pe0iqK!-w%=;L|l(L0W0Z~ z7`B!@NO>=vDB-Kf3_Q9ZAsY(cF8)5El8>LBeXXc1Uw*=={3r^hq{v(zicaCUhoyl= z^531BN3DokKC-QN i7X( zAnP^e_< GNALwb3X}n)j4?3|ZzMztx`4Yb?{u2I^AFc8L=kq=W${6LPR$idX?o zy0?9Btt;LUm7i(cOOr(|5u1-;6gnGeE$_ikNP8!!!r^8HjiD*;#0HX51mFHC;$MDG zdg7Qj+;;uPs$0Q@BcFNwi?iKkJEgw??RDH>2OQwbuA3DEPKPb{C%)XI9ifTi2Upfe zXEhL*^@seaB!X7n4viXvMtQu*+VwGPRT;aH@fk7ZtU z-{*QP>~Oc}!O7b) zJ)Pdm)o2OYxF6YGfdo5|+nnmyZvkz0+0EvKh5Uk&!5yqUdj@3q3DPqOVRU8*YXBG6 zlM3m}v7$8Trjpmm)U;V@u!Y~0T}AC@rFR@z!-yqc7#IUYvd)(jBXpQA$uMryT=LA7aA9@xE`@{Aq zY^;s;wCO$firv>0(?lvoFK=uxVjx}px&h`z%qESG!#lCx0T*+*#+SMwxh=?XA U!Vu=;SAJIfn_)=XLcI%i#Giu|_o$=i^_>4Lo$o~oTHNwwrU@^!l*BdxBdNB6| z2#$NR0h9HEBy9+8kI`GZ6hx08@fnNV`*%m_ORjCoaYwah0W~X! zf-9t2?3%0PHs~L@tf@zvA zM#a+ZCpu-14@>B (D)ag zn+aWj#v7r&hXHKYyFPbAl`wnG@cchgn~BJGj*$VL_0SncLTvdZP&Yk*1RjSb#N0*g zu}%I)*O}#PnaeT$U>^8BZpM4}Z1ZW^$=Vhbw$V*eqv`>GLX@z#aXS(W?x2Q0%#Sb6 z>X%(T#|vx}Axyyb1<0xj$nDh}+k&H&TPaGq!agUvfR4>;7l!7A07cN>dm@JRp^6pe zjT5Hhfa+{`Z=$_dD!dEE-rjIoP4%C-N`aR ZY+v^TyS*F^P<}9pKi+@!eCh5+pBO$Xos-m399WDUp|!{x0Y{FV z1F`aXVen!xkHQeHWMhF?)(vm1{GA2Z)Orc)!=+UExIXw8#UeaFo|frkXBsjfk>` zMxF`XUq1G9Wx>U2Rw?@=@*RFuG}J&7_VM#N@TmQp#Hwc|n*^j`pqN5B?Plf%jE_^> zY Tk_7PvJo*dBh)okdzBSq^Zc5AYl&oWs3Nzr8n})| &YxaOt2Z`vCh`F-3|e3~;(i@`4Eu0yRjWCsjS`33^x?686tt{`$V6n%NE_D>7T% zlUs*M0|L%cKEg`09fa1>+;pnId}&Tw59-1Q&jJDqLt&>r1iJ6Pb$IY2Xo@t&Q2ji> zaq2Fq#VFj;j{xIvLD~OpB>Dd`oc#YPi^|-62d*#;?~!!~$Nw_`1;|J!idTvn1^f?o C+JImH diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 1e0c17cb5..43fedf4ac 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -86,7 +86,7 @@ def safe_join_path(root: str | Path, *unsafe: str | Path) -> Path: path = os.path.abspath(os.path.join(root, *unsafe)) if os.path.commonprefix([root, path]) != root: - # If the common prefix is not root directory we resolved outside the root dir + # We resolved outside the root dir, potential directory traversal attack. raise ValueError( f"Unsafe path detected. Path '{path}' is outside root directory '{root}'" ) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index dabdbba03..bd7d413f4 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -25,7 +25,6 @@ from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor, VdomDict -DEFAULT_BLOCK_SIZE = 8192 _logger = logging.getLogger(__name__) _backhaul_loop = asyncio.new_event_loop() @@ -50,6 +49,7 @@ def __init__( static_dir: Path | str | None = None, head: Sequence[VdomDict] | VdomDict | str = "", backhaul_thread: bool = True, + block_size: int = 8192, ) -> None: self.component = ( app_or_component @@ -78,6 +78,7 @@ def __init__( self._cached_index_html = "" self.connected = False self.backhaul_thread = backhaul_thread + self.block_size = block_size self.dispatcher_future_or_task = None if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() @@ -172,7 +173,7 @@ async def js_modules_app(self, scope, receive, send) -> None: return # Serve the file - await file_response(scope, send, abs_file_path) + await file_response(scope, send, abs_file_path, self.block_size) async def static_file_app(self, scope, receive, send) -> None: """ASGI app for ReactPy static files.""" @@ -192,7 +193,7 @@ async def static_file_app(self, scope, receive, send) -> None: return # Serve the file - await file_response(scope, send, abs_file_path) + await file_response(scope, send, abs_file_path, self.block_size) async def standalone_app(self, scope, receive, send) -> None: """ASGI app for ReactPy standalone mode.""" @@ -276,7 +277,7 @@ async def http_response( await send({"type": "http.response.body", "body": message.encode()}) -async def file_response(scope, send, file_path: Path) -> None: +async def file_response(scope, send, file_path: Path, block_size: int) -> None: """Send a file in chunks.""" # Make sure the file exists if not await asyncio.to_thread(os.path.exists, file_path): @@ -289,7 +290,7 @@ async def file_response(scope, send, file_path: Path) -> None: return # Check if the file is already cached by the client - etag = await get_val_from_header(scope, b"etag") + etag = await header_val(scope, b"etag") modification_time = await asyncio.to_thread(os.path.getmtime, file_path) if etag and etag != modification_time: await http_response(scope, send, 304, "Not modified.") @@ -325,7 +326,7 @@ async def file_response(scope, send, file_path: Path) -> None: # Head requests don't need a body if scope["method"] != "HEAD": while True: - chunk = await file_handle.read(DEFAULT_BLOCK_SIZE) + chunk = await file_handle.read(block_size) more_body = bool(chunk) await send( { @@ -338,9 +339,7 @@ async def file_response(scope, send, file_path: Path) -> None: break -async def get_val_from_header( - scope: dict, key: str, default: str | None = None -) -> str | None: +async def header_val(scope: dict, key: str, default: str | int | None = None) -> str | int | None: """Get a value from a scope's headers.""" return await anext( ( From 5707fba1b72b2830aa61893d8b422d7062d7171b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:36:50 -0700 Subject: [PATCH 18/22] refactor init --- src/py/reactpy/reactpy/backend/asgi.py | 40 +++++++++++++++----------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 8f035b4fc..716899b50 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -24,6 +24,7 @@ from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor, VdomDict +from concurrent.futures import Future _logger = logging.getLogger(__name__) _backhaul_loop = asyncio.new_event_loop() @@ -51,35 +52,40 @@ def __init__( backhaul_thread: bool = True, block_size: int = 8192, ) -> None: - self.component = ( + # Convert kwargs to class attributes + self.dispatch_path = re.compile(dispatcher_path) + self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None + self.static_path = re.compile(static_path) if static_path else None + self.static_dir = static_dir + self.head = vdom_head_elements_to_html(head) + self.backhaul_thread = backhaul_thread + self.block_size = block_size + + # Internal attributes (not using the same name as a kwarg) + self.component: re.Pattern = ( app_or_component if isinstance(app_or_component, ComponentConstructor) else None ) - self.user_app = ( + self.user_app: re.Pattern = ( guarantee_single_callable(app_or_component) if not self.component and asyncio.iscoroutinefunction(app_or_component) else None ) - if not self.component and not self.user_app: - raise TypeError( - "The first argument to ReactPy(...) must be a component or an ASGI application." - ) - self.dispatch_path = re.compile(dispatcher_path) - self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None - self.static_path = re.compile(static_path) if static_path else None - self.static_dir = static_dir - self.all_paths = re.compile( + self.all_paths: re.Pattern = re.compile( "|".join( path for path in [dispatcher_path, js_modules_path, static_path] if path ) ) - self.head = vdom_head_elements_to_html(head) - self._cached_index_html = "" - self.connected = False - self.backhaul_thread = backhaul_thread - self.dispatcher = None - self.block_size = block_size + self.dispatcher: Future | asyncio.Task | None = None + self._cached_index_html: str = "" + self.connected: bool = False + + # Validate the arguments + if not self.component and not self.user_app: + raise TypeError( + "The first argument to ReactPy(...) must be a component or an ASGI application." + ) if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() From 93d7c036fe17e2d3b3cdb30fde158464f693e5bf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:37:15 -0700 Subject: [PATCH 19/22] format --- src/py/reactpy/reactpy/backend/asgi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 716899b50..e2c984520 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -343,7 +343,9 @@ async def file_response(scope, send, file_path: Path, block_size: int) -> None: break -async def header_val(scope: dict, key: str, default: str | int | None = None) -> str | int | None: +async def header_val( + scope: dict, key: str, default: str | int | None = None +) -> str | int | None: """Get a value from a scope's headers.""" return await anext( ( From 24fb8170a7911518577373110b622de26e24df13 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 20 Jul 2023 00:47:30 -0700 Subject: [PATCH 20/22] use starlette for static files --- src/py/reactpy/reactpy/backend/asgi.py | 221 ++++++++------------ src/py/reactpy/reactpy/backend/mimetypes.py | 20 ++ 2 files changed, 110 insertions(+), 131 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index e2c984520..7c08dce8d 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -1,37 +1,37 @@ import asyncio import logging -import mimetypes -import os import re import urllib.parse from collections.abc import Coroutine, Sequence +from concurrent.futures import Future +from importlib import import_module from pathlib import Path from threading import Thread +from typing import Any, Callable import aiofiles import orjson from asgiref.compatibility import guarantee_single_callable +from starlette.staticfiles import StaticFiles from reactpy.backend._common import ( CLIENT_BUILD_DIR, - safe_join_path, vdom_head_elements_to_html, ) from reactpy.backend.hooks import ConnectionContext -from reactpy.backend.mimetypes import MIME_TYPES from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_WEB_MODULES_DIR from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor, VdomDict -from concurrent.futures import Future _logger = logging.getLogger(__name__) _backhaul_loop = asyncio.new_event_loop() def start_backhaul_loop(): - """Starts the asyncio event loop that will perform component rendering tasks.""" + """Starts the asyncio event loop that will perform component rendering + tasks.""" asyncio.set_event_loop(_backhaul_loop) _backhaul_loop.run_forever() @@ -42,7 +42,7 @@ def start_backhaul_loop(): class ReactPy: def __init__( self, - app_or_component: ComponentConstructor | Coroutine, + app_or_component: ComponentConstructor | Callable[..., Coroutine], *, dispatcher_path: str = "^reactpy/([^/]+)/?", js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", @@ -62,16 +62,14 @@ def __init__( self.block_size = block_size # Internal attributes (not using the same name as a kwarg) - self.component: re.Pattern = ( - app_or_component - if isinstance(app_or_component, ComponentConstructor) - else None - ) - self.user_app: re.Pattern = ( + self.user_app: Callable[..., Coroutine] | None = ( guarantee_single_callable(app_or_component) - if not self.component and asyncio.iscoroutinefunction(app_or_component) + if asyncio.iscoroutinefunction(app_or_component) else None ) + self.component: ComponentConstructor | None = ( + None if self.user_app else app_or_component + ) self.all_paths: re.Pattern = re.compile( "|".join( path for path in [dispatcher_path, js_modules_path, static_path] if path @@ -79,18 +77,28 @@ def __init__( ) self.dispatcher: Future | asyncio.Task | None = None self._cached_index_html: str = "" + self._static_file_server: StaticFiles | None = None + self._js_module_server: StaticFiles | None = None self.connected: bool = False + # TODO: Remove this setting from ReactPy config + self.js_modules_dir: Path | None = REACTPY_WEB_MODULES_DIR.current # Validate the arguments if not self.component and not self.user_app: raise TypeError( - "The first argument to ReactPy(...) must be a component or an ASGI application." + "The first argument to ReactPy(...) must be a component or an " + "ASGI application." ) if self.backhaul_thread and not _backhaul_thread.is_alive(): _backhaul_thread.start() - async def __call__(self, scope, receive, send) -> None: - """The ASGI callable. This determines whether ReactPy should route the the + async def __call__( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """The ASGI callable. This determines whether ReactPy should route the request to ourselves or to the user application.""" # Determine if ReactPy should handle the request if not self.user_app or re.match(self.all_paths, scope["path"]): @@ -100,10 +108,14 @@ async def __call__(self, scope, receive, send) -> None: # Serve the user's application await self.user_app(scope, receive, send) - async def reactpy_app(self, scope, receive, send) -> None: - """Determine what type of request this is and route it to the appropriate - ReactPy ASGI sub-application.""" - + async def reactpy_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: + """Determine what type of request this is and route it to the + appropriate ReactPy ASGI sub-application.""" # Only HTTP and WebSocket requests are supported if scope["type"] not in {"http", "websocket"}: return @@ -120,7 +132,7 @@ async def reactpy_app(self, scope, receive, send) -> None: # JS modules app if self.js_modules_path and re.match(self.js_modules_path, scope["path"]): - await self.js_modules_app(scope, receive, send) + await self.js_module_app(scope, receive, send) return # Static file app @@ -133,7 +145,12 @@ async def reactpy_app(self, scope, receive, send) -> None: await self.standalone_app(scope, receive, send) return - async def component_dispatch_app(self, scope, receive, send) -> None: + async def component_dispatch_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: """ASGI app for rendering ReactPy Python components.""" while True: event = await receive() @@ -161,45 +178,50 @@ async def component_dispatch_app(self, scope, receive, send) -> None: else: await recv_queue_put - async def js_modules_app(self, scope, receive, send) -> None: + async def js_module_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: """ASGI app for ReactPy web modules.""" - if not REACTPY_WEB_MODULES_DIR.current: + if not self.js_modules_dir: raise RuntimeError("No web modules directory configured.") - - # Make sure the user hasn't tried to escape the web modules directory - try: - abs_file_path = safe_join_path( - REACTPY_WEB_MODULES_DIR.current, - re.match(self.js_modules_path, scope["path"])[1], + if not self.js_modules_path: + raise RuntimeError( + "Web modules cannot be served without defining `js_module_path`." ) - except ValueError: - await http_response(scope, send, 403, "Forbidden") - return + if not self._js_module_server: + self._js_module_server = StaticFiles(directory=self.js_modules_dir) - # Serve the file - await file_response(scope, send, abs_file_path, self.block_size) + await self._js_module_server(scope, receive, send) - async def static_file_app(self, scope, receive, send) -> None: + async def static_file_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: """ASGI app for ReactPy static files.""" if not self.static_dir: raise RuntimeError( "Static files cannot be served without defining `static_dir`." ) - - # Make sure the user hasn't tried to escape the static directory - try: - abs_file_path = safe_join_path( - self.static_dir, - re.match(self.static_path, scope["path"])[1], + if not self.static_path: + raise RuntimeError( + "Static files cannot be served without defining `static_path`." ) - except ValueError: - await http_response(scope, send, 403, "Forbidden") - return + if not self._static_file_server: + self._static_file_server = StaticFiles(directory=self.static_dir) - # Serve the file - await file_response(scope, send, abs_file_path, self.block_size) + await self._static_file_server(scope, receive, send) - async def standalone_app(self, scope, receive, send) -> None: + async def standalone_app( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: """ASGI app for ReactPy standalone mode.""" file_path = CLIENT_BUILD_DIR / "index.html" if not self._cached_index_html: @@ -221,10 +243,23 @@ async def standalone_app(self, scope, receive, send) -> None: ], ) - async def run_dispatcher(self, scope, receive, send): + async def run_dispatcher( + self, + scope: dict[str, Any], + receive: Callable[..., Coroutine], + send: Callable[..., Coroutine], + ) -> None: # If in standalone mode, serve the user provided component. # In middleware mode, get the component from the URL. - component = self.component or re.match(self.dispatch_path, scope["path"])[1] + component = self.component + if not component: + url_path = re.match(self.dispatch_path, scope["path"]) + if not url_path: + raise RuntimeError("Could not find component in URL path.") + dotted_path = url_path[1] + module_str, component_str = dotted_path.rsplit(".", 1) + module = import_module(module_str) + component = getattr(module, component_str) parsed_url = urllib.parse.urlparse(scope["path"]) self.recv_queue: asyncio.Queue = asyncio.Queue() @@ -251,18 +286,18 @@ async def run_dispatcher(self, scope, receive, send): ) -def send_json(send) -> None: +def send_json(send: Callable) -> Callable[..., Coroutine]: """Use orjson to send JSON over an ASGI websocket.""" - async def _send_json(value) -> None: + async def _send_json(value: Any) -> None: await send({"type": "websocket.send", "text": orjson.dumps(value)}) return _send_json async def http_response( - scope, - send, + scope: dict[str, Any], + send: Callable[..., Coroutine], code: int, message: str, content_type: bytes = b"text/plain", @@ -279,79 +314,3 @@ async def http_response( # Head requests don't need a body if scope["method"] != "HEAD": await send({"type": "http.response.body", "body": message.encode()}) - - -async def file_response(scope, send, file_path: Path, block_size: int) -> None: - """Send a file in chunks.""" - # Make sure the file exists - if not await asyncio.to_thread(os.path.exists, file_path): - await http_response(scope, send, 404, "File not found.") - return - - # Make sure it's a file - if not await asyncio.to_thread(os.path.isfile, file_path): - await http_response(scope, send, 400, "Not a file.") - return - - # Check if the file is already cached by the client - etag = await header_val(scope, b"etag") - modification_time = await asyncio.to_thread(os.path.getmtime, file_path) - if etag and etag != modification_time: - await http_response(scope, send, 304, "Not modified.") - return - - # Get the file's MIME type - mime_type = ( - MIME_TYPES.get(file_path.rsplit(".")[1], None) - # Fallback to guess_type to allow for the user to define custom MIME types on their system - or (await asyncio.to_thread(mimetypes.guess_type, file_path, strict=False))[0] - ) - if mime_type is None: - mime_type = "text/plain" - _logger.error( - f"Could not determine MIME type for {file_path}. Defaulting to 'text/plain'." - ) - - # Send the file in chunks - file_size = await asyncio.to_thread(os.path.getsize, file_path) - async with aiofiles.open(file_path, "rb") as file_handle: - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [ - (b"content-type", mime_type.encode()), - (b"etag", modification_time), - (b"content-length", file_size), - ], - } - ) - - # Head requests don't need a body - if scope["method"] != "HEAD": - while True: - chunk = await file_handle.read(block_size) - more_body = bool(chunk) - await send( - { - "type": "http.response.body", - "body": chunk, - "more_body": more_body, - } - ) - if not more_body: - break - - -async def header_val( - scope: dict, key: str, default: str | int | None = None -) -> str | int | None: - """Get a value from a scope's headers.""" - return await anext( - ( - value.decode() - for header_key, value in scope["headers"] - if header_key == key.encode() - ), - default, - ) diff --git a/src/py/reactpy/reactpy/backend/mimetypes.py b/src/py/reactpy/reactpy/backend/mimetypes.py index 051d5c88e..ee319a2c2 100644 --- a/src/py/reactpy/reactpy/backend/mimetypes.py +++ b/src/py/reactpy/reactpy/backend/mimetypes.py @@ -2,6 +2,11 @@ We ship our own mime types to ensure consistent behavior across platforms. This dictionary is based on: https://github.com/micnic/mime.json """ +import mimetypes +import os +import typing + +from starlette import responses MIME_TYPES = { "123": "application/vnd.lotus-1-2-3", @@ -1206,3 +1211,18 @@ "zirz": "application/vnd.zul", "zmm": "application/vnd.handheld-entertainment+xml", } + + +def guess_type( + url: typing.Union[str, "os.PathLike[str]"], + strict: bool = True, +): + """Mime type checker that prefers our predefined types over the built-in + mimetypes module.""" + mime_type, encoding = mimetypes.guess_type(url, strict) + + return (MIME_TYPES.get(str(url).rsplit(".")[1]) or mime_type, encoding) + + +# Monkey patch starlette's mime types +responses.guess_type = guess_type From 5df567e81e703529ef60ae431c8801a03aa55ae8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 22 Jul 2023 21:42:46 -0700 Subject: [PATCH 21/22] local ws connection --- src/py/reactpy/reactpy/backend/asgi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 7c08dce8d..80254eb13 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -152,11 +152,14 @@ async def component_dispatch_app( send: Callable[..., Coroutine], ) -> None: """ASGI app for rendering ReactPy Python components.""" + ws_connected: bool = False + while True: + # Future WS events on this connection will always be received here event = await receive() - if event["type"] == "websocket.connect" and not self.connected: - self.connected = True + if event["type"] == "websocket.connect" and not ws_connected: + ws_connected = True await send({"type": "websocket.accept"}) run_dispatcher = self.run_dispatcher(scope, receive, send) if self.backhaul_thread: From 57d47da0d9630416d11e138373795185943c8110 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 23 Jul 2023 00:39:03 -0700 Subject: [PATCH 22/22] more refactoring --- src/py/reactpy/reactpy/backend/asgi.py | 99 ++++++++++++++++---------- 1 file changed, 63 insertions(+), 36 deletions(-) diff --git a/src/py/reactpy/reactpy/backend/asgi.py b/src/py/reactpy/reactpy/backend/asgi.py index 80254eb13..f9f0e7d54 100644 --- a/src/py/reactpy/reactpy/backend/asgi.py +++ b/src/py/reactpy/reactpy/backend/asgi.py @@ -21,9 +21,10 @@ from reactpy.backend.hooks import ConnectionContext from reactpy.backend.types import Connection, Location from reactpy.config import REACTPY_WEB_MODULES_DIR +from reactpy.core.component import Component from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy.core.types import ComponentConstructor, VdomDict +from reactpy.core.types import ComponentType, VdomDict _logger = logging.getLogger(__name__) _backhaul_loop = asyncio.new_event_loop() @@ -42,20 +43,24 @@ def start_backhaul_loop(): class ReactPy: def __init__( self, - app_or_component: ComponentConstructor | Callable[..., Coroutine], + app_or_component: ComponentType | Callable[..., Coroutine], *, - dispatcher_path: str = "^reactpy/([^/]+)/?", - js_modules_path: str | None = "^reactpy/modules/([^/]+)/?", - static_path: str | None = "^reactpy/static/([^/]+)/?", + dispatcher_path: str = "reactpy/", + web_modules_path: str = "reactpy/modules/", + web_modules_dir: Path | str | None = REACTPY_WEB_MODULES_DIR.current, + static_path: str = "reactpy/static/", static_dir: Path | str | None = None, head: Sequence[VdomDict] | VdomDict | str = "", backhaul_thread: bool = True, block_size: int = 8192, ) -> None: + """Anything initialized in this method will be shared across all + requests.""" # Convert kwargs to class attributes - self.dispatch_path = re.compile(dispatcher_path) - self.js_modules_path = re.compile(js_modules_path) if js_modules_path else None - self.static_path = re.compile(static_path) if static_path else None + self.dispatch_path = re.compile(f"^{dispatcher_path}(?P [^/]+)/?") + self.js_modules_path = re.compile(f"^{web_modules_path}") + self.web_modules_dir = web_modules_dir + self.static_path = re.compile(f"^{static_path}") self.static_dir = static_dir self.head = vdom_head_elements_to_html(head) self.backhaul_thread = backhaul_thread @@ -67,21 +72,26 @@ def __init__( if asyncio.iscoroutinefunction(app_or_component) else None ) - self.component: ComponentConstructor | None = ( - None if self.user_app else app_or_component + self.component: ComponentType | None = ( + None if self.user_app else app_or_component # type: ignore ) self.all_paths: re.Pattern = re.compile( "|".join( - path for path in [dispatcher_path, js_modules_path, static_path] if path + path + for path in [dispatcher_path, web_modules_path, static_path] + if path ) ) self.dispatcher: Future | asyncio.Task | None = None self._cached_index_html: str = "" self._static_file_server: StaticFiles | None = None - self._js_module_server: StaticFiles | None = None - self.connected: bool = False - # TODO: Remove this setting from ReactPy config - self.js_modules_dir: Path | None = REACTPY_WEB_MODULES_DIR.current + self._web_module_server: StaticFiles | None = None + + # Startup tasks + if self.backhaul_thread and not _backhaul_thread.is_alive(): + _backhaul_thread.start() + if self.web_modules_dir != REACTPY_WEB_MODULES_DIR.current: + REACTPY_WEB_MODULES_DIR.set_current(self.web_modules_dir) # Validate the arguments if not self.component and not self.user_app: @@ -89,8 +99,12 @@ def __init__( "The first argument to ReactPy(...) must be a component or an " "ASGI application." ) - if self.backhaul_thread and not _backhaul_thread.is_alive(): - _backhaul_thread.start() + if check_path(dispatcher_path): + raise ValueError("Invalid `dispatcher_path`.") + if check_path(web_modules_path): + raise ValueError("Invalid `web_modules_path`.") + if check_path(static_path): + raise ValueError("Invalid `static_path`.") async def __call__( self, @@ -131,12 +145,12 @@ async def reactpy_app( return # JS modules app - if self.js_modules_path and re.match(self.js_modules_path, scope["path"]): - await self.js_module_app(scope, receive, send) + if re.match(self.js_modules_path, scope["path"]): + await self.web_module_app(scope, receive, send) return # Static file app - if self.static_path and re.match(self.static_path, scope["path"]): + if re.match(self.static_path, scope["path"]): await self.static_file_app(scope, receive, send) return @@ -181,23 +195,25 @@ async def component_dispatch_app( else: await recv_queue_put - async def js_module_app( + async def web_module_app( self, scope: dict[str, Any], receive: Callable[..., Coroutine], send: Callable[..., Coroutine], ) -> None: """ASGI app for ReactPy web modules.""" - if not self.js_modules_dir: - raise RuntimeError("No web modules directory configured.") - if not self.js_modules_path: - raise RuntimeError( - "Web modules cannot be served without defining `js_module_path`." + if not self.web_modules_dir: + await asyncio.to_thread( + _logger.info, + "Tried to serve web module without a configured directory.", ) - if not self._js_module_server: - self._js_module_server = StaticFiles(directory=self.js_modules_dir) + if self.user_app: + await self.user_app(scope, receive, send) + return - await self._js_module_server(scope, receive, send) + if not self._web_module_server: + self._web_module_server = StaticFiles(directory=self.web_modules_dir) + await self._web_module_server(scope, receive, send) async def static_file_app( self, @@ -206,17 +222,18 @@ async def static_file_app( send: Callable[..., Coroutine], ) -> None: """ASGI app for ReactPy static files.""" + # If no static directory is configured, serve the user's application if not self.static_dir: - raise RuntimeError( - "Static files cannot be served without defining `static_dir`." - ) - if not self.static_path: - raise RuntimeError( - "Static files cannot be served without defining `static_path`." + await asyncio.to_thread( + _logger.info, + "Tried to serve static file without a configured directory.", ) + if self.user_app: + await self.user_app(scope, receive, send) + return + if not self._static_file_server: self._static_file_server = StaticFiles(directory=self.static_dir) - await self._static_file_server(scope, receive, send) async def standalone_app( @@ -317,3 +334,13 @@ async def http_response( # Head requests don't need a body if scope["method"] != "HEAD": await send({"type": "http.response.body", "body": message.encode()}) + + +def check_path(url_path: str) -> bool: + """Check that a path is valid URL path.""" + return ( + not url_path + or not isinstance(url_path, str) + or not url_path[0].isalnum() + or not url_path.endswith("/") + )