diff --git a/marimo/_messaging/notification.py b/marimo/_messaging/notification.py index 72dd4415fc7..e9e49c7f04c 100644 --- a/marimo/_messaging/notification.py +++ b/marimo/_messaging/notification.py @@ -3,12 +3,14 @@ from __future__ import annotations +import re import time from typing import ( Any, ClassVar, Literal, ) +from urllib.parse import urlsplit import msgspec @@ -48,6 +50,28 @@ LOGGER = loggers.marimo_logger() +_VIRTUAL_FILE_URL_RE = re.compile(r"^(?:\.?/)?@file/[^?#]+$") +_JAVASCRIPT_DATA_URL_RE = re.compile( + r"^data:(?:text|application)/javascript(?:;[^,]*)?,[^\r\n]*$" +) + + +def _normalize_esm_url(value: str) -> str | None: + if _VIRTUAL_FILE_URL_RE.fullmatch(value) is not None: + return value + if _JAVASCRIPT_DATA_URL_RE.fullmatch(value) is not None: + return value + if any(character.isspace() for character in value): + return None + + try: + parsed = urlsplit(value) + except ValueError: + return None + if parsed.scheme in ("http", "https") and parsed.netloc: + return parsed.geturl() + return None + class Notification(msgspec.Struct, tag_field="op"): """Base class for all kernel-to-frontend notifications. @@ -202,7 +226,10 @@ def from_esm(esm: str) -> EsmSpec: import marimo._output.data.data as mo_data from marimo._utils.code import hash_code - return EsmSpec(url=mo_data.js(esm).url, hash=hash_code(esm)) + url = _normalize_esm_url(esm) + if url is None: + url = mo_data.any_data(esm.encode("utf-8"), ext="js").url + return EsmSpec(url=url, hash=hash_code(esm)) class ModelOpen(msgspec.Struct, tag="open", tag_field="method"): diff --git a/tests/_plugins/ui/_impl/test_comm.py b/tests/_plugins/ui/_impl/test_comm.py index 0cf1224a21a..909421ec0f2 100644 --- a/tests/_plugins/ui/_impl/test_comm.py +++ b/tests/_plugins/ui/_impl/test_comm.py @@ -390,6 +390,33 @@ def test_url_form_esm_passes_through(self, comm_manager): assert message.esm_spec.url == url assert message.esm_spec.hash == hash_code(url) + def test_url_form_esm_normalizes_scheme(self, comm_manager): + url = "HTTPS://esm.sh/some-widget@1.0.0" + _, message = self._open_comm(comm_manager, {"_esm": url}) + + assert message.esm_spec is not None + assert message.esm_spec.url == "https://esm.sh/some-widget@1.0.0" + + def test_virtual_file_url_esm_passes_through(self, comm_manager): + from marimo._utils.code import hash_code + + url = "./@file/64-bundle.js" + _, message = self._open_comm(comm_manager, {"_esm": url}) + + assert message.esm_spec is not None + assert message.esm_spec.url == url + assert message.esm_spec.hash == hash_code(url) + + def test_url_like_inline_esm_is_minted(self, comm_manager): + esm = "data: {\n const value = 1;\n}\nexport default {};" + _, message = self._open_comm(comm_manager, {"_esm": esm}) + + assert message.esm_spec is not None + assert message.esm_spec.url.startswith( + "data:application/javascript;base64," + ) + assert message.esm_spec.url != esm + def test_no_esm_means_no_spec(self, comm_manager): comm, message = self._open_comm(comm_manager, {"value": 5})