chromium/ash/webui/recorder_app_ui/resources/scripts/cra/commands/dev.py

# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import functools
import http.server
import json
import logging
import mimetypes
import os
import pathlib
import re
from typing import Callable, NamedTuple, Optional, Union
from urllib import parse as urllib_parse
from xml.dom import minidom

from cra import build
from cra import cli
from cra import util

_RequestPath = pathlib.PurePosixPath


def _get_root_relative_path(request_path: _RequestPath) -> _RequestPath:
    request_path = request_path.parent
    root = _RequestPath("/")
    # Note that relative_to can't be used here since it requires the path to be
    # inside target folder.
    return _RequestPath(os.path.relpath(root, root / request_path))


def _stub_chrome_url(request_path: _RequestPath, s: str) -> str:
    """
    Replaces all chrome:// reference to /chrome_stub/.

    Also replaces all //resources/ references to /chrome_stub/resources/, since
    some imports in cros_component and mwc use // instead of chrome://, but
    replacing all '//' is too broad.
    """
    # Note that the path join needs to be done via string interpolation instead
    # of manipulation on _RequestPath, since './chrome_stub' gets transformed
    # into 'chrome_stub' by pathlib.
    chrome_stub_path = f"{_get_root_relative_path(request_path)}/chrome_stub/"
    return s.replace("chrome://", chrome_stub_path).replace(
        "//resources/", f"{chrome_stub_path}resources/")


class _Route(NamedTuple):
    # The url pattern for the route. Can be a regex.
    pattern: Union[_RequestPath, re.Pattern]
    # Handler of the route, takes path as argument and returns response in
    # bytes and the content type.
    handler: Callable[[_RequestPath], tuple[bytes, str]]


# importmap tag to make the import of /images/images.js independent of the base
# path. The import is absolute because of build issue (See comment in
# resources/BUILD.gn on ts_path_mappings).
IMPORT_MAP = ('<script type="importmap">' +
              '{"imports":{"/images/": "./images/"}}' + '</script>')


class RequestHandler:

    def __init__(
        self,
        cra_root: pathlib.Path,
        tsc_root: pathlib.Path,
        build_dir: pathlib.Path,
        strings_dir: pathlib.Path,
    ):
        self._cra_root = cra_root
        self._tsc_root = tsc_root
        self._gen_dir = build_dir / "gen"
        self._strings_dir = strings_dir
        self._dev_static_dir = self._cra_root / "scripts/cra/static"
        self._routes = self._build_routes()
        mimetypes.add_type('application/javascript', '.map')

    def _transform_html(self, request_path: _RequestPath, html: str) -> str:
        html = _stub_chrome_url(request_path, html)

        relative_path = _get_root_relative_path(request_path)
        html = re.sub(r"(href|src)=\"/", f'\\1="{relative_path}/', html)
        # Put the import map before the first script tag.
        html = html.replace('<script', IMPORT_MAP + '<script')

        return html

    def _transform_js(self, request_path: _RequestPath, js: str) -> str:
        return _stub_chrome_url(request_path, js)

    def _transform_init_js(self, request_path: _RequestPath, js: str) -> str:
        # TODO(pihsun): The inline source would still be wrong, have some hacky
        # way to fix that too.
        js = js.replace("'./platforms/swa/handler.js'",
                        "'./platforms/dev/handler.js'")
        return self._transform_js(request_path, js)

    def _load_grd_strings(self) -> dict[str, str]:

        def get_message_text_content(message: minidom.Element) -> str:
            pieces = []
            for child in message.childNodes:
                if child.nodeType == minidom.Element.TEXT_NODE:
                    pieces.append(child.nodeValue)
                if child.nodeType == minidom.Element.ELEMENT_NODE:
                    if child.tagName == "ex":
                        continue
                    pieces.append(get_message_text_content(child))
            return "".join(pieces)

        strings = {}
        grd_path = self._strings_dir / "recorder_strings.grdp"
        dom = minidom.parse(str(grd_path))
        messages = dom.getElementsByTagName("grit-part")[0]
        for message in messages.getElementsByTagName("message"):
            name = message.getAttribute("name")
            value = get_message_text_content(message).strip()
            assert name.startswith("IDS_RECORDER_")
            id = name[len("IDS_RECORDER_"):]
            id = util.to_camel_case(id)
            strings[id] = value
        return strings

    def _handle_dev_strings_js(
            self, _request_path: _RequestPath) -> tuple[bytes, str]:
        grd_strings = self._load_grd_strings()

        return (f"export const strings = {json.dumps(grd_strings)};".encode(),
                "text/javascript")

    def _handle_images_js(self,
                          request_path: _RequestPath) -> tuple[bytes, str]:
        # TODO(pihsun): With watch, we can cache the result and only
        # re-generate when any image files are changed.
        return (self._transform_js(request_path,
                                   build.gen_images_js()).encode(),
                "text/javascript")

    def _handle_static_file(
        self,
        request_path: _RequestPath,
        *,
        root: Optional[pathlib.Path] = None,
        path: Optional[Union[_RequestPath, Callable[[_RequestPath],
                                                    _RequestPath]]] = None,
        transform: Optional[Callable[[_RequestPath, str], str]] = None,
        content_type: Optional[str] = None,
    ) -> tuple[bytes, str]:

        def calculate_path():
            if callable(path):
                return path(request_path)
            return path or request_path

        root = root or self._cra_root
        path = calculate_path()

        if content_type is None:
            content_type = mimetypes.guess_type(path)[0]
            if content_type is None:
                raise RuntimeError(
                    f"Can't guess MIME type for {request_path} ({path}).")

        with open(root / path, "rb") as f:
            content = f.read()
            if transform is not None:
                content = transform(request_path, content.decode()).encode()
            return (content, content_type)

    def _build_routes(self) -> list[_Route]:
        """
        Returns a list of routes served by the dev server.

        Note that bundle.py also use this same set of routes for generating
        static files bundle, so anything specific to dev server should be in
        DevServerHandler.
        """
        return [
            # Stubbed file from chrome://.
            _Route(
                re.compile("chrome_stub/resources/(mwc|cros_components)/.*"),
                functools.partial(
                    self._handle_static_file,
                    root=self._gen_dir / "ui/webui/resources/tsc/",
                    path=lambda path: _RequestPath(*path.parts[2:]),
                    transform=_stub_chrome_url,
                ),
            ),
            # Stubbed css files.
            _Route(
                _RequestPath("chrome_stub/theme/typography.css"),
                functools.partial(
                    self._handle_static_file,
                    root=self._dev_static_dir,
                    path=_RequestPath("typography.css"),
                ),
            ),
            _Route(
                _RequestPath("chrome_stub/theme/colors.css"),
                functools.partial(
                    self._handle_static_file,
                    root=self._dev_static_dir,
                    path=_RequestPath("colors.css"),
                ),
            ),
            # All static files.
            _Route(re.compile(r"static/.*"), self._handle_static_file),
            # images.js are dynamically generated from images.
            _Route(_RequestPath("images/images.js"), self._handle_images_js),
            # init.js needs special transform to change the platform used.
            _Route(
                _RequestPath("init.js"),
                functools.partial(
                    self._handle_static_file,
                    root=self._tsc_root,
                    transform=self._transform_init_js,
                ),
            ),
            # platforms/dev/strings.js is for strings in dev server.
            _Route(
                _RequestPath("platforms/dev/strings.js"),
                self._handle_dev_strings_js,
            ),
            # All other .js files.
            _Route(
                re.compile(r".*\.js"),
                functools.partial(
                    self._handle_static_file,
                    root=self._tsc_root,
                    transform=self._transform_js,
                ),
            ),
            # index.html.
            _Route(
                _RequestPath("index.html"),
                functools.partial(self._handle_static_file,
                                  transform=self._transform_html),
            ),
            # Other request path without extension, assuming that it's handled
            # by client side navigation.
            # Files with extension is omitted so it's easier to debug when
            # import paths are wrong.
            # Note that "." is also included since empty relative path is
            # represented by "." by pathlib.
            _Route(
                re.compile(r"[^.]*|\."),
                functools.partial(self._handle_static_file,
                                  path=_RequestPath("index.html"),
                                  transform=self._transform_html),
            ),
        ]

    def handle(self, path: _RequestPath) -> Optional[tuple[bytes, str]]:

        def _route_match(route: _Route) -> bool:
            if isinstance(route.pattern, _RequestPath):
                return path == route.pattern
            return route.pattern.fullmatch(str(path)) is not None

        for route in self._routes:
            if _route_match(route):
                return route.handler(path)

        return None


class DevServerHandler(http.server.SimpleHTTPRequestHandler):

    def __init__(
        self,
        handler: RequestHandler,
        *args,
        **kwargs,
    ):
        self._handler = handler

        super().__init__(*args, **kwargs)

    def end_headers(self):
        self.send_header("Cache-Control", "no-cache")
        super().end_headers()

    def _send_200(self, content: bytes, content_type: str):
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(content)))
        self.end_headers()
        self.wfile.write(content)

    def do_GET(self):
        # Remove query parameters, and transform to relative path.
        path = _RequestPath(urllib_parse.urlparse(self.path).path).relative_to(
            _RequestPath("/"))

        try:
            resp = self._handler.handle(path)
        except Exception as e:
            logging.debug("Error while handling %r: %r",
                          path,
                          e,
                          exc_info=True)
            self.send_response(404)
            self.end_headers()
            return

        if resp is None:
            self.send_response(404)
            self.end_headers()
            return

        content, content_type = resp
        return self._send_200(content, content_type)


_DEV_OUTPUT_TEMP_DIR = pathlib.Path("/tmp/cra-dev-out")


@cli.command(
    "dev",
    help="run local dev server",
    description="run local dev server for UI development",
)
@util.build_dir_option()
@cli.option(
    "--port",
    default=10244,
    type=int,
    help="server port",
)
def cmd(build_dir: pathlib.Path, port: int) -> int:
    _DEV_OUTPUT_TEMP_DIR.mkdir(parents=True, exist_ok=True)

    build.generate_tsconfig(build_dir)

    # TODO(pihsun): Watch / live reload
    util.run_node(
        [
            "typescript/bin/tsc",
            "--outDir",
            str(_DEV_OUTPUT_TEMP_DIR),
            "--noEmit",
            "false",
            # Makes compilation faster
            "--incremental",
            # For better debugging experience.
            "--inlineSourceMap",
            "--inlineSources",
            # Makes devtools show TypeScript source with better path
            "--sourceRoot",
            "/",
            # For easier developing / test cycle.
            "--noUnusedLocals",
            "false",
            "--noUnusedParameters",
            "false",
        ],
        cwd=util.get_cra_root())

    handler = RequestHandler(util.get_cra_root(), _DEV_OUTPUT_TEMP_DIR,
                             build_dir, util.get_strings_dir())
    dev_server = http.server.ThreadingHTTPServer(
        ("localhost", port),
        lambda *args: DevServerHandler(handler, *args),
    )

    logging.info(f"Starting server on http://localhost:{port}")
    dev_server.serve_forever()

    return 0