diff --git a/.github/workflows/pull-request-tests.yml b/.github/workflows/pull-request-tests.yml index 11e18cdf3..4c2d551a0 100644 --- a/.github/workflows/pull-request-tests.yml +++ b/.github/workflows/pull-request-tests.yml @@ -400,6 +400,16 @@ jobs: with: python-version: '3.12' + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - uses: jiro4989/setup-nim-action@v1 + with: + nim-version: 2.2.4 + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install redis run: sudo apt-get -y update && sudo apt-get install -y redis-server @@ -428,6 +438,22 @@ jobs: mkdir -p ../frontend/dist/static TEST=1 pytest + frameos-setup-script: + name: Standalone setup script + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Verify Docker + run: docker version + + - name: Check shell syntax + run: sh -n scripts/frameos-setup.sh + + - name: Run setup script container tests + run: python3 -m unittest scripts.tests.test_frameos_setup -v + deploy-e2e: name: Deploy E2E SSH runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 46aee14f2..90eb8506f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ - Output folder is consumed by the backend’s static file mounts; ensure `frontend/dist` exists (e.g., via `pnpm --dir frontend run build`) before running the Python app outside of test mode. 【F:backend/app/fastapi.py†L38-L86】 - ALWAYS prefer writing frontend business logic in kea logic files over using effects like `useState` or `useEffect`. - This includes small functions and callbacks inside components. Prefer to keep as much code as possible in logic files, treating React as a templating layer. +- When adding a frame model key that is tracked for deploy changes in `frontend/src/scenes/frame/frameLogic.ts`, also add a marker to `FRAME_KEY_INTRODUCED_FRAMEOS_VERSION`. For unreleased work, use the next patch after the current `versions.json` FrameOS base version. ## Device runtime (Nim) notes - `frameos/frameos` houses the on-device runtime written in Nim with asyncdispatch. diff --git a/Dockerfile b/Dockerfile index f1ff7b63b..3a8faa50a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,8 +56,8 @@ RUN nim --version && nimble --version FROM nim-toolchain AS app-builder ARG FRAMEOS_ARCHIVE_BASE_URL=https://archive.frameos.net -ARG QUICKJS_VERSION=2025-04-26 -ARG QUICKJS_SHA256=2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf +ARG QUICKJS_VERSION=2026-06-04 +ARG QUICKJS_SHA256=b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a ENV DEBIAN_FRONTEND=noninteractive @@ -136,6 +136,14 @@ RUN find /app/frameos -path '*/tests' -type d -prune -exec rm -rf {} + \ /app/frameos/agent/build \ /app/frameos/agent/tmp +WORKDIR /app/frameos +RUN nim c \ + --nimCache:/tmp/frameos-native-js-transpile-nimcache \ + --out:/app/frameos/build/native_js_transpile \ + tools/native_js_transpile.nim \ + && test -x /app/frameos/build/native_js_transpile \ + && rm -rf /tmp/frameos-native-js-transpile-nimcache + FROM ${PYTHON_IMAGE} AS python-deps ENV DEBIAN_FRONTEND=noninteractive @@ -163,6 +171,7 @@ ENV DEBIAN_FRONTEND=noninteractive ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 ENV VIRTUAL_ENV=/app/backend/.venv +ENV FRAMEOS_NATIVE_JS_TRANSPILE=/app/frameos/build/native_js_transpile ENV PATH="/opt/nim/bin:${VIRTUAL_ENV}/bin:${PATH}" WORKDIR /app diff --git a/backend/app/tasks/buildroot_image.py b/backend/app/tasks/buildroot_image.py index 263f1ab75..5ab216ff5 100644 --- a/backend/app/tasks/buildroot_image.py +++ b/backend/app/tasks/buildroot_image.py @@ -4,6 +4,7 @@ import copy import hashlib import json +import math import os import re import shlex @@ -63,9 +64,11 @@ BUILDROOT_HOST_CFLAGS = "-O2 -pipe" BUILDROOT_JLEVEL = int(os.environ.get("FRAMEOS_BUILDROOT_JLEVEL", "0")) BUILDROOT_BOOTSTRAP_SCRIPT_VERSION = "5" -BUILDROOT_SD_IMAGE_CUSTOMIZATION_VERSION = 14 -BUILDROOT_FRAMEOS_PARTITION_SIZE = os.environ.get("FRAMEOS_BUILDROOT_FRAMEOS_PARTITION_SIZE", "100M") -BUILDROOT_ASSETS_PARTITION_SIZE = os.environ.get("FRAMEOS_BUILDROOT_ASSETS_PARTITION_SIZE", "100M") +BUILDROOT_SD_IMAGE_CUSTOMIZATION_VERSION = 15 +BUILDROOT_FRAMEOS_PARTITION_SIZE = os.environ.get("FRAMEOS_BUILDROOT_FRAMEOS_PARTITION_SIZE", "30M") +BUILDROOT_ASSETS_PARTITION_SIZE = os.environ.get("FRAMEOS_BUILDROOT_ASSETS_PARTITION_SIZE", "30M") +BUILDROOT_DATA_PARTITION_HEADROOM_BYTES = 8 * 1024 * 1024 +BUILDROOT_DATA_PARTITION_HEADROOM_RATIO = 1.25 BUILDROOT_LOCAL_FONTS_DIR = REPO_ROOT / "frameos" / "assets" / "copied" / "fonts" BUILDROOT_LOCAL_FONT_EXTENSIONS = {".ttf", ".txt", ".md"} BUILDROOT_ARCHIVE_BASE_URL = os.environ.get("FRAMEOS_ARCHIVE_BASE_URL", "https://archive.frameos.net/") @@ -1547,6 +1550,10 @@ async def _compose_sd_image_from_base( if not any(assets_root.iterdir()): (assets_root / "frameos-assets-placeholder").write_text("", encoding="utf-8") + frameos_partition_size = _partition_size_for_root( + frameos_root, + minimum_size=BUILDROOT_FRAMEOS_PARTITION_SIZE, + ) compose_roots = f"/tmp/frameos-compose-roots-{os.getpid()}-{secure_token(6)}" genimage_cfg = compose_dir / "frameos-genimage.cfg" genimage_cfg.write_text( @@ -1556,7 +1563,7 @@ async def _compose_sd_image_from_base( label = "FRAMEOS" }} srcpath = "{compose_roots}/frameos" - size = {BUILDROOT_FRAMEOS_PARTITION_SIZE} + size = {frameos_partition_size} }} image assets.vfat {{ @@ -1627,6 +1634,12 @@ async def _compose_sd_image_from_base( shutil.copy2(base_image_path, output_path) partitions = _mbr_partitions(output_path) + partitions = _shrink_data_partitions( + output_path, + partitions, + frameos_image=images_dir / "frameos.ext4", + assets_image=images_dir / "assets.vfat", + ) _replace_partition(output_path, partitions, 3, images_dir / "frameos.ext4") _replace_partition(output_path, partitions, 4, images_dir / "assets.vfat") await self._patch_boot_partition(output_path, partitions, boot_root, image=image) @@ -1655,6 +1668,12 @@ async def _compose_sd_image_from_precompiled_release( shutil.copy2(release_image_path, output_path) partitions = _mbr_partitions(output_path) + max_frameos_size = _partition_size_bytes(BUILDROOT_FRAMEOS_PARTITION_SIZE) + max_assets_size = _partition_size_bytes(BUILDROOT_ASSETS_PARTITION_SIZE) + if partitions[2]["size"] > max_frameos_size or partitions[3]["size"] > max_assets_size: + raise RuntimeError( + "Full precompiled Buildroot SD image uses larger data partitions than the current image layout" + ) await self._patch_boot_partition(output_path, partitions, boot_root, image=image) async def _patch_boot_partition( @@ -2146,6 +2165,41 @@ def normalize_path(value: str) -> str: files+=("$kernel") fi +partition_size_for_root() {{ + python3 - "$1" "$2" <<'PY' +import math +import os +import re +import sys + +HEADROOM_BYTES = {BUILDROOT_DATA_PARTITION_HEADROOM_BYTES} +HEADROOM_RATIO = {BUILDROOT_DATA_PARTITION_HEADROOM_RATIO} +MIB = 1024 * 1024 + +def parse_size(value): + match = re.fullmatch(r"\\s*([0-9]+)\\s*([KMG]?)\\s*", value, flags=re.IGNORECASE) + if not match: + raise SystemExit(f"Unsupported partition size {{value!r}}") + number = int(match.group(1)) + unit = match.group(2).upper() + return number * {{"": 1, "K": 1024, "M": MIB, "G": 1024 * MIB}}[unit] + +root = sys.argv[1] +minimum = parse_size(sys.argv[2]) +payload = 0 +for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + for name in dirnames + filenames: + try: + payload += os.lstat(os.path.join(dirpath, name)).st_size + except FileNotFoundError: + pass + +required = max(minimum, math.ceil(payload * HEADROOM_RATIO) + HEADROOM_BYTES) +print(f"{{math.ceil(required / MIB)}}M") +PY +}} + +frameos_partition_size="$(partition_size_for_root "${{BASE_DIR:?BASE_DIR is required}}/frameos-partition-root" "{BUILDROOT_FRAMEOS_PARTITION_SIZE}")" boot_files="$(printf '\\t\\t\\t"%s",\\n' "${{files[@]}}")" cat > "$genimage_cfg" < str: label = "FRAMEOS" }} srcpath = "${{BASE_DIR:?BASE_DIR is required}}/frameos-partition-root" - size = {BUILDROOT_FRAMEOS_PARTITION_SIZE} + size = $frameos_partition_size }} image assets.vfat {{ @@ -2283,7 +2337,7 @@ def normalize_path(value: str) -> str: return 0 fi echo "Moving $name data: $old_start -> $new_start ($sectors sectors)" - chunk_sectors=$((1024 * 1024 / sector_size)) + chunk_sectors=$((4 * 1024 * 1024 / sector_size)) if [ "$chunk_sectors" -lt 1 ]; then chunk_sectors=1 fi @@ -2632,6 +2686,93 @@ def _mbr_partitions(image_path: Path) -> list[dict[str, int]]: return partitions +def _partition_size_bytes(size: str) -> int: + match = re.fullmatch(r"\s*([0-9]+)\s*([KMG]?)\s*", size, flags=re.IGNORECASE) + if not match: + raise RuntimeError(f"Unsupported partition size {size!r}") + value = int(match.group(1)) + unit = match.group(2).upper() + multiplier = { + "": 1, + "K": 1024, + "M": 1024 * 1024, + "G": 1024 * 1024 * 1024, + }[unit] + return value * multiplier + + +def _align_up_bytes(value: int, alignment: int = 1024 * 1024) -> int: + return ((value + alignment - 1) // alignment) * alignment + + +def _directory_payload_size_bytes(root: Path) -> int: + total = 0 + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + for name in [*dirnames, *filenames]: + try: + total += (Path(dirpath) / name).lstat().st_size + except FileNotFoundError: + continue + return total + + +def _partition_size_for_root(root: Path, *, minimum_size: str) -> str: + minimum_bytes = _partition_size_bytes(minimum_size) + payload_bytes = _directory_payload_size_bytes(root) + required_bytes = max( + minimum_bytes, + math.ceil(payload_bytes * BUILDROOT_DATA_PARTITION_HEADROOM_RATIO) + BUILDROOT_DATA_PARTITION_HEADROOM_BYTES, + ) + return f"{_align_up_bytes(required_bytes) // (1024 * 1024)}M" + + +def _set_mbr_partition_geometry(image_path: Path, partition_number: int, *, start: int, size: int) -> None: + if partition_number < 1 or partition_number > 4: + raise RuntimeError(f"Invalid partition number {partition_number}") + if start % 512 != 0 or size % 512 != 0: + raise RuntimeError(f"Partition {partition_number} geometry must be sector-aligned") + with image_path.open("r+b") as image: + image.seek(446 + (partition_number - 1) * 16 + 8) + image.write((start // 512).to_bytes(4, "little")) + image.write((size // 512).to_bytes(4, "little")) + + +def _shrink_data_partitions( + image_path: Path, + partitions: list[dict[str, int]], + *, + frameos_image: Path, + assets_image: Path, +) -> list[dict[str, int]]: + if len(partitions) < 4: + raise RuntimeError("Cannot shrink data partitions; SD image has fewer than four partitions") + + frameos_size = frameos_image.stat().st_size + assets_size = assets_image.stat().st_size + frameos_partition = partitions[2] + assets_partition = partitions[3] + if frameos_size > frameos_partition["size"]: + raise RuntimeError( + f"{frameos_image.name} is larger than partition 3: {frameos_size} > {frameos_partition['size']}" + ) + if assets_size > assets_partition["size"]: + raise RuntimeError( + f"{assets_image.name} is larger than partition 4: {assets_size} > {assets_partition['size']}" + ) + + frameos_start = frameos_partition["start"] + assets_start = _align_up_bytes(frameos_start + frameos_size) + output_size = assets_start + assets_size + if output_size > image_path.stat().st_size: + raise RuntimeError(f"Shrunk data partition layout would exceed {image_path.name}") + + _set_mbr_partition_geometry(image_path, 3, start=frameos_start, size=frameos_size) + _set_mbr_partition_geometry(image_path, 4, start=assets_start, size=assets_size) + with image_path.open("r+b") as image: + image.truncate(output_size) + return _mbr_partitions(image_path) + + def _replace_partition(image_path: Path, partitions: list[dict[str, int]], partition_number: int, source_path: Path) -> None: if partition_number < 1 or partition_number > len(partitions): raise RuntimeError(f"Invalid partition number {partition_number}") diff --git a/backend/app/tasks/frame_deploy_helpers.py b/backend/app/tasks/frame_deploy_helpers.py index 3cc7dbbdf..e584a7294 100644 --- a/backend/app/tasks/frame_deploy_helpers.py +++ b/backend/app/tasks/frame_deploy_helpers.py @@ -15,8 +15,8 @@ icon = "🔷" QUICKJS_ARCHIVE_URL = "https://archive.frameos.net/source/vendor/quickjs-{version}.tar.xz" -DEFAULT_QUICKJS_VERSION = "2025-04-26" -DEFAULT_QUICKJS_SHA256 = "2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf" +DEFAULT_QUICKJS_VERSION = "2026-06-04" +DEFAULT_QUICKJS_SHA256 = "b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a" APT_PACKAGE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+.-]*$") RPIOS_SUDO_SECURITY_UPDATE_URL = "https://www.raspberrypi.com/news/a-security-update-for-raspberry-pi-os/" diff --git a/backend/app/tasks/tests/deploy_ssh_target/Dockerfile b/backend/app/tasks/tests/deploy_ssh_target/Dockerfile index 3403bf925..49642ad1c 100644 --- a/backend/app/tasks/tests/deploy_ssh_target/Dockerfile +++ b/backend/app/tasks/tests/deploy_ssh_target/Dockerfile @@ -31,13 +31,13 @@ RUN set -eux; \ touch /boot/config.txt; \ sed -ri 's/^#?PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config; \ sed -ri 's/^#?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config; \ - wget -q -O /tmp/quickjs.tar.xz https://bellard.org/quickjs/quickjs-2025-04-26.tar.xz; \ - echo "2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf /tmp/quickjs.tar.xz" | sha256sum -c -; \ + wget -q -O /tmp/quickjs.tar.xz https://bellard.org/quickjs/quickjs-2026-06-04.tar.xz; \ + echo "b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a /tmp/quickjs.tar.xz" | sha256sum -c -; \ tar -xf /tmp/quickjs.tar.xz -C /tmp; \ - cp -a /tmp/quickjs-2025-04-26 /srv/frameos/vendor/quickjs/quickjs-2025-04-26; \ - make -C /srv/frameos/vendor/quickjs/quickjs-2025-04-26 libquickjs.a; \ + cp -a /tmp/quickjs-2026-06-04 /srv/frameos/vendor/quickjs/quickjs-2026-06-04; \ + make -C /srv/frameos/vendor/quickjs/quickjs-2026-06-04 libquickjs.a; \ chown -R "${FRAMEOS_E2E_USER}:${FRAMEOS_E2E_USER}" /srv/frameos; \ - rm -rf /tmp/quickjs.tar.xz /tmp/quickjs-2025-04-26; \ + rm -rf /tmp/quickjs.tar.xz /tmp/quickjs-2026-06-04; \ rm -rf /var/lib/apt/lists/* RUN set -eux; \ diff --git a/backend/app/tasks/tests/test_buildroot_image.py b/backend/app/tasks/tests/test_buildroot_image.py index b18dafbc5..023a45707 100644 --- a/backend/app/tasks/tests/test_buildroot_image.py +++ b/backend/app/tasks/tests/test_buildroot_image.py @@ -228,7 +228,9 @@ def test_buildroot_partition_scripts_create_frameos_and_assets_partitions(tmp_pa assert "raspberrypi,model-zero-2-2" in post_build assert "image frameos.ext4" in post_image assert "image assets.vfat" in post_image - assert "size = 100M" in post_image + assert 'frameos_partition_size="$(partition_size_for_root "${BASE_DIR:?BASE_DIR is required}/frameos-partition-root" "30M")"' in post_image + assert "size = $frameos_partition_size" in post_image + assert "size = 30M" in post_image assert 'rootfs_image="${BINARIES_DIR:?BINARIES_DIR is required}/rootfs.ext4"' in post_image assert 'resize2fs -M "$rootfs_image"' in post_image assert "console=tty1" in post_image @@ -256,6 +258,7 @@ def test_buildroot_expand_sd_card_service_runs_before_local_mounts(): assert "small_card_threshold_sectors=$((4 * 1024 * 1024 * 1024 / sector_size))" in script assert "small_frameos_sectors=$((1 * 1024 * 1024 * 1024 / sector_size))" in script assert "large_frameos_sectors=$((2 * 1024 * 1024 * 1024 / sector_size))" in script + assert "chunk_sectors=$((4 * 1024 * 1024 / sector_size))" in script assert 'move_partition_data "FRAMEOS" "$p3_start" "$frameos_start" "$p3_size"' in script assert 'echo "New root start/size: $p2_start/$target_root_size sectors"' in script assert '$(partition_device "$disk" 2) : start= $p2_start, size= $target_root_size, type=83' in script @@ -444,8 +447,8 @@ def fail_replace_partition(*_args, **_kwargs): lambda _path: [ {"start": 512, "size": 32 * 1024 * 1024}, {"start": 33554944, "size": 768 * 1024 * 1024}, - {"start": 838861312, "size": 512 * 1024 * 1024}, - {"start": 1375732224, "size": 512 * 1024 * 1024}, + {"start": 838861312, "size": 30 * 1024 * 1024}, + {"start": 870318080, "size": 30 * 1024 * 1024}, ], ) monkeypatch.setattr("app.tasks.buildroot_image._replace_partition", fail_replace_partition) @@ -474,6 +477,93 @@ def fail_replace_partition(*_args, **_kwargs): assert 'mdel -i "$target"' in captured["patch_script"] +def _write_test_mbr(path: Path, partitions: list[tuple[int, int]]) -> None: + mbr = bytearray(512) + mbr[510:512] = b"\x55\xaa" + for index, (start, size) in enumerate(partitions): + entry = 446 + index * 16 + mbr[entry] = 0x80 if index == 0 else 0 + mbr[entry + 4] = 0x0C if index in (0, 3) else 0x83 + mbr[entry + 8 : entry + 12] = (start // 512).to_bytes(4, "little") + mbr[entry + 12 : entry + 16] = (size // 512).to_bytes(4, "little") + path.write_bytes(bytes(mbr)) + + +def test_shrink_data_partitions_rewrites_mbr_and_truncates_image(tmp_path): + image = tmp_path / "base.img" + frameos = tmp_path / "frameos.ext4" + assets = tmp_path / "assets.vfat" + partitions = [ + (512, 32 * 1024 * 1024), + (32 * 1024 * 1024 + 512, 160 * 1024 * 1024), + (192 * 1024 * 1024 + 512, 100 * 1024 * 1024), + (292 * 1024 * 1024 + 512, 100 * 1024 * 1024), + ] + _write_test_mbr(image, partitions) + with image.open("r+b") as image_file: + image_file.truncate(partitions[-1][0] + partitions[-1][1]) + frameos.write_bytes(b"\0" * (30 * 1024 * 1024)) + assets.write_bytes(b"\0" * (30 * 1024 * 1024)) + + shrunk = buildroot_image_module._shrink_data_partitions( + image, + buildroot_image_module._mbr_partitions(image), + frameos_image=frameos, + assets_image=assets, + ) + + assert shrunk[2] == {"start": partitions[2][0], "size": 30 * 1024 * 1024} + expected_assets_start = buildroot_image_module._align_up_bytes(partitions[2][0] + 30 * 1024 * 1024) + assert shrunk[3] == { + "start": expected_assets_start, + "size": 30 * 1024 * 1024, + } + assert image.stat().st_size == shrunk[3]["start"] + shrunk[3]["size"] + + +def test_partition_size_for_root_grows_with_payload_size(tmp_path): + root = tmp_path / "root" + root.mkdir() + payload = root / "payload.bin" + with payload.open("wb") as handle: + handle.truncate(40 * 1024 * 1024) + + assert buildroot_image_module._partition_size_for_root(root, minimum_size="30M") == "58M" + + +@pytest.mark.asyncio +async def test_precompiled_sd_image_rejects_larger_data_partitions(tmp_path, monkeypatch): + release_image = tmp_path / "release.img" + output_image = tmp_path / "output.img" + boot_overlay = tmp_path / "tmp" / "overlay" / "boot" + boot_overlay.mkdir(parents=True) + (boot_overlay / "frameos-setup.json").write_text("{}", encoding="utf-8") + partitions = [ + (512, 32 * 1024 * 1024), + (32 * 1024 * 1024 + 512, 160 * 1024 * 1024), + (192 * 1024 * 1024 + 512, 100 * 1024 * 1024), + (292 * 1024 * 1024 + 512, 100 * 1024 * 1024), + ] + _write_test_mbr(release_image, partitions) + with release_image.open("r+b") as image_file: + image_file.truncate(partitions[-1][0] + partitions[-1][1]) + + builder = BuildrootImageBuilder(db=object(), redis=None, frame=SimpleNamespace(id=1)) + + async def fake_log(*args, **kwargs): + return None + + monkeypatch.setattr(builder, "_log", fake_log) + + with pytest.raises(RuntimeError, match="larger data partitions"): + await builder._compose_sd_image_from_precompiled_release( + temp_dir=tmp_path / "tmp", + release_image_path=release_image, + output_path=output_image, + image=None, + ) + + @pytest.mark.asyncio async def test_buildroot_docker_run_raises_nofile_limit(tmp_path, monkeypatch): temp_dir = tmp_path / "tmp" @@ -556,7 +646,14 @@ async def fake_log(*args, **kwargs): def fake_replace_partition(_image_path, _partitions, partition_number, partition_image): replaced.append((partition_number, partition_image.name)) + def fake_shrink_data_partitions(_image_path, partitions, **_kwargs): + return partitions + monkeypatch.setattr("app.tasks.buildroot_image.exec_local_command", fake_exec_local_command) + monkeypatch.setattr( + "app.tasks.buildroot_image._partition_size_for_root", + lambda _root, *, minimum_size: "42M", + ) monkeypatch.setattr( "app.tasks.buildroot_image._mbr_partitions", lambda _path: [ @@ -566,6 +663,7 @@ def fake_replace_partition(_image_path, _partitions, partition_number, partition {"start": 1375732224, "size": 512 * 1024 * 1024}, ], ) + monkeypatch.setattr("app.tasks.buildroot_image._shrink_data_partitions", fake_shrink_data_partitions) monkeypatch.setattr("app.tasks.buildroot_image._replace_partition", fake_replace_partition) monkeypatch.setattr(builder, "_log", fake_log) @@ -582,6 +680,7 @@ def fake_replace_partition(_image_path, _partitions, partition_number, partition assert f'srcpath = "{compose_root.group(1)}/assets"' in captured["config"] assert f'srcpath = "{compose_root.group(1)}/rootfs"' not in captured["config"] assert f'srcpath = "{compose_root.group(1)}/boot"' not in captured["config"] + assert "size = 42M" in captured["config"] assert 'label = "BOOT"' not in captured["config"] assert str(temp_dir) not in captured["config"] assert "bash /work/compose-partitions.sh" in captured["compose_command"] @@ -635,6 +734,9 @@ async def fake_log(*args, **kwargs): def fake_replace_partition(_image_path, _partitions, partition_number, partition_image): replaced.append((partition_number, partition_image.name)) + def fake_shrink_data_partitions(_image_path, partitions, **_kwargs): + return partitions + monkeypatch.setattr("app.tasks.buildroot_image.exec_local_command", fake_exec_local_command) monkeypatch.setattr( "app.tasks.buildroot_image._mbr_partitions", @@ -645,6 +747,7 @@ def fake_replace_partition(_image_path, _partitions, partition_number, partition {"start": 1375732224, "size": 512 * 1024 * 1024}, ], ) + monkeypatch.setattr("app.tasks.buildroot_image._shrink_data_partitions", fake_shrink_data_partitions) monkeypatch.setattr("app.tasks.buildroot_image._replace_partition", fake_replace_partition) monkeypatch.setattr(builder, "_log", fake_log) diff --git a/backend/app/tasks/tests/test_cross_compile.py b/backend/app/tasks/tests/test_cross_compile.py index 2e94dca54..c0f77e3e1 100644 --- a/backend/app/tasks/tests/test_cross_compile.py +++ b/backend/app/tasks/tests/test_cross_compile.py @@ -103,7 +103,7 @@ def test_cross_compiler_canonicalizes_ubuntu_codename(tmp_path, monkeypatch: pyt @pytest.mark.parametrize( ("component", "version"), [ - ("quickjs", "2025-04-26"), + ("quickjs", "2026-06-04"), ], ) async def test_ensure_prebuilt_component_refreshes_incomplete_cached_artifacts( @@ -138,8 +138,8 @@ async def fake_download(_url: str, extract_dir, _expected_md5: str | None) -> No @pytest.mark.asyncio async def test_ensure_prebuilt_component_rejects_invalid_download(tmp_path, monkeypatch: pytest.MonkeyPatch): - compiler = make_cross_compiler(tmp_path, monkeypatch, component="quickjs", version="2025-04-26") - dest_dir = compiler.prebuilt_dir / "quickjs-2025-04-26" + compiler = make_cross_compiler(tmp_path, monkeypatch, component="quickjs", version="2026-06-04") + dest_dir = compiler.prebuilt_dir / "quickjs-2026-06-04" async def fake_download(_url: str, extract_dir, _expected_md5: str | None) -> None: write_component_payload("quickjs", extract_dir, valid=False) diff --git a/backend/app/tasks/tests/test_frame_deploy_workflow.py b/backend/app/tasks/tests/test_frame_deploy_workflow.py index cbbbdeaf2..3b6bf2952 100644 --- a/backend/app/tasks/tests/test_frame_deploy_workflow.py +++ b/backend/app/tasks/tests/test_frame_deploy_workflow.py @@ -485,7 +485,7 @@ async def test_full_plan_reports_installed_state_and_remote_build_dependencies(m ) deployer = FakeDeployer( installed_packages={"build-essential", "ntp", "python3-pip"}, - existing_paths={"/srv/frameos/vendor/quickjs/quickjs-2025-04-26"}, + existing_paths={"/srv/frameos/vendor/quickjs/quickjs-2026-06-04"}, ) deployer.success_commands.update( { @@ -1664,7 +1664,7 @@ async def fake_upload_file(_db, _redis, _frame, remote_path, _data): ), "build12345678", "/srv/frameos/releases/release_build12345678/frameos", - "quickjs-2025-04-26", + "quickjs-2026-06-04", ) assert uploaded == ["/srv/frameos/build/build_build12345678.tar.gz"] diff --git a/backend/app/tasks/tests/test_install_prebuilt_quickjs.py b/backend/app/tasks/tests/test_install_prebuilt_quickjs.py index abb56079b..f917ca51e 100644 --- a/backend/app/tasks/tests/test_install_prebuilt_quickjs.py +++ b/backend/app/tasks/tests/test_install_prebuilt_quickjs.py @@ -19,7 +19,7 @@ def load_installer(): def write_quickjs_archive(archive_path: Path, tmp_path: Path) -> str: - payload = tmp_path / "payload" / "quickjs-2025-04-26" + payload = tmp_path / "payload" / "quickjs-2026-06-04" include = payload / "include" / "quickjs" lib = payload / "lib" include.mkdir(parents=True) @@ -44,11 +44,11 @@ def write_manifest(manifest_path: Path, archive_md5: str) -> None: "entries": [ { "target": "debian-bookworm-amd64", - "versions": {"quickjs": "2025-04-26"}, + "versions": {"quickjs": "2026-06-04"}, "component_keys": { "quickjs": ( "prebuilt-deps/debian-bookworm-amd64/" - "quickjs-2025-04-26.tar.gz" + "quickjs-2026-06-04.tar.gz" ) }, "component_md5sums": {"quickjs": archive_md5}, @@ -80,7 +80,7 @@ def test_installs_prebuilt_quickjs_archive_shape(tmp_path): archive_root / "prebuilt-deps" / "debian-bookworm-amd64" - / "quickjs-2025-04-26.tar.gz" + / "quickjs-2026-06-04.tar.gz" ) archive_md5 = write_quickjs_archive(archive_path, tmp_path) manifest_path = tmp_path / "manifest.json" @@ -107,4 +107,4 @@ def test_installs_prebuilt_quickjs_archive_shape(tmp_path): assert (dest / "include" / "quickjs" / "cutils.h").exists() assert (dest / "libquickjs.a").read_bytes() == b"!\n" assert (dest / "lib" / "libquickjs.a").exists() - assert (dest / "VERSION").read_text() == "2025-04-26\n" + assert (dest / "VERSION").read_text() == "2026-06-04\n" diff --git a/backend/app/utils/js_apps.py b/backend/app/utils/js_apps.py index 0fe6aa6cd..af899f739 100644 --- a/backend/app/utils/js_apps.py +++ b/backend/app/utils/js_apps.py @@ -5,9 +5,11 @@ import shutil import subprocess import tempfile +import threading from pathlib import Path JS_APP_SOURCE_FILES = ("app.ts", "app.js", "app.tsx", "app.jsx") +_NATIVE_TRANSPILER_LOCK = threading.Lock() def find_js_app_source_key(sources: dict | None) -> str | None: @@ -27,50 +29,6 @@ def find_js_app_source_filename(app_dir: str) -> str | None: return None -def _node_sucrase_script() -> str: - return """ -import fs from 'node:fs'; - -const filename = process.argv[1]; -const source = fs.readFileSync(process.argv[2], 'utf8'); -const vendorPath = process.argv[3]; - -async function transpile() { - if (vendorPath && fs.existsSync(vendorPath)) { - globalThis.eval(fs.readFileSync(vendorPath, 'utf8')); - return globalThis.__frameosTranspile(source, { filePath: filename }); - } - - const { transform } = await import('sucrase'); - return transform(source, { - filePath: filename, - transforms: ['typescript', 'jsx'], - jsxRuntime: 'classic', - jsxPragma: '__frameosJsx', - jsxFragmentPragma: '__frameosFragment', - production: true, - }).code; -} - -try { - await transpile(); - process.stdout.write(JSON.stringify({ ok: true })); -} catch (error) { - process.stderr.write(JSON.stringify({ - ok: false, - errors: [{ - text: String(error?.message || error || 'Unknown JavaScript error'), - location: { - line: Number(error?.loc?.line || 1), - column: Number(error?.loc?.column || 1), - }, - }], - })); - process.exit(1); -} -""" - - def _json_payload_from_process(proc: subprocess.CompletedProcess[str], fallback: str) -> tuple[bool, dict]: output = proc.stdout.strip() or proc.stderr.strip() if not output: @@ -82,64 +40,260 @@ def _json_payload_from_process(proc: subprocess.CompletedProcess[str], fallback: return proc.returncode == 0, payload -def _quickjs_binary(repo_root: Path) -> str | None: - candidates = [ - repo_root / "frameos" / "quickjs" / "qjs", - Path("/app/frameos/quickjs/qjs"), +def _native_transpiler_sources(frameos_root: Path) -> list[Path]: + return [ + frameos_root / "tools" / "native_js_transpile.nim", + *(frameos_root / "src" / "frameos" / "js_runtime").glob("*.nim"), ] - for candidate in candidates: - if candidate.exists() and os.access(candidate, os.X_OK): - return str(candidate) - return shutil.which("qjs") -def _run_quickjs_sucrase(filename: str, source_path: str, repo_root: Path, vendor_path: Path) -> tuple[bool, dict] | None: - qjs = _quickjs_binary(repo_root) - if not qjs or not vendor_path.exists(): - return None +def _native_transpiler_bin(frameos_root: Path) -> Path: + suffix = ".exe" if os.name == "nt" else "" + return frameos_root / "build" / f"native_js_transpile{suffix}" - script_path = Path(__file__).resolve().with_name("js_validate_quickjs.js") - proc = subprocess.run( - [qjs, "--std", str(script_path), filename, source_path, str(vendor_path)], - cwd=repo_root, - capture_output=True, - text=True, - check=False, - ) - ok, payload = _json_payload_from_process( - proc, - '{"ok": false, "errors": [{"text": "quickjs sucrase validation failed"}]}', + +def _native_transpiler_is_current(binary: Path, frameos_root: Path) -> bool: + if not binary.exists(): + return False + binary_mtime = binary.stat().st_mtime + return all( + path.exists() and path.stat().st_mtime <= binary_mtime + for path in _native_transpiler_sources(frameos_root) ) - if ok or payload.get("errors"): - return ok, payload - return None -def _run_node_sucrase(filename: str, source_path: str, repo_root: Path, vendor_path: Path) -> tuple[bool, dict]: +def _ensure_native_transpiler(repo_root: Path) -> tuple[Path | None, dict | None]: + override = os.environ.get("FRAMEOS_NATIVE_JS_TRANSPILE") + if override: + binary = Path(override) + if binary.exists(): + return binary, None + return None, { + "ok": False, + "errors": [ + { + "text": f"FRAMEOS_NATIVE_JS_TRANSPILE does not exist: {override}", + "location": {"line": 1, "column": 1}, + } + ], + } + + frameos_root = repo_root / "frameos" + binary = _native_transpiler_bin(frameos_root) + if _native_transpiler_is_current(binary, frameos_root): + return binary, None + + nim = shutil.which("nim") + if not nim: + return None, { + "ok": False, + "errors": [ + { + "text": "JavaScript validation requires Nim to build the FrameOS native transpiler", + "location": {"line": 1, "column": 1}, + } + ], + } + + with _NATIVE_TRANSPILER_LOCK: + if _native_transpiler_is_current(binary, frameos_root): + return binary, None + binary.parent.mkdir(parents=True, exist_ok=True) + proc = subprocess.run( + [ + nim, + "c", + "--nimCache:build/nimcache/native_js_transpile", + f"--out:build/{binary.name}", + "tools/native_js_transpile.nim", + ], + cwd=frameos_root, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + output = (proc.stderr or proc.stdout).strip() or "Failed to build FrameOS native JavaScript transpiler" + return None, { + "ok": False, + "errors": [{"text": output, "location": {"line": 1, "column": 1}}], + } + return binary, None + + +def _source_map_generated_position(source_map: dict | None, line: int, column: int) -> tuple[int, int]: + if not source_map: + return line, column + + mapped_line = 0 + generated_to_source_line = source_map.get("generatedToSourceLine") or [] + if 0 < line < len(generated_to_source_line): + try: + mapped_line = int(generated_to_source_line[line]) + except (TypeError, ValueError): + mapped_line = 0 + + mapped_column = max(1, column) + best_segment: dict | None = None + for segment in source_map.get("segments") or []: + try: + segment_line = int(segment.get("generatedLine", 0)) + segment_column = int(segment.get("generatedColumn", 0)) + except (TypeError, ValueError): + continue + if segment_line == line and segment_column <= column: + if best_segment is None or segment_column > int(best_segment.get("generatedColumn", 0)): + best_segment = segment + + if best_segment is not None: + try: + mapped_line = int(best_segment.get("sourceLine", mapped_line)) + source_column = int(best_segment.get("sourceColumn", 1)) + generated_column = int(best_segment.get("generatedColumn", 1)) + except (TypeError, ValueError): + return mapped_line or line, mapped_column + mapped_column = max(1, source_column + (column - generated_column)) + + return mapped_line or line, mapped_column + + +def _path_line_prefixes(path: str) -> tuple[str, ...]: + paths = [path, os.path.realpath(path)] + if path.startswith("/var/"): + paths.append("/private" + path) + return tuple(dict.fromkeys(path + ":" for path in paths)) + + +def _node_check_error_payload( + proc: subprocess.CompletedProcess[str], + source: str, + generated_path: str, + source_map: dict | None = None, +) -> dict: + output = (proc.stderr or proc.stdout).strip() + line = 1 + column = 1 + found_column = False + text = "Unknown JavaScript error" + source_lines = source.splitlines() or [""] + line_prefixes = _path_line_prefixes(generated_path) + + for raw_line in output.splitlines(): + if raw_line.startswith(line_prefixes): + try: + line = int(raw_line.rsplit(":", 1)[1]) + except ValueError: + line = 1 + elif raw_line.startswith("SyntaxError:"): + text = raw_line.removeprefix("SyntaxError:").strip() or raw_line + + output_lines = output.splitlines() + for index, raw_line in enumerate(output_lines): + if raw_line.startswith(line_prefixes) and index + 2 < len(output_lines): + caret_line = output_lines[index + 2] + caret_index = caret_line.find("^") + if caret_index >= 0: + column = caret_index + 1 + found_column = True + break + + source_line, source_column = _source_map_generated_position(source_map, line, column) + source_line = min(max(1, source_line), len(source_lines)) + if not found_column: + source_column = len(source_lines[source_line - 1]) + 1 + + return { + "ok": False, + "errors": [ + { + "text": text, + "location": {"line": source_line, "column": max(1, source_column)}, + } + ], + } + + +def _run_node_syntax_check(code: str, source: str, source_map: dict | None = None) -> tuple[bool, dict]: node = shutil.which("node") if not node: - return False, {"ok": False, "errors": [{"text": "JavaScript validation requires QuickJS or Node", "location": {"line": 1, "column": 0}}]} + return False, { + "ok": False, + "errors": [ + { + "text": "JavaScript validation requires Node", + "location": {"line": 1, "column": 1}, + } + ], + } + + tmp_path = "" + try: + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as tmp: + tmp.write(code) + tmp_path = str(tmp.name) + proc = subprocess.run([node, "--check", tmp_path], capture_output=True, text=True, check=False) + if proc.returncode == 0: + return True, {"ok": True} + return False, _node_check_error_payload(proc, source, tmp_path, source_map) + finally: + if tmp_path and os.path.exists(tmp_path): + os.remove(tmp_path) + + +def _run_native_frameos_transpiler(filename: str, source_path: str, source: str, repo_root: Path) -> tuple[bool, dict]: + binary, error_payload = _ensure_native_transpiler(repo_root) + if error_payload is not None or binary is None: + return False, error_payload or { + "ok": False, + "errors": [ + { + "text": "FrameOS native JavaScript transpiler is unavailable", + "location": {"line": 1, "column": 1}, + } + ], + } proc = subprocess.run( - [node, "--input-type=module", "-e", _node_sucrase_script(), filename, source_path, str(vendor_path)], - cwd=repo_root, + [str(binary), "module-json", source_path], + cwd=repo_root / "frameos", capture_output=True, text=True, check=False, ) - return _json_payload_from_process( + ok, payload = _json_payload_from_process( proc, - '{"ok": false, "errors": [{"text": "sucrase failed"}]}', + json.dumps( + { + "ok": False, + "errors": [ + { + "text": f"Failed to transform {filename}", + "location": {"line": 1, "column": 1}, + } + ], + } + ), ) + if not ok or not payload.get("ok", ok): + return False, payload + + code = payload.get("code") + if not isinstance(code, str): + return False, { + "ok": False, + "errors": [ + { + "text": f"Failed to transform {filename}", + "location": {"line": 1, "column": 1}, + } + ], + } + return _run_node_syntax_check(code, source, payload.get("sourceMap")) -def _run_sucrase(filename: str, source_path: str) -> tuple[bool, dict]: +def _run_frameos_js_validation(filename: str, source_path: str, source: str) -> tuple[bool, dict]: repo_root = Path(__file__).resolve().parents[3] - vendor_path = repo_root / "frameos" / "assets" / "compiled" / "vendor" / "sucrase.js" - quickjs_result = _run_quickjs_sucrase(filename, source_path, repo_root, vendor_path) - if quickjs_result is not None: - return quickjs_result - return _run_node_sucrase(filename, source_path, repo_root, vendor_path) + return _run_native_frameos_transpiler(filename, source_path, source, repo_root) def validate_js_source(filename: str, source: str) -> list[dict]: @@ -149,7 +303,7 @@ def validate_js_source(filename: str, source: str) -> list[dict]: tmp.write(source) tmp_path = str(tmp.name) - ok, payload = _run_sucrase(filename, tmp_path) + ok, payload = _run_frameos_js_validation(filename, tmp_path, source) finally: if tmp_path and os.path.exists(tmp_path): os.remove(tmp_path) diff --git a/backend/app/utils/js_validate_quickjs.js b/backend/app/utils/js_validate_quickjs.js deleted file mode 100644 index 2f6a86bca..000000000 --- a/backend/app/utils/js_validate_quickjs.js +++ /dev/null @@ -1,41 +0,0 @@ -const args = scriptArgs.length >= 4 ? scriptArgs.slice(1) : scriptArgs -const filename = args[0] -const sourcePath = args[1] -const vendorPath = args[2] - -function writePayload(payload, exitCode) { - std.out.puts(JSON.stringify(payload)) - std.exit(exitCode) -} - -try { - const source = std.loadFile(sourcePath) - const vendor = std.loadFile(vendorPath) - - if (source === null) { - throw new Error(`Unable to read JavaScript source: ${sourcePath}`) - } - if (vendor === null) { - throw new Error(`Unable to read Sucrase vendor bundle: ${vendorPath}`) - } - - globalThis.eval(vendor) - globalThis.__frameosTranspile(source, { filePath: filename }) - writePayload({ ok: true }, 0) -} catch (error) { - writePayload( - { - ok: false, - errors: [ - { - text: String((error && error.message) || error || 'Unknown JavaScript error'), - location: { - line: Number((error && error.loc && error.loc.line) || 1), - column: Number((error && error.loc && error.loc.column) || 1), - }, - }, - ], - }, - 1 - ) -} diff --git a/backend/app/utils/tests/test_js_apps.py b/backend/app/utils/tests/test_js_apps.py index f3ed8224a..68c4d6961 100644 --- a/backend/app/utils/tests/test_js_apps.py +++ b/backend/app/utils/tests/test_js_apps.py @@ -11,10 +11,27 @@ def test_validate_js_source_accepts_typescript_jsx(): ) -def test_validate_js_source_reports_sucrase_location(): +def test_validate_js_source_reports_native_transform_location(): errors = validate_js_source("app.ts", "export function get(app: any) { return ") assert errors assert errors[0]["line"] == 1 assert errors[0]["column"] > 0 - assert "Unexpected token" in errors[0]["error"] + assert "Unexpected" in errors[0]["error"] + + +def test_validate_js_source_reports_multiline_node_check_location(): + source = """export function run(app: FrameOSApp, context: FrameOSContext): void { + const stateKey = app.config.stateKey || 'jsLogicResult' + + app.log('JS logic app ran', { event: context.eve +nt, stateKey }) +} +""" + + errors = validate_js_source("app.ts", source) + + assert errors + assert errors[0]["line"] == 5 + assert errors[0]["column"] == 1 + assert "Unexpected identifier 'nt'" in errors[0]["error"] diff --git a/backend/app/utils/tests/test_versions.py b/backend/app/utils/tests/test_versions.py new file mode 100644 index 000000000..6c9ed7426 --- /dev/null +++ b/backend/app/utils/tests/test_versions.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from app.utils import versions + + +def test_get_versions_reflects_file_changes(monkeypatch, tmp_path: Path): + versions_path = tmp_path / "versions.json" + monkeypatch.setattr(versions, "VERSIONS_PATH", versions_path) + + versions_path.write_text('{"frameos":"2026.6.7+old","agent":"2026.6.7+old"}\n', encoding="utf-8") + assert versions.current_frameos_version() == "2026.6.7" + assert versions.current_agent_version() == "2026.6.7" + + versions_path.write_text('{"frameos":"2026.6.8+new","agent":"2026.6.8+new"}\n', encoding="utf-8") + assert versions.current_frameos_version() == "2026.6.8" + assert versions.current_agent_version() == "2026.6.8" diff --git a/backend/app/utils/versions.py b/backend/app/utils/versions.py index efd40e2b8..e3aedb146 100644 --- a/backend/app/utils/versions.py +++ b/backend/app/utils/versions.py @@ -1,5 +1,4 @@ import json -from functools import lru_cache from pathlib import Path from typing import Any @@ -7,7 +6,6 @@ VERSIONS_PATH = Path(__file__).resolve().parents[3] / "versions.json" -@lru_cache(maxsize=1) def get_versions() -> dict[str, Any]: if not VERSIONS_PATH.exists(): return {} diff --git a/frameos/assets/compiled/vendor/sucrase.js b/frameos/assets/compiled/vendor/sucrase.js deleted file mode 100644 index 8435d283d..000000000 --- a/frameos/assets/compiled/vendor/sucrase.js +++ /dev/null @@ -1,135 +0,0 @@ -"use strict";var __frameosSucraseBundle=(()=>{var na=Object.create;var po=Object.defineProperty;var sa=Object.getOwnPropertyDescriptor;var ra=Object.getOwnPropertyNames;var oa=Object.getPrototypeOf,ia=Object.prototype.hasOwnProperty;var kt=(e,n)=>()=>(n||e((n={exports:{}}).exports,n),n.exports);var aa=(e,n,s,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let i of ra(n))!ia.call(e,i)&&i!==s&&po(e,i,{get:()=>n[i],enumerable:!(o=sa(n,i))||o.enumerable});return e};var en=(e,n,s)=>(s=e!=null?na(oa(e)):{},aa(n||!e||!e.__esModule?po(s,"default",{value:e,enumerable:!0}):s,e));var No=kt((fr,hr)=>{(function(e,n){typeof fr=="object"&&typeof hr<"u"?hr.exports=n():typeof define=="function"&&define.amd?define(n):(e=typeof globalThis<"u"?globalThis:e||self,e.resolveURI=n())})(fr,function(){"use strict";let e=/^[\w+.-]+:\/\//,n=/^([\w+.-]+:)\/\/([^@/#?]*@)?([^:/#?]*)(:\d+)?(\/[^#?]*)?(\?[^#]*)?(#.*)?/,s=/^file:(?:\/\/((?![a-z]:)[^/#?]*)?)?(\/?[^#?]*)(\?[^#]*)?(#.*)?/i;function o(b){return e.test(b)}function i(b){return b.startsWith("//")}function c(b){return b.startsWith("/")}function u(b){return b.startsWith("file:")}function d(b){return/^[.?#]/.test(b)}function x(b){let R=n.exec(b);return _(R[1],R[2]||"",R[3],R[4]||"",R[5]||"/",R[6]||"",R[7]||"")}function g(b){let R=s.exec(b),L=R[2];return _("file:","",R[1]||"","",c(L)?L:"/"+L,R[3]||"",R[4]||"")}function _(b,R,L,z,V,W,te){return{scheme:b,user:R,host:L,port:z,path:V,query:W,hash:te,type:7}}function w(b){if(i(b)){let L=x("http:"+b);return L.scheme="",L.type=6,L}if(c(b)){let L=x("http://foo.com"+b);return L.scheme="",L.host="",L.type=5,L}if(u(b))return g(b);if(o(b))return x(b);let R=x("http://foo.com/"+b);return R.scheme="",R.host="",R.type=b?b.startsWith("?")?3:b.startsWith("#")?2:4:1,R}function S(b){if(b.endsWith("/.."))return b;let R=b.lastIndexOf("/");return b.slice(0,R+1)}function v(b,R){j(R,R.type),b.path==="/"?b.path=R.path:b.path=S(R.path)+b.path}function j(b,R){let L=R<=4,z=b.path.split("/"),V=1,W=0,te=!1;for(let oe=1;oez&&(z=te)}j(L,z);let V=L.query+L.hash;switch(z){case 2:case 3:return V;case 4:{let W=L.path.slice(1);return W?d(R||b)&&!d(W)?"./"+W+V:W+V:V||"."}case 5:return L.path+V;default:return L.scheme+"//"+L.user+L.host+L.port+L.path+V}}return U})});var fn=kt(Te=>{"use strict";var Qa=Te&&Te.__extends||function(){var e=function(n,s){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(o,i){o.__proto__=i}||function(o,i){for(var c in i)i.hasOwnProperty(c)&&(o[c]=i[c])},e(n,s)};return function(n,s){e(n,s);function o(){this.constructor=n}n.prototype=s===null?Object.create(s):(o.prototype=s.prototype,new o)}}();Object.defineProperty(Te,"__esModule",{value:!0});Te.DetailContext=Te.NoopContext=Te.VError=void 0;var Mo=function(e){Qa(n,e);function n(s,o){var i=e.call(this,o)||this;return i.path=s,Object.setPrototypeOf(i,n.prototype),i}return n}(Error);Te.VError=Mo;var Za=function(){function e(){}return e.prototype.fail=function(n,s,o){return!1},e.prototype.unionResolver=function(){return this},e.prototype.createContext=function(){return this},e.prototype.resolveUnion=function(n){},e}();Te.NoopContext=Za;var Bo=function(){function e(){this._propNames=[""],this._messages=[null],this._score=0}return e.prototype.fail=function(n,s,o){return this._propNames.push(n),this._messages.push(s),this._score+=o,!1},e.prototype.unionResolver=function(){return new ec},e.prototype.resolveUnion=function(n){for(var s,o,i=n,c=null,u=0,d=i.contexts;u=c._score)&&(c=x)}c&&c._score>0&&((s=this._propNames).push.apply(s,c._propNames),(o=this._messages).push.apply(o,c._messages))},e.prototype.getError=function(n){for(var s=[],o=this._propNames.length-1;o>=0;o--){var i=this._propNames[o];n+=typeof i=="number"?"["+i+"]":i?"."+i:"";var c=this._messages[o];c&&s.push(n+" "+c)}return new Mo(n,s.join("; "))},e.prototype.getErrorDetail=function(n){for(var s=[],o=this._propNames.length-1;o>=0;o--){var i=this._propNames[o];n+=typeof i=="number"?"["+i+"]":i?"."+i:"";var c=this._messages[o];c&&s.push({path:n,message:c})}for(var u=null,o=s.length-1;o>=0;o--)u&&(s[o].nested=[u]),u=s[o];return u},e}();Te.DetailContext=Bo;var ec=function(){function e(){this.contexts=[]}return e.prototype.createContext=function(){var n=new Bo;return this.contexts.push(n),n},e}()});var wr=kt(T=>{"use strict";var ue=T&&T.__extends||function(){var e=function(n,s){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(o,i){o.__proto__=i}||function(o,i){for(var c in i)i.hasOwnProperty(c)&&(o[c]=i[c])},e(n,s)};return function(n,s){e(n,s);function o(){this.constructor=n}n.prototype=s===null?Object.create(s):(o.prototype=s.prototype,new o)}}();Object.defineProperty(T,"__esModule",{value:!0});T.basicTypes=T.BasicType=T.TParamList=T.TParam=T.param=T.TFunc=T.func=T.TProp=T.TOptional=T.opt=T.TIface=T.iface=T.TEnumLiteral=T.enumlit=T.TEnumType=T.enumtype=T.TIntersection=T.intersection=T.TUnion=T.union=T.TTuple=T.tuple=T.TArray=T.array=T.TLiteral=T.lit=T.TName=T.name=T.TType=void 0;var Uo=fn(),ae=function(){function e(){}return e}();T.TType=ae;function Re(e){return typeof e=="string"?Ho(e):e}function _r(e,n){var s=e[n];if(!s)throw new Error("Unknown type "+n);return s}function Ho(e){return new yr(e)}T.name=Ho;var yr=function(e){ue(n,e);function n(s){var o=e.call(this)||this;return o.name=s,o._failMsg="is not a "+s,o}return n.prototype.getChecker=function(s,o,i){var c=this,u=_r(s,this.name),d=u.getChecker(s,o,i);return u instanceof ne||u instanceof n?d:function(x,g){return d(x,g)?!0:g.fail(null,c._failMsg,0)}},n}(ae);T.TName=yr;function tc(e){return new Ir(e)}T.lit=tc;var Ir=function(e){ue(n,e);function n(s){var o=e.call(this)||this;return o.value=s,o.name=JSON.stringify(s),o._failMsg="is not "+o.name,o}return n.prototype.getChecker=function(s,o){var i=this;return function(c,u){return c===i.value?!0:u.fail(null,i._failMsg,-1)}},n}(ae);T.TLiteral=Ir;function nc(e){return new Vo(Re(e))}T.array=nc;var Vo=function(e){ue(n,e);function n(s){var o=e.call(this)||this;return o.ttype=s,o}return n.prototype.getChecker=function(s,o){var i=this.ttype.getChecker(s,o);return function(c,u){if(!Array.isArray(c))return u.fail(null,"is not an array",0);for(var d=0;d0&&i.push(c+" more"),o._failMsg="is none of "+i.join(", ")):o._failMsg="is none of "+c+" types",o}return n.prototype.getChecker=function(s,o){var i=this,c=this.ttypes.map(function(u){return u.getChecker(s,o)});return function(u,d){for(var x=d.unionResolver(),g=0;g{"use strict";var kc=F&&F.__spreadArrays||function(){for(var e=0,n=0,s=arguments.length;n{"use strict";Zt.__esModule=!0;Zt.LinesAndColumns=void 0;var Mn=` -`,Oi="\r",Fi=function(){function e(n){this.string=n;for(var s=[0],o=0;othis.string.length)return null;for(var s=0,o=this.offsets;o[s+1]<=n;)s++;var i=n-o[s];return{line:s,column:i}},e.prototype.indexForLocation=function(n){var s=n.line,o=n.column;return s<0||s>=this.offsets.length||o<0||o>this.lengthOfLine(s)?null:this.offsets[s]+o},e.prototype.lengthOfLine=function(n){var s=this.offsets[n],o=n===this.offsets.length-1?this.string.length:this.offsets[n+1];return o-s},e}();Zt.LinesAndColumns=Fi;Zt.default=Fi});var l;(function(e){e[e.NONE=0]="NONE";let s=1;e[e._abstract=s]="_abstract";let o=s+1;e[e._accessor=o]="_accessor";let i=o+1;e[e._as=i]="_as";let c=i+1;e[e._assert=c]="_assert";let u=c+1;e[e._asserts=u]="_asserts";let d=u+1;e[e._async=d]="_async";let x=d+1;e[e._await=x]="_await";let g=x+1;e[e._checks=g]="_checks";let _=g+1;e[e._constructor=_]="_constructor";let w=_+1;e[e._declare=w]="_declare";let S=w+1;e[e._enum=S]="_enum";let v=S+1;e[e._exports=v]="_exports";let j=v+1;e[e._from=j]="_from";let U=j+1;e[e._get=U]="_get";let b=U+1;e[e._global=b]="_global";let R=b+1;e[e._implements=R]="_implements";let L=R+1;e[e._infer=L]="_infer";let z=L+1;e[e._interface=z]="_interface";let V=z+1;e[e._is=V]="_is";let W=V+1;e[e._keyof=W]="_keyof";let te=W+1;e[e._mixins=te]="_mixins";let fe=te+1;e[e._module=fe]="_module";let oe=fe+1;e[e._namespace=oe]="_namespace";let he=oe+1;e[e._of=he]="_of";let Ue=he+1;e[e._opaque=Ue]="_opaque";let He=Ue+1;e[e._out=He]="_out";let Ve=He+1;e[e._override=Ve]="_override";let We=Ve+1;e[e._private=We]="_private";let Xe=We+1;e[e._protected=Xe]="_protected";let Ge=Xe+1;e[e._proto=Ge]="_proto";let Je=Ge+1;e[e._public=Je]="_public";let ze=Je+1;e[e._readonly=ze]="_readonly";let Ke=ze+1;e[e._require=Ke]="_require";let Ye=Ke+1;e[e._satisfies=Ye]="_satisfies";let Qe=Ye+1;e[e._set=Qe]="_set";let Ze=Qe+1;e[e._static=Ze]="_static";let et=Ze+1;e[e._symbol=et]="_symbol";let tt=et+1;e[e._type=tt]="_type";let nt=tt+1;e[e._unique=nt]="_unique";let dt=nt+1;e[e._using=dt]="_using"})(l||(l={}));var t;(function(e){e[e.PRECEDENCE_MASK=15]="PRECEDENCE_MASK";let s=16;e[e.IS_KEYWORD=s]="IS_KEYWORD";let o=32;e[e.IS_ASSIGN=o]="IS_ASSIGN";let i=64;e[e.IS_RIGHT_ASSOCIATIVE=i]="IS_RIGHT_ASSOCIATIVE";let c=128;e[e.IS_PREFIX=c]="IS_PREFIX";let u=256;e[e.IS_POSTFIX=u]="IS_POSTFIX";let d=512;e[e.IS_EXPRESSION_START=d]="IS_EXPRESSION_START";let x=512;e[e.num=x]="num";let g=1536;e[e.bigint=g]="bigint";let _=2560;e[e.decimal=_]="decimal";let w=3584;e[e.regexp=w]="regexp";let S=4608;e[e.string=S]="string";let v=5632;e[e.name=v]="name";let j=6144;e[e.eof=j]="eof";let U=7680;e[e.bracketL=U]="bracketL";let b=8192;e[e.bracketR=b]="bracketR";let R=9728;e[e.braceL=R]="braceL";let L=10752;e[e.braceBarL=L]="braceBarL";let z=11264;e[e.braceR=z]="braceR";let V=12288;e[e.braceBarR=V]="braceBarR";let W=13824;e[e.parenL=W]="parenL";let te=14336;e[e.parenR=te]="parenR";let fe=15360;e[e.comma=fe]="comma";let oe=16384;e[e.semi=oe]="semi";let he=17408;e[e.colon=he]="colon";let Ue=18432;e[e.doubleColon=Ue]="doubleColon";let He=19456;e[e.dot=He]="dot";let Ve=20480;e[e.question=Ve]="question";let We=21504;e[e.questionDot=We]="questionDot";let Xe=22528;e[e.arrow=Xe]="arrow";let Ge=23552;e[e.template=Ge]="template";let Je=24576;e[e.ellipsis=Je]="ellipsis";let ze=25600;e[e.backQuote=ze]="backQuote";let Ke=27136;e[e.dollarBraceL=Ke]="dollarBraceL";let Ye=27648;e[e.at=Ye]="at";let Qe=29184;e[e.hash=Qe]="hash";let Ze=29728;e[e.eq=Ze]="eq";let et=30752;e[e.assign=et]="assign";let tt=32640;e[e.preIncDec=tt]="preIncDec";let nt=33664;e[e.postIncDec=nt]="postIncDec";let dt=34432;e[e.bang=dt]="bang";let Bn=35456;e[e.tilde=Bn]="tilde";let qn=35841;e[e.pipeline=qn]="pipeline";let $n=36866;e[e.nullishCoalescing=$n]="nullishCoalescing";let Un=37890;e[e.logicalOR=Un]="logicalOR";let Hn=38915;e[e.logicalAND=Hn]="logicalAND";let Vn=39940;e[e.bitwiseOR=Vn]="bitwiseOR";let Wn=40965;e[e.bitwiseXOR=Wn]="bitwiseXOR";let Xn=41990;e[e.bitwiseAND=Xn]="bitwiseAND";let Gn=43015;e[e.equality=Gn]="equality";let Jn=44040;e[e.lessThan=Jn]="lessThan";let zn=45064;e[e.greaterThan=zn]="greaterThan";let Kn=46088;e[e.relationalOrEqual=Kn]="relationalOrEqual";let Yn=47113;e[e.bitShiftL=Yn]="bitShiftL";let Qn=48137;e[e.bitShiftR=Qn]="bitShiftR";let Zn=49802;e[e.plus=Zn]="plus";let es=50826;e[e.minus=es]="minus";let ts=51723;e[e.modulo=ts]="modulo";let ns=52235;e[e.star=ns]="star";let ss=53259;e[e.slash=ss]="slash";let rs=54348;e[e.exponent=rs]="exponent";let os=55296;e[e.jsxName=os]="jsxName";let is=56320;e[e.jsxText=is]="jsxText";let as=57344;e[e.jsxEmptyText=as]="jsxEmptyText";let cs=58880;e[e.jsxTagStart=cs]="jsxTagStart";let ls=59392;e[e.jsxTagEnd=ls]="jsxTagEnd";let us=60928;e[e.typeParameterStart=us]="typeParameterStart";let ps=61440;e[e.nonNullAssertion=ps]="nonNullAssertion";let fs=62480;e[e._break=fs]="_break";let hs=63504;e[e._case=hs]="_case";let ms=64528;e[e._catch=ms]="_catch";let ds=65552;e[e._continue=ds]="_continue";let ks=66576;e[e._debugger=ks]="_debugger";let xs=67600;e[e._default=xs]="_default";let gs=68624;e[e._do=gs]="_do";let _s=69648;e[e._else=_s]="_else";let ys=70672;e[e._finally=ys]="_finally";let Is=71696;e[e._for=Is]="_for";let Ts=73232;e[e._function=Ts]="_function";let bs=73744;e[e._if=bs]="_if";let ws=74768;e[e._return=ws]="_return";let Ss=75792;e[e._switch=Ss]="_switch";let Es=77456;e[e._throw=Es]="_throw";let As=77840;e[e._try=As]="_try";let vs=78864;e[e._var=vs]="_var";let Cs=79888;e[e._let=Cs]="_let";let Ps=80912;e[e._const=Ps]="_const";let Ns=81936;e[e._while=Ns]="_while";let Rs=82960;e[e._with=Rs]="_with";let Ls=84496;e[e._new=Ls]="_new";let Ds=85520;e[e._this=Ds]="_this";let Os=86544;e[e._super=Os]="_super";let Fs=87568;e[e._class=Fs]="_class";let js=88080;e[e._extends=js]="_extends";let Ms=89104;e[e._export=Ms]="_export";let Bs=90640;e[e._import=Bs]="_import";let qs=91664;e[e._yield=qs]="_yield";let $s=92688;e[e._null=$s]="_null";let Us=93712;e[e._true=Us]="_true";let Hs=94736;e[e._false=Hs]="_false";let Vs=95256;e[e._in=Vs]="_in";let Ws=96280;e[e._instanceof=Ws]="_instanceof";let Xs=97936;e[e._typeof=Xs]="_typeof";let Gs=98960;e[e._void=Gs]="_void";let qi=99984;e[e._delete=qi]="_delete";let $i=100880;e[e._async=$i]="_async";let Ui=101904;e[e._get=Ui]="_get";let Hi=102928;e[e._set=Hi]="_set";let Vi=103952;e[e._declare=Vi]="_declare";let Wi=104976;e[e._readonly=Wi]="_readonly";let Xi=106e3;e[e._abstract=Xi]="_abstract";let Gi=107024;e[e._static=Gi]="_static";let Ji=107536;e[e._public=Ji]="_public";let zi=108560;e[e._private=zi]="_private";let Ki=109584;e[e._protected=Ki]="_protected";let Yi=110608;e[e._override=Yi]="_override";let Qi=112144;e[e._as=Qi]="_as";let Zi=113168;e[e._enum=Zi]="_enum";let ea=114192;e[e._type=ea]="_type";let ta=115216;e[e._implements=ta]="_implements"})(t||(t={}));function Js(e){switch(e){case t.num:return"num";case t.bigint:return"bigint";case t.decimal:return"decimal";case t.regexp:return"regexp";case t.string:return"string";case t.name:return"name";case t.eof:return"eof";case t.bracketL:return"[";case t.bracketR:return"]";case t.braceL:return"{";case t.braceBarL:return"{|";case t.braceR:return"}";case t.braceBarR:return"|}";case t.parenL:return"(";case t.parenR:return")";case t.comma:return",";case t.semi:return";";case t.colon:return":";case t.doubleColon:return"::";case t.dot:return".";case t.question:return"?";case t.questionDot:return"?.";case t.arrow:return"=>";case t.template:return"template";case t.ellipsis:return"...";case t.backQuote:return"`";case t.dollarBraceL:return"${";case t.at:return"@";case t.hash:return"#";case t.eq:return"=";case t.assign:return"_=";case t.preIncDec:return"++/--";case t.postIncDec:return"++/--";case t.bang:return"!";case t.tilde:return"~";case t.pipeline:return"|>";case t.nullishCoalescing:return"??";case t.logicalOR:return"||";case t.logicalAND:return"&&";case t.bitwiseOR:return"|";case t.bitwiseXOR:return"^";case t.bitwiseAND:return"&";case t.equality:return"==/!=";case t.lessThan:return"<";case t.greaterThan:return">";case t.relationalOrEqual:return"<=/>=";case t.bitShiftL:return"<<";case t.bitShiftR:return">>/>>>";case t.plus:return"+";case t.minus:return"-";case t.modulo:return"%";case t.star:return"*";case t.slash:return"/";case t.exponent:return"**";case t.jsxName:return"jsxName";case t.jsxText:return"jsxText";case t.jsxEmptyText:return"jsxEmptyText";case t.jsxTagStart:return"jsxTagStart";case t.jsxTagEnd:return"jsxTagEnd";case t.typeParameterStart:return"typeParameterStart";case t.nonNullAssertion:return"nonNullAssertion";case t._break:return"break";case t._case:return"case";case t._catch:return"catch";case t._continue:return"continue";case t._debugger:return"debugger";case t._default:return"default";case t._do:return"do";case t._else:return"else";case t._finally:return"finally";case t._for:return"for";case t._function:return"function";case t._if:return"if";case t._return:return"return";case t._switch:return"switch";case t._throw:return"throw";case t._try:return"try";case t._var:return"var";case t._let:return"let";case t._const:return"const";case t._while:return"while";case t._with:return"with";case t._new:return"new";case t._this:return"this";case t._super:return"super";case t._class:return"class";case t._extends:return"extends";case t._export:return"export";case t._import:return"import";case t._yield:return"yield";case t._null:return"null";case t._true:return"true";case t._false:return"false";case t._in:return"in";case t._instanceof:return"instanceof";case t._typeof:return"typeof";case t._void:return"void";case t._delete:return"delete";case t._async:return"async";case t._get:return"get";case t._set:return"set";case t._declare:return"declare";case t._readonly:return"readonly";case t._abstract:return"abstract";case t._static:return"static";case t._public:return"public";case t._private:return"private";case t._protected:return"protected";case t._override:return"override";case t._as:return"as";case t._enum:return"enum";case t._type:return"type";case t._implements:return"implements";default:return""}}var ie=class{constructor(n,s,o){this.startTokenIndex=n,this.endTokenIndex=s,this.isFunctionScope=o}},zs=class{constructor(n,s,o,i,c,u,d,x,g,_,w,S,v){this.potentialArrowAt=n,this.noAnonFunctionType=s,this.inDisallowConditionalTypesContext=o,this.tokensLength=i,this.scopesLength=c,this.pos=u,this.type=d,this.contextualKeyword=x,this.start=g,this.end=_,this.isType=w,this.scopeDepth=S,this.error=v}},xt=class e{constructor(){e.prototype.__init.call(this),e.prototype.__init2.call(this),e.prototype.__init3.call(this),e.prototype.__init4.call(this),e.prototype.__init5.call(this),e.prototype.__init6.call(this),e.prototype.__init7.call(this),e.prototype.__init8.call(this),e.prototype.__init9.call(this),e.prototype.__init10.call(this),e.prototype.__init11.call(this),e.prototype.__init12.call(this),e.prototype.__init13.call(this)}__init(){this.potentialArrowAt=-1}__init2(){this.noAnonFunctionType=!1}__init3(){this.inDisallowConditionalTypesContext=!1}__init4(){this.tokens=[]}__init5(){this.scopes=[]}__init6(){this.pos=0}__init7(){this.type=t.eof}__init8(){this.contextualKeyword=l.NONE}__init9(){this.start=0}__init10(){this.end=0}__init11(){this.isType=!1}__init12(){this.scopeDepth=0}__init13(){this.error=null}snapshot(){return new zs(this.potentialArrowAt,this.noAnonFunctionType,this.inDisallowConditionalTypesContext,this.tokens.length,this.scopes.length,this.pos,this.type,this.contextualKeyword,this.start,this.end,this.isType,this.scopeDepth,this.error)}restoreFromSnapshot(n){this.potentialArrowAt=n.potentialArrowAt,this.noAnonFunctionType=n.noAnonFunctionType,this.inDisallowConditionalTypesContext=n.inDisallowConditionalTypesContext,this.tokens.length=n.tokensLength,this.scopes.length=n.scopesLength,this.pos=n.pos,this.type=n.type,this.contextualKeyword=n.contextualKeyword,this.start=n.start,this.end=n.end,this.isType=n.isType,this.scopeDepth=n.scopeDepth,this.error=n.error}};var p;(function(e){e[e.backSpace=8]="backSpace";let s=10;e[e.lineFeed=s]="lineFeed";let o=9;e[e.tab=o]="tab";let i=13;e[e.carriageReturn=i]="carriageReturn";let c=14;e[e.shiftOut=c]="shiftOut";let u=32;e[e.space=u]="space";let d=33;e[e.exclamationMark=d]="exclamationMark";let x=34;e[e.quotationMark=x]="quotationMark";let g=35;e[e.numberSign=g]="numberSign";let _=36;e[e.dollarSign=_]="dollarSign";let w=37;e[e.percentSign=w]="percentSign";let S=38;e[e.ampersand=S]="ampersand";let v=39;e[e.apostrophe=v]="apostrophe";let j=40;e[e.leftParenthesis=j]="leftParenthesis";let U=41;e[e.rightParenthesis=U]="rightParenthesis";let b=42;e[e.asterisk=b]="asterisk";let R=43;e[e.plusSign=R]="plusSign";let L=44;e[e.comma=L]="comma";let z=45;e[e.dash=z]="dash";let V=46;e[e.dot=V]="dot";let W=47;e[e.slash=W]="slash";let te=48;e[e.digit0=te]="digit0";let fe=49;e[e.digit1=fe]="digit1";let oe=50;e[e.digit2=oe]="digit2";let he=51;e[e.digit3=he]="digit3";let Ue=52;e[e.digit4=Ue]="digit4";let He=53;e[e.digit5=He]="digit5";let Ve=54;e[e.digit6=Ve]="digit6";let We=55;e[e.digit7=We]="digit7";let Xe=56;e[e.digit8=Xe]="digit8";let Ge=57;e[e.digit9=Ge]="digit9";let Je=58;e[e.colon=Je]="colon";let ze=59;e[e.semicolon=ze]="semicolon";let Ke=60;e[e.lessThan=Ke]="lessThan";let Ye=61;e[e.equalsTo=Ye]="equalsTo";let Qe=62;e[e.greaterThan=Qe]="greaterThan";let Ze=63;e[e.questionMark=Ze]="questionMark";let et=64;e[e.atSign=et]="atSign";let tt=65;e[e.uppercaseA=tt]="uppercaseA";let nt=66;e[e.uppercaseB=nt]="uppercaseB";let dt=67;e[e.uppercaseC=dt]="uppercaseC";let Bn=68;e[e.uppercaseD=Bn]="uppercaseD";let qn=69;e[e.uppercaseE=qn]="uppercaseE";let $n=70;e[e.uppercaseF=$n]="uppercaseF";let Un=71;e[e.uppercaseG=Un]="uppercaseG";let Hn=72;e[e.uppercaseH=Hn]="uppercaseH";let Vn=73;e[e.uppercaseI=Vn]="uppercaseI";let Wn=74;e[e.uppercaseJ=Wn]="uppercaseJ";let Xn=75;e[e.uppercaseK=Xn]="uppercaseK";let Gn=76;e[e.uppercaseL=Gn]="uppercaseL";let Jn=77;e[e.uppercaseM=Jn]="uppercaseM";let zn=78;e[e.uppercaseN=zn]="uppercaseN";let Kn=79;e[e.uppercaseO=Kn]="uppercaseO";let Yn=80;e[e.uppercaseP=Yn]="uppercaseP";let Qn=81;e[e.uppercaseQ=Qn]="uppercaseQ";let Zn=82;e[e.uppercaseR=Zn]="uppercaseR";let es=83;e[e.uppercaseS=es]="uppercaseS";let ts=84;e[e.uppercaseT=ts]="uppercaseT";let ns=85;e[e.uppercaseU=ns]="uppercaseU";let ss=86;e[e.uppercaseV=ss]="uppercaseV";let rs=87;e[e.uppercaseW=rs]="uppercaseW";let os=88;e[e.uppercaseX=os]="uppercaseX";let is=89;e[e.uppercaseY=is]="uppercaseY";let as=90;e[e.uppercaseZ=as]="uppercaseZ";let cs=91;e[e.leftSquareBracket=cs]="leftSquareBracket";let ls=92;e[e.backslash=ls]="backslash";let us=93;e[e.rightSquareBracket=us]="rightSquareBracket";let ps=94;e[e.caret=ps]="caret";let fs=95;e[e.underscore=fs]="underscore";let hs=96;e[e.graveAccent=hs]="graveAccent";let ms=97;e[e.lowercaseA=ms]="lowercaseA";let ds=98;e[e.lowercaseB=ds]="lowercaseB";let ks=99;e[e.lowercaseC=ks]="lowercaseC";let xs=100;e[e.lowercaseD=xs]="lowercaseD";let gs=101;e[e.lowercaseE=gs]="lowercaseE";let _s=102;e[e.lowercaseF=_s]="lowercaseF";let ys=103;e[e.lowercaseG=ys]="lowercaseG";let Is=104;e[e.lowercaseH=Is]="lowercaseH";let Ts=105;e[e.lowercaseI=Ts]="lowercaseI";let bs=106;e[e.lowercaseJ=bs]="lowercaseJ";let ws=107;e[e.lowercaseK=ws]="lowercaseK";let Ss=108;e[e.lowercaseL=Ss]="lowercaseL";let Es=109;e[e.lowercaseM=Es]="lowercaseM";let As=110;e[e.lowercaseN=As]="lowercaseN";let vs=111;e[e.lowercaseO=vs]="lowercaseO";let Cs=112;e[e.lowercaseP=Cs]="lowercaseP";let Ps=113;e[e.lowercaseQ=Ps]="lowercaseQ";let Ns=114;e[e.lowercaseR=Ns]="lowercaseR";let Rs=115;e[e.lowercaseS=Rs]="lowercaseS";let Ls=116;e[e.lowercaseT=Ls]="lowercaseT";let Ds=117;e[e.lowercaseU=Ds]="lowercaseU";let Os=118;e[e.lowercaseV=Os]="lowercaseV";let Fs=119;e[e.lowercaseW=Fs]="lowercaseW";let js=120;e[e.lowercaseX=js]="lowercaseX";let Ms=121;e[e.lowercaseY=Ms]="lowercaseY";let Bs=122;e[e.lowercaseZ=Bs]="lowercaseZ";let qs=123;e[e.leftCurlyBrace=qs]="leftCurlyBrace";let $s=124;e[e.verticalBar=$s]="verticalBar";let Us=125;e[e.rightCurlyBrace=Us]="rightCurlyBrace";let Hs=126;e[e.tilde=Hs]="tilde";let Vs=160;e[e.nonBreakingSpace=Vs]="nonBreakingSpace";let Ws=5760;e[e.oghamSpaceMark=Ws]="oghamSpaceMark";let Xs=8232;e[e.lineSeparator=Xs]="lineSeparator";let Gs=8233;e[e.paragraphSeparator=Gs]="paragraphSeparator"})(p||(p={}));var st,D,O,r,k,fo;function Fe(){return fo++}function ho(e){if("pos"in e){let n=ca(e.pos);e.message+=` (${n.line}:${n.column})`,e.loc=n}return e}var Ks=class{constructor(n,s){this.line=n,this.column=s}};function ca(e){let n=1,s=1;for(let o=0;op.lowercaseZ));){let i=er[e+(n-p.lowercaseA)+1];if(i===-1)break;e=i,s++}let o=er[e];if(o>-1&&!se[n]){r.pos=s,o&1?C(o>>>1):C(t.name,o>>>1);return}for(;s=k.length){let e=r.tokens;e.length>=2&&e[e.length-1].start>=k.length&&e[e.length-2].start>=k.length&&A("Unexpectedly reached the end of input."),C(t.eof);return}ua(k.charCodeAt(r.pos))}function ua(e){Se[e]||e===p.backslash||e===p.atSign&&k.charCodeAt(r.pos+1)===p.atSign?tr():lr(e)}function pa(){for(;k.charCodeAt(r.pos)!==p.asterisk||k.charCodeAt(r.pos+1)!==p.slash;)if(r.pos++,r.pos>k.length){A("Unterminated comment",r.pos-2);return}r.pos+=2}function ar(e){let n=k.charCodeAt(r.pos+=e);if(r.pos=p.digit0&&e<=p.digit9){To(!0);return}e===p.dot&&k.charCodeAt(r.pos+2)===p.dot?(r.pos+=3,C(t.ellipsis)):(++r.pos,C(t.dot))}function ha(){k.charCodeAt(r.pos+1)===p.equalsTo?M(t.assign,2):M(t.slash,1)}function ma(e){let n=e===p.asterisk?t.star:t.modulo,s=1,o=k.charCodeAt(r.pos+1);e===p.asterisk&&o===p.asterisk&&(s++,o=k.charCodeAt(r.pos+2),n=t.exponent),o===p.equalsTo&&k.charCodeAt(r.pos+2)!==p.greaterThan&&(s++,n=t.assign),M(n,s)}function da(e){let n=k.charCodeAt(r.pos+1);if(n===e){k.charCodeAt(r.pos+2)===p.equalsTo?M(t.assign,3):M(e===p.verticalBar?t.logicalOR:t.logicalAND,2);return}if(e===p.verticalBar){if(n===p.greaterThan){M(t.pipeline,2);return}else if(n===p.rightCurlyBrace&&O){M(t.braceBarR,2);return}}if(n===p.equalsTo){M(t.assign,2);return}M(e===p.verticalBar?t.bitwiseOR:t.bitwiseAND,1)}function ka(){k.charCodeAt(r.pos+1)===p.equalsTo?M(t.assign,2):M(t.bitwiseXOR,1)}function xa(e){let n=k.charCodeAt(r.pos+1);if(n===e){M(t.preIncDec,2);return}n===p.equalsTo?M(t.assign,2):e===p.plusSign?M(t.plus,1):M(t.minus,1)}function ga(){let e=k.charCodeAt(r.pos+1);if(e===p.lessThan){if(k.charCodeAt(r.pos+2)===p.equalsTo){M(t.assign,3);return}r.isType?M(t.lessThan,1):M(t.bitShiftL,2);return}e===p.equalsTo?M(t.relationalOrEqual,2):M(t.lessThan,1)}function Io(){if(r.isType){M(t.greaterThan,1);return}let e=k.charCodeAt(r.pos+1);if(e===p.greaterThan){let n=k.charCodeAt(r.pos+2)===p.greaterThan?3:2;if(k.charCodeAt(r.pos+n)===p.equalsTo){M(t.assign,n+1);return}M(t.bitShiftR,n);return}e===p.equalsTo?M(t.relationalOrEqual,2):M(t.greaterThan,1)}function on(){r.type===t.greaterThan&&(r.pos-=1,Io())}function _a(e){let n=k.charCodeAt(r.pos+1);if(n===p.equalsTo){M(t.equality,k.charCodeAt(r.pos+2)===p.equalsTo?3:2);return}if(e===p.equalsTo&&n===p.greaterThan){r.pos+=2,C(t.arrow);return}M(e===p.equalsTo?t.eq:t.bang,1)}function ya(){let e=k.charCodeAt(r.pos+1),n=k.charCodeAt(r.pos+2);e===p.questionMark&&!(O&&r.isType)?n===p.equalsTo?M(t.assign,3):M(t.nullishCoalescing,2):e===p.dot&&!(n>=p.digit0&&n<=p.digit9)?(r.pos+=2,C(t.questionDot)):(++r.pos,C(t.question))}function lr(e){switch(e){case p.numberSign:++r.pos,C(t.hash);return;case p.dot:fa();return;case p.leftParenthesis:++r.pos,C(t.parenL);return;case p.rightParenthesis:++r.pos,C(t.parenR);return;case p.semicolon:++r.pos,C(t.semi);return;case p.comma:++r.pos,C(t.comma);return;case p.leftSquareBracket:++r.pos,C(t.bracketL);return;case p.rightSquareBracket:++r.pos,C(t.bracketR);return;case p.leftCurlyBrace:O&&k.charCodeAt(r.pos+1)===p.verticalBar?M(t.braceBarL,2):(++r.pos,C(t.braceL));return;case p.rightCurlyBrace:++r.pos,C(t.braceR);return;case p.colon:k.charCodeAt(r.pos+1)===p.colon?M(t.doubleColon,2):(++r.pos,C(t.colon));return;case p.questionMark:ya();return;case p.atSign:++r.pos,C(t.at);return;case p.graveAccent:++r.pos,C(t.backQuote);return;case p.digit0:{let n=k.charCodeAt(r.pos+1);if(n===p.lowercaseX||n===p.uppercaseX||n===p.lowercaseO||n===p.uppercaseO||n===p.lowercaseB||n===p.uppercaseB){Ta();return}}case p.digit1:case p.digit2:case p.digit3:case p.digit4:case p.digit5:case p.digit6:case p.digit7:case p.digit8:case p.digit9:To(!1);return;case p.quotationMark:case p.apostrophe:ba(e);return;case p.slash:ha();return;case p.percentSign:case p.asterisk:ma(e);return;case p.verticalBar:case p.ampersand:da(e);return;case p.caret:ka();return;case p.plusSign:case p.dash:xa(e);return;case p.lessThan:ga();return;case p.greaterThan:Io();return;case p.equalsTo:case p.exclamationMark:_a(e);return;case p.tilde:M(t.tilde,1);return;default:break}A(`Unexpected character '${String.fromCharCode(e)}'`,r.pos)}function M(e,n){r.pos+=n,C(e)}function Ia(){let e=r.pos,n=!1,s=!1;for(;;){if(r.pos>=k.length){A("Unterminated regular expression",e);return}let o=k.charCodeAt(r.pos);if(n)n=!1;else{if(o===p.leftSquareBracket)s=!0;else if(o===p.rightSquareBracket&&s)s=!1;else if(o===p.slash&&!s)break;n=o===p.backslash}++r.pos}++r.pos,Sa(),C(t.regexp)}function nr(){for(;;){let e=k.charCodeAt(r.pos);if(e>=p.digit0&&e<=p.digit9||e===p.underscore)r.pos++;else break}}function Ta(){for(r.pos+=2;;){let n=k.charCodeAt(r.pos);if(n>=p.digit0&&n<=p.digit9||n>=p.lowercaseA&&n<=p.lowercaseF||n>=p.uppercaseA&&n<=p.uppercaseF||n===p.underscore)r.pos++;else break}k.charCodeAt(r.pos)===p.lowercaseN?(++r.pos,C(t.bigint)):C(t.num)}function To(e){let n=!1,s=!1;e||nr();let o=k.charCodeAt(r.pos);if(o===p.dot&&(++r.pos,nr(),o=k.charCodeAt(r.pos)),(o===p.uppercaseE||o===p.lowercaseE)&&(o=k.charCodeAt(++r.pos),(o===p.plusSign||o===p.dash)&&++r.pos,nr(),o=k.charCodeAt(r.pos)),o===p.lowercaseN?(++r.pos,n=!0):o===p.lowercaseM&&(++r.pos,s=!0),n){C(t.bigint);return}if(s){C(t.decimal);return}C(t.num)}function ba(e){for(r.pos++;;){if(r.pos>=k.length){A("Unterminated string constant");return}let n=k.charCodeAt(r.pos);if(n===p.backslash)r.pos++;else if(n===e)break;r.pos++}r.pos++,C(t.string)}function wa(){for(;;){if(r.pos>=k.length){A("Unterminated template");return}let e=k.charCodeAt(r.pos);if(e===p.graveAccent||e===p.dollarSign&&k.charCodeAt(r.pos+1)===p.leftCurlyBrace){if(r.pos===r.start&&a(t.template))if(e===p.dollarSign){r.pos+=2,C(t.dollarBraceL);return}else{++r.pos,C(t.backQuote);return}C(t.template);return}e===p.backslash&&r.pos++,r.pos++}}function Sa(){for(;r.pos"],["nbsp","\xA0"],["iexcl","\xA1"],["cent","\xA2"],["pound","\xA3"],["curren","\xA4"],["yen","\xA5"],["brvbar","\xA6"],["sect","\xA7"],["uml","\xA8"],["copy","\xA9"],["ordf","\xAA"],["laquo","\xAB"],["not","\xAC"],["shy","\xAD"],["reg","\xAE"],["macr","\xAF"],["deg","\xB0"],["plusmn","\xB1"],["sup2","\xB2"],["sup3","\xB3"],["acute","\xB4"],["micro","\xB5"],["para","\xB6"],["middot","\xB7"],["cedil","\xB8"],["sup1","\xB9"],["ordm","\xBA"],["raquo","\xBB"],["frac14","\xBC"],["frac12","\xBD"],["frac34","\xBE"],["iquest","\xBF"],["Agrave","\xC0"],["Aacute","\xC1"],["Acirc","\xC2"],["Atilde","\xC3"],["Auml","\xC4"],["Aring","\xC5"],["AElig","\xC6"],["Ccedil","\xC7"],["Egrave","\xC8"],["Eacute","\xC9"],["Ecirc","\xCA"],["Euml","\xCB"],["Igrave","\xCC"],["Iacute","\xCD"],["Icirc","\xCE"],["Iuml","\xCF"],["ETH","\xD0"],["Ntilde","\xD1"],["Ograve","\xD2"],["Oacute","\xD3"],["Ocirc","\xD4"],["Otilde","\xD5"],["Ouml","\xD6"],["times","\xD7"],["Oslash","\xD8"],["Ugrave","\xD9"],["Uacute","\xDA"],["Ucirc","\xDB"],["Uuml","\xDC"],["Yacute","\xDD"],["THORN","\xDE"],["szlig","\xDF"],["agrave","\xE0"],["aacute","\xE1"],["acirc","\xE2"],["atilde","\xE3"],["auml","\xE4"],["aring","\xE5"],["aelig","\xE6"],["ccedil","\xE7"],["egrave","\xE8"],["eacute","\xE9"],["ecirc","\xEA"],["euml","\xEB"],["igrave","\xEC"],["iacute","\xED"],["icirc","\xEE"],["iuml","\xEF"],["eth","\xF0"],["ntilde","\xF1"],["ograve","\xF2"],["oacute","\xF3"],["ocirc","\xF4"],["otilde","\xF5"],["ouml","\xF6"],["divide","\xF7"],["oslash","\xF8"],["ugrave","\xF9"],["uacute","\xFA"],["ucirc","\xFB"],["uuml","\xFC"],["yacute","\xFD"],["thorn","\xFE"],["yuml","\xFF"],["OElig","\u0152"],["oelig","\u0153"],["Scaron","\u0160"],["scaron","\u0161"],["Yuml","\u0178"],["fnof","\u0192"],["circ","\u02C6"],["tilde","\u02DC"],["Alpha","\u0391"],["Beta","\u0392"],["Gamma","\u0393"],["Delta","\u0394"],["Epsilon","\u0395"],["Zeta","\u0396"],["Eta","\u0397"],["Theta","\u0398"],["Iota","\u0399"],["Kappa","\u039A"],["Lambda","\u039B"],["Mu","\u039C"],["Nu","\u039D"],["Xi","\u039E"],["Omicron","\u039F"],["Pi","\u03A0"],["Rho","\u03A1"],["Sigma","\u03A3"],["Tau","\u03A4"],["Upsilon","\u03A5"],["Phi","\u03A6"],["Chi","\u03A7"],["Psi","\u03A8"],["Omega","\u03A9"],["alpha","\u03B1"],["beta","\u03B2"],["gamma","\u03B3"],["delta","\u03B4"],["epsilon","\u03B5"],["zeta","\u03B6"],["eta","\u03B7"],["theta","\u03B8"],["iota","\u03B9"],["kappa","\u03BA"],["lambda","\u03BB"],["mu","\u03BC"],["nu","\u03BD"],["xi","\u03BE"],["omicron","\u03BF"],["pi","\u03C0"],["rho","\u03C1"],["sigmaf","\u03C2"],["sigma","\u03C3"],["tau","\u03C4"],["upsilon","\u03C5"],["phi","\u03C6"],["chi","\u03C7"],["psi","\u03C8"],["omega","\u03C9"],["thetasym","\u03D1"],["upsih","\u03D2"],["piv","\u03D6"],["ensp","\u2002"],["emsp","\u2003"],["thinsp","\u2009"],["zwnj","\u200C"],["zwj","\u200D"],["lrm","\u200E"],["rlm","\u200F"],["ndash","\u2013"],["mdash","\u2014"],["lsquo","\u2018"],["rsquo","\u2019"],["sbquo","\u201A"],["ldquo","\u201C"],["rdquo","\u201D"],["bdquo","\u201E"],["dagger","\u2020"],["Dagger","\u2021"],["bull","\u2022"],["hellip","\u2026"],["permil","\u2030"],["prime","\u2032"],["Prime","\u2033"],["lsaquo","\u2039"],["rsaquo","\u203A"],["oline","\u203E"],["frasl","\u2044"],["euro","\u20AC"],["image","\u2111"],["weierp","\u2118"],["real","\u211C"],["trade","\u2122"],["alefsym","\u2135"],["larr","\u2190"],["uarr","\u2191"],["rarr","\u2192"],["darr","\u2193"],["harr","\u2194"],["crarr","\u21B5"],["lArr","\u21D0"],["uArr","\u21D1"],["rArr","\u21D2"],["dArr","\u21D3"],["hArr","\u21D4"],["forall","\u2200"],["part","\u2202"],["exist","\u2203"],["empty","\u2205"],["nabla","\u2207"],["isin","\u2208"],["notin","\u2209"],["ni","\u220B"],["prod","\u220F"],["sum","\u2211"],["minus","\u2212"],["lowast","\u2217"],["radic","\u221A"],["prop","\u221D"],["infin","\u221E"],["ang","\u2220"],["and","\u2227"],["or","\u2228"],["cap","\u2229"],["cup","\u222A"],["int","\u222B"],["there4","\u2234"],["sim","\u223C"],["cong","\u2245"],["asymp","\u2248"],["ne","\u2260"],["equiv","\u2261"],["le","\u2264"],["ge","\u2265"],["sub","\u2282"],["sup","\u2283"],["nsub","\u2284"],["sube","\u2286"],["supe","\u2287"],["oplus","\u2295"],["otimes","\u2297"],["perp","\u22A5"],["sdot","\u22C5"],["lceil","\u2308"],["rceil","\u2309"],["lfloor","\u230A"],["rfloor","\u230B"],["lang","\u2329"],["rang","\u232A"],["loz","\u25CA"],["spades","\u2660"],["clubs","\u2663"],["hearts","\u2665"],["diams","\u2666"]]);function _t(e){let[n,s]=wo(e.jsxPragma||"React.createElement"),[o,i]=wo(e.jsxFragmentPragma||"React.Fragment");return{base:n,suffix:s,fragmentBase:o,fragmentSuffix:i}}function wo(e){let n=e.indexOf(".");return n===-1&&(n=e.length),[e.slice(0,n),e.slice(n)]}var G=class{getPrefixCode(){return""}getHoistedCode(){return""}getSuffixCode(){return""}};var yt=class e extends G{__init(){this.lastLineNumber=1}__init2(){this.lastIndex=0}__init3(){this.filenameVarName=null}__init4(){this.esmAutomaticImportNameResolutions={}}__init5(){this.cjsAutomaticModuleNameResolutions={}}constructor(n,s,o,i,c){super(),this.rootTransformer=n,this.tokens=s,this.importProcessor=o,this.nameManager=i,this.options=c,e.prototype.__init.call(this),e.prototype.__init2.call(this),e.prototype.__init3.call(this),e.prototype.__init4.call(this),e.prototype.__init5.call(this),this.jsxPragmaInfo=_t(c),this.isAutomaticRuntime=c.jsxRuntime==="automatic",this.jsxImportSource=c.jsxImportSource||"react"}process(){return this.tokens.matches1(t.jsxTagStart)?(this.processJSXTag(),!0):!1}getPrefixCode(){let n="";if(this.filenameVarName&&(n+=`const ${this.filenameVarName} = ${JSON.stringify(this.options.filePath||"")};`),this.isAutomaticRuntime)if(this.importProcessor)for(let[s,o]of Object.entries(this.cjsAutomaticModuleNameResolutions))n+=`var ${o} = require("${s}");`;else{let{createElement:s,...o}=this.esmAutomaticImportNameResolutions;s&&(n+=`import {createElement as ${s}} from "${this.jsxImportSource}";`);let i=Object.entries(o).map(([c,u])=>`${c} as ${u}`).join(", ");if(i){let c=this.jsxImportSource+(this.options.production?"/jsx-runtime":"/jsx-dev-runtime");n+=`import {${i}} from "${c}";`}}return n}processJSXTag(){let{jsxRole:n,start:s}=this.tokens.currentToken(),o=this.options.production?null:this.getElementLocationCode(s);this.isAutomaticRuntime&&n!==le.KeyAfterPropSpread?this.transformTagToJSXFunc(o,n):this.transformTagToCreateElement(o)}getElementLocationCode(n){return`lineNumber: ${this.getLineNumberForIndex(n)}`}getLineNumberForIndex(n){let s=this.tokens.code;for(;this.lastIndex or > at the end of the tag.");i&&this.tokens.appendCode(`, ${i}`)}for(this.options.production||(i===null&&this.tokens.appendCode(", void 0"),this.tokens.appendCode(`, ${o}, ${this.getDevSource(n)}, this`)),this.tokens.removeInitialToken();!this.tokens.matches1(t.jsxTagEnd);)this.tokens.removeToken();this.tokens.replaceToken(")")}transformTagToCreateElement(n){if(this.tokens.replaceToken(this.getCreateElementInvocationCode()),this.tokens.matches1(t.jsxTagEnd))this.tokens.replaceToken(`${this.getFragmentCode()}, null`),this.processChildren(!0);else if(this.processTagIntro(),this.processPropsObjectWithDevInfo(n),!this.tokens.matches2(t.slash,t.jsxTagEnd))if(this.tokens.matches1(t.jsxTagEnd))this.tokens.removeToken(),this.processChildren(!0);else throw new Error("Expected either /> or > at the end of the tag.");for(this.tokens.removeInitialToken();!this.tokens.matches1(t.jsxTagEnd);)this.tokens.removeToken();this.tokens.replaceToken(")")}getJSXFuncInvocationCode(n){return this.options.production?n?this.claimAutoImportedFuncInvocation("jsxs","/jsx-runtime"):this.claimAutoImportedFuncInvocation("jsx","/jsx-runtime"):this.claimAutoImportedFuncInvocation("jsxDEV","/jsx-dev-runtime")}getCreateElementInvocationCode(){if(this.isAutomaticRuntime)return this.claimAutoImportedFuncInvocation("createElement","");{let{jsxPragmaInfo:n}=this;return`${this.importProcessor&&this.importProcessor.getIdentifierReplacement(n.base)||n.base}${n.suffix}(`}}getFragmentCode(){if(this.isAutomaticRuntime)return this.claimAutoImportedName("Fragment",this.options.production?"/jsx-runtime":"/jsx-dev-runtime");{let{jsxPragmaInfo:n}=this;return(this.importProcessor&&this.importProcessor.getIdentifierReplacement(n.fragmentBase)||n.fragmentBase)+n.fragmentSuffix}}claimAutoImportedFuncInvocation(n,s){let o=this.claimAutoImportedName(n,s);return this.importProcessor?`${o}.call(void 0, `:`${o}(`}claimAutoImportedName(n,s){if(this.importProcessor){let o=this.jsxImportSource+s;return this.cjsAutomaticModuleNameResolutions[o]||(this.cjsAutomaticModuleNameResolutions[o]=this.importProcessor.getFreeIdentifierForPath(o)),`${this.cjsAutomaticModuleNameResolutions[o]}.${n}`}else return this.esmAutomaticImportNameResolutions[n]||(this.esmAutomaticImportNameResolutions[n]=this.nameManager.claimFreeName(`_${n}`)),this.esmAutomaticImportNameResolutions[n]}processTagIntro(){let n=this.tokens.currentIndex()+1;for(;this.tokens.tokens[n].isType||!this.tokens.matches2AtIndex(n-1,t.jsxName,t.jsxName)&&!this.tokens.matches2AtIndex(n-1,t.greaterThan,t.jsxName)&&!this.tokens.matches1AtIndex(n,t.braceL)&&!this.tokens.matches1AtIndex(n,t.jsxTagEnd)&&!this.tokens.matches2AtIndex(n,t.slash,t.jsxTagEnd);)n++;if(n===this.tokens.currentIndex()+1){let s=this.tokens.identifierName();ur(s)&&this.tokens.replaceToken(`'${s}'`)}for(;this.tokens.currentIndex()=p.lowercaseA&&n<=p.lowercaseZ}function Ea(e){let n="",s="",o=!1,i=!1;for(let c=0;c=p.digit0&&e<=p.digit9}function Ca(e){return e>=p.digit0&&e<=p.digit9||e>=p.lowercaseA&&e<=p.lowercaseF||e>=p.uppercaseA&&e<=p.uppercaseF}function cn(e,n){let s=_t(n),o=new Set;for(let i=0;i0||s.namedExports.length>0)continue;[...s.defaultNames,...s.wildcardNames,...s.namedImports.map(({localName:i})=>i)].every(i=>this.shouldAutomaticallyElideImportedName(i))&&this.importsToReplace.set(n,"")}}shouldAutomaticallyElideImportedName(n){return this.isTypeScriptTransformEnabled&&!this.keepUnusedImports&&!this.nonTypeIdentifiers.has(n)}generateImportReplacements(){for(let[n,s]of this.importInfoByPath.entries()){let{defaultNames:o,wildcardNames:i,namedImports:c,namedExports:u,exportStarNames:d,hasStarExport:x}=s;if(o.length===0&&i.length===0&&c.length===0&&u.length===0&&d.length===0&&!x){this.importsToReplace.set(n,`require('${n}');`);continue}let g=this.getFreeIdentifierForPath(n),_;this.enableLegacyTypeScriptModuleInterop?_=g:_=i.length>0?i[0]:this.getFreeIdentifierForPath(n);let w=`var ${g} = require('${n}');`;if(i.length>0)for(let S of i){let v=this.enableLegacyTypeScriptModuleInterop?g:`${this.helperManager.getHelperName("interopRequireWildcard")}(${g})`;w+=` var ${S} = ${v};`}else d.length>0&&_!==g?w+=` var ${_} = ${this.helperManager.getHelperName("interopRequireWildcard")}(${g});`:o.length>0&&_!==g&&(w+=` var ${_} = ${this.helperManager.getHelperName("interopRequireDefault")}(${g});`);for(let{importedName:S,localName:v}of u)w+=` ${this.helperManager.getHelperName("createNamedExportFrom")}(${g}, '${v}', '${S}');`;for(let S of d)w+=` exports.${S} = ${_};`;x&&(w+=` ${this.helperManager.getHelperName("createStarExport")}(${g});`),this.importsToReplace.set(n,w);for(let S of o)this.identifierReplacements.set(S,`${_}.default`);for(let{importedName:S,localName:v}of c)this.identifierReplacements.set(v,`${g}.${S}`)}}getFreeIdentifierForPath(n){let s=n.split("/"),i=s[s.length-1].replace(/\W/g,"");return this.nameManager.claimFreeName(`_${i}`)}preprocessImportAtIndex(n){let s=[],o=[],i=[];if(n++,(this.tokens.matchesContextualAtIndex(n,l._type)||this.tokens.matches1AtIndex(n,t._typeof))&&!this.tokens.matches1AtIndex(n+1,t.comma)&&!this.tokens.matchesContextualAtIndex(n+1,l._from)||this.tokens.matches1AtIndex(n,t.parenL))return;if(this.tokens.matches1AtIndex(n,t.name)&&(s.push(this.tokens.identifierNameAtIndex(n)),n++,this.tokens.matches1AtIndex(n,t.comma)&&n++),this.tokens.matches1AtIndex(n,t.star)&&(n+=2,o.push(this.tokens.identifierNameAtIndex(n)),n++),this.tokens.matches1AtIndex(n,t.braceL)){let d=this.getNamedImports(n+1);n=d.newIndex;for(let x of d.namedImports)x.importedName==="default"?s.push(x.localName):i.push(x)}if(this.tokens.matchesContextualAtIndex(n,l._from)&&n++,!this.tokens.matches1AtIndex(n,t.string))throw new Error("Expected string token at the end of import statement.");let c=this.tokens.stringValueAtIndex(n),u=this.getImportInfo(c);u.defaultNames.push(...s),u.wildcardNames.push(...o),u.namedImports.push(...i),s.length===0&&o.length===0&&i.length===0&&(u.hasBareImport=!0)}preprocessExportAtIndex(n){if(this.tokens.matches2AtIndex(n,t._export,t._var)||this.tokens.matches2AtIndex(n,t._export,t._let)||this.tokens.matches2AtIndex(n,t._export,t._const))this.preprocessVarExportAtIndex(n);else if(this.tokens.matches2AtIndex(n,t._export,t._function)||this.tokens.matches2AtIndex(n,t._export,t._class)){let s=this.tokens.identifierNameAtIndex(n+2);this.addExportBinding(s,s)}else if(this.tokens.matches3AtIndex(n,t._export,t.name,t._function)){let s=this.tokens.identifierNameAtIndex(n+3);this.addExportBinding(s,s)}else this.tokens.matches2AtIndex(n,t._export,t.braceL)?this.preprocessNamedExportAtIndex(n):this.tokens.matches2AtIndex(n,t._export,t.star)&&this.preprocessExportStarAtIndex(n)}preprocessVarExportAtIndex(n){let s=0;for(let o=n+2;;o++)if(this.tokens.matches1AtIndex(o,t.braceL)||this.tokens.matches1AtIndex(o,t.dollarBraceL)||this.tokens.matches1AtIndex(o,t.bracketL))s++;else if(this.tokens.matches1AtIndex(o,t.braceR)||this.tokens.matches1AtIndex(o,t.bracketR))s--;else{if(s===0&&!this.tokens.matches1AtIndex(o,t.name))break;if(this.tokens.matches1AtIndex(1,t.eq)){let i=this.tokens.currentToken().rhsEndIndex;if(i==null)throw new Error("Expected = token with an end index.");o=i-1}else{let i=this.tokens.tokens[o];if(nn(i)){let c=this.tokens.identifierNameAtIndex(o);this.identifierReplacements.set(c,`exports.${c}`)}}}}preprocessNamedExportAtIndex(n){n+=2;let{newIndex:s,namedImports:o}=this.getNamedImports(n);if(n=s,this.tokens.matchesContextualAtIndex(n,l._from))n++;else{for(let{importedName:u,localName:d}of o)this.addExportBinding(u,d);return}if(!this.tokens.matches1AtIndex(n,t.string))throw new Error("Expected string token at the end of import statement.");let i=this.tokens.stringValueAtIndex(n);this.getImportInfo(i).namedExports.push(...o)}preprocessExportStarAtIndex(n){let s=null;if(this.tokens.matches3AtIndex(n,t._export,t.star,t._as)?(n+=3,s=this.tokens.identifierNameAtIndex(n),n+=2):n+=3,!this.tokens.matches1AtIndex(n,t.string))throw new Error("Expected string token at the end of star export statement.");let o=this.tokens.stringValueAtIndex(n),i=this.getImportInfo(o);s!==null?i.exportStarNames.push(s):i.hasStarExport=!0}getNamedImports(n){let s=[];for(;;){if(this.tokens.matches1AtIndex(n,t.braceR)){n++;break}let o=Ie(this.tokens,n);if(n=o.endIndex,o.isType||s.push({importedName:o.leftName,localName:o.rightName}),this.tokens.matches2AtIndex(n,t.comma,t.braceR)){n+=2;break}else if(this.tokens.matches1AtIndex(n,t.braceR)){n++;break}else if(this.tokens.matches1AtIndex(n,t.comma))n++;else throw new Error(`Unexpected token: ${JSON.stringify(this.tokens.tokens[n])}`)}return{newIndex:n,namedImports:s}}getImportInfo(n){let s=this.importInfoByPath.get(n);if(s)return s;let o={defaultNames:[],wildcardNames:[],namedImports:[],namedExports:[],hasBareImport:!1,exportStarNames:[],hasStarExport:!1};return this.importInfoByPath.set(n,o),o}addExportBinding(n,s){this.exportBindingsByLocalName.has(n)||this.exportBindingsByLocalName.set(n,[]),this.exportBindingsByLocalName.get(n).push(s)}claimImportCode(n){let s=this.importsToReplace.get(n);return this.importsToReplace.set(n,""),s||""}getIdentifierReplacement(n){return this.identifierReplacements.get(n)||null}resolveExportBinding(n){let s=this.exportBindingsByLocalName.get(n);return!s||s.length===0?null:s.map(o=>`exports.${o}`).join(" = ")}getGlobalNames(){return new Set([...this.identifierReplacements.keys(),...this.exportBindingsByLocalName.keys()])}};var Pa=44,Na=59,Ao="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",Po=new Uint8Array(64),Ra=new Uint8Array(128);for(let e=0;e>>=5,o>0&&(i|=32),e.write(Po[i])}while(o>0);return n}var vo=1024*16,Co=typeof TextDecoder<"u"?new TextDecoder:typeof Buffer<"u"?{decode(e){return Buffer.from(e.buffer,e.byteOffset,e.byteLength).toString()}}:{decode(e){let n="";for(let s=0;s0?n+Co.decode(e.subarray(0,s)):n}};function pr(e){let n=new La,s=0,o=0,i=0,c=0;for(let u=0;u0&&n.write(Na),d.length===0)continue;let x=0;for(let g=0;g0&&n.write(Pa),x=Tt(n,_[0],x),_.length!==1&&(s=Tt(n,_[1],s),o=Tt(n,_[2],o),i=Tt(n,_[3],i),_.length!==4&&(c=Tt(n,_[4],c)))}}return n.flush()}var Da=en(No(),1);var mr=class{constructor(){this._indexes={__proto__:null},this.array=[]}};function Oa(e,n){return e._indexes[n]}function Ro(e,n){let s=Oa(e,n);if(s!==void 0)return s;let{array:o,_indexes:i}=e,c=o.push(n);return i[n]=c-1}var Fa=0,ja=1,Ma=2,Ba=3,qa=4,Do=-1,Oo=class{constructor({file:e,sourceRoot:n}={}){this._names=new mr,this._sources=new mr,this._sourcesContent=[],this._mappings=[],this.file=e,this.sourceRoot=n,this._ignoreList=new mr}};var ln=(e,n,s,o,i,c,u,d)=>Ua(!0,e,n,s,o,i,c,u,d);function $a(e){let{_mappings:n,_sources:s,_sourcesContent:o,_names:i,_ignoreList:c}=e;return Wa(n),{version:3,file:e.file||void 0,names:i.array,sourceRoot:e.sourceRoot||void 0,sources:s.array,sourcesContent:o,mappings:n,ignoreList:c.array}}function Fo(e){let n=$a(e);return Object.assign({},n,{mappings:pr(n.mappings)})}function Ua(e,n,s,o,i,c,u,d,x){let{_mappings:g,_sources:_,_sourcesContent:w,_names:S}=n,v=Ha(g,s),j=Va(v,o);if(!i)return e&&Xa(v,j)?void 0:Lo(v,j,[o]);let U=Ro(_,i),b=d?Ro(S,d):Do;if(U===w.length&&(w[U]=x??null),!(e&&Ga(v,j,U,c,u,b)))return Lo(v,j,d?[o,U,c,u,b]:[o,U,c,u])}function Ha(e,n){for(let s=e.length;s<=n;s++)e[s]=[];return e[n]}function Va(e,n){let s=e.length;for(let o=s-1;o>=0;s=o--){let i=e[o];if(n>=i[Fa])break}return s}function Lo(e,n,s){for(let o=e.length;o>n;o--)e[o]=e[o-1];e[n]=s}function Wa(e){let{length:n}=e,s=n;for(let o=s-1;o>=0&&!(e[o].length>0);s=o,o--);s obj[importedName]}); - } - `,createStarExport:` - function createStarExport(obj) { - Object.keys(obj) - .filter((key) => key !== "default" && key !== "__esModule") - .forEach((key) => { - if (exports.hasOwnProperty(key)) { - return; - } - Object.defineProperty(exports, key, {enumerable: true, configurable: true, get: () => obj[key]}); - }); - } - `,nullishCoalesce:` - function nullishCoalesce(lhs, rhsFn) { - if (lhs != null) { - return lhs; - } else { - return rhsFn(); - } - } - `,asyncNullishCoalesce:` - async function asyncNullishCoalesce(lhs, rhsFn) { - if (lhs != null) { - return lhs; - } else { - return await rhsFn(); - } - } - `,optionalChain:` - function optionalChain(ops) { - let lastAccessLHS = undefined; - let value = ops[0]; - let i = 1; - while (i < ops.length) { - const op = ops[i]; - const fn = ops[i + 1]; - i += 2; - if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { - return undefined; - } - if (op === 'access' || op === 'optionalAccess') { - lastAccessLHS = value; - value = fn(value); - } else if (op === 'call' || op === 'optionalCall') { - value = fn((...args) => value.call(lastAccessLHS, ...args)); - lastAccessLHS = undefined; - } - } - return value; - } - `,asyncOptionalChain:` - async function asyncOptionalChain(ops) { - let lastAccessLHS = undefined; - let value = ops[0]; - let i = 1; - while (i < ops.length) { - const op = ops[i]; - const fn = ops[i + 1]; - i += 2; - if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { - return undefined; - } - if (op === 'access' || op === 'optionalAccess') { - lastAccessLHS = value; - value = await fn(value); - } else if (op === 'call' || op === 'optionalCall') { - value = await fn((...args) => value.call(lastAccessLHS, ...args)); - lastAccessLHS = undefined; - } - } - return value; - } - `,optionalChainDelete:` - function optionalChainDelete(ops) { - const result = OPTIONAL_CHAIN_NAME(ops); - return result == null ? true : result; - } - `,asyncOptionalChainDelete:` - async function asyncOptionalChainDelete(ops) { - const result = await ASYNC_OPTIONAL_CHAIN_NAME(ops); - return result == null ? true : result; - } - `},un=class e{__init(){this.helperNames={}}__init2(){this.createRequireName=null}constructor(n){this.nameManager=n,e.prototype.__init.call(this),e.prototype.__init2.call(this)}getHelperName(n){let s=this.helperNames[n];return s||(s=this.nameManager.claimFreeName(`_${n}`),this.helperNames[n]=s,s)}emitHelpers(){let n="";this.helperNames.optionalChainDelete&&this.getHelperName("optionalChain"),this.helperNames.asyncOptionalChainDelete&&this.getHelperName("asyncOptionalChain");for(let[s,o]of Object.entries(za)){let i=this.helperNames[s],c=o;s==="optionalChainDelete"?c=c.replace("OPTIONAL_CHAIN_NAME",this.helperNames.optionalChain):s==="asyncOptionalChainDelete"?c=c.replace("ASYNC_OPTIONAL_CHAIN_NAME",this.helperNames.asyncOptionalChain):s==="require"&&(this.createRequireName===null&&(this.createRequireName=this.nameManager.claimFreeName("_createRequire")),c=c.replace(/CREATE_REQUIRE_NAME/g,this.createRequireName)),i&&(n+=" ",n+=c.replace(s,i).replace(/\s+/g," ").trim())}return n}};function pn(e,n,s){Ka(e,s)&&Ya(e,n,s)}function Ka(e,n){for(let s of e.tokens)if(s.type===t.name&&!s.isType&&ko(s)&&n.has(e.identifierNameForToken(s)))return!0;return!1}function Ya(e,n,s){let o=[],i=n.length-1;for(let c=e.tokens.length-1;;c--){for(;o.length>0&&o[o.length-1].startTokenIndex===c+1;)o.pop();for(;i>=0&&n[i].endTokenIndex===c+1;)o.push(n[i]),i--;if(c<0)break;let u=e.tokens[c],d=e.identifierNameForToken(u);if(o.length>1&&!u.isType&&u.type===t.name&&s.has(d)){if(xo(u))jo(o[o.length-1],e,d);else if(go(u)){let x=o.length-1;for(;x>0&&!o[x].isFunctionScope;)x--;if(x<0)throw new Error("Did not find parent function scope.");jo(o[x],e,d)}}}if(o.length>0)throw new Error("Expected empty scope stack after processing file.")}function jo(e,n,s){for(let o=e.startTokenIndex;o0&&!r.error;)a(t.braceL)||a(t.bracketL)?e++:(a(t.braceR)||a(t.bracketR))&&e--,m();return!0}return!1}function zc(){let e=r.snapshot(),n=Kc();return r.restoreFromSnapshot(e),n}function Kc(){return m(),!!(a(t.parenR)||a(t.ellipsis)||Jc()&&(a(t.colon)||a(t.comma)||a(t.question)||a(t.eq)||a(t.parenR)&&(m(),a(t.arrow))))}function Pt(e){let n=N(0);h(e),Zc()||J(),P(n)}function Yc(){a(t.colon)&&Pt(t.colon)}function Me(){a(t.colon)&&ct()}function Qc(){f(t.colon)&&J()}function Zc(){let e=r.snapshot();return y(l._asserts)?(m(),K(l._is)?(J(),!0):Cr()||a(t._this)?(m(),K(l._is)&&J(),!0):(r.restoreFromSnapshot(e),!1)):Cr()||a(t._this)?(m(),y(l._is)&&!Z()?(m(),J(),!0):(r.restoreFromSnapshot(e),!1)):!1}function ct(){let e=N(0);h(t.colon),J(),P(e)}function J(){if(l1(),r.inDisallowConditionalTypesContext||Z()||!f(t._extends))return;let e=r.inDisallowConditionalTypesContext;r.inDisallowConditionalTypesContext=!0,l1(),r.inDisallowConditionalTypesContext=e,h(t.question),J(),h(t.colon),J()}function el(){return y(l._abstract)&&$()===t._new}function l1(){if(Gc()){vr(Le.TSFunctionType);return}if(a(t._new)){vr(Le.TSConstructorType);return}else if(el()){vr(Le.TSAbstractConstructorType);return}Xc()}function k1(){let e=N(1);J(),h(t.greaterThan),P(e),ut()}function x1(){if(f(t.jsxTagStart)){r.tokens[r.tokens.length-1].type=t.typeParameterStart;let e=N(1);for(;!a(t.greaterThan)&&!r.error;)J(),f(t.comma);pe(),P(e)}}function g1(){for(;!a(t.braceL)&&!r.error;)tl(),f(t.comma)}function tl(){Nt(),a(t.lessThan)&<()}function nl(){ge(!1),Oe(),f(t._extends)&&g1(),d1()}function sl(){ge(!1),Oe(),h(t.eq),J(),q()}function rl(){if(a(t.string)?De():E(),f(t.eq)){let e=r.tokens.length-1;Y(),r.tokens[e].rhsEndIndex=r.tokens.length}}function Lr(){for(ge(!1),h(t.braceL);!f(t.braceR)&&!r.error;)rl(),f(t.comma)}function Dr(){h(t.braceL),pt(t.braceR)}function Nr(){ge(!1),f(t.dot)?Nr():Dr()}function _1(){y(l._global)?E():a(t.string)?ke():A(),a(t.braceL)?Dr():q()}function xn(){it(),h(t.eq),il(),q()}function ol(){return y(l._require)&&$()===t.parenL}function il(){ol()?al():Nt()}function al(){X(l._require),h(t.parenL),a(t.string)||A(),De(),h(t.parenR)}function cl(){if(me())return!1;switch(r.type){case t._function:{let e=N(1);m();let n=r.start;return Ee(n,!0),P(e),!0}case t._class:{let e=N(1);return ve(!0,!1),P(e),!0}case t._const:if(a(t._const)&&rt(l._enum)){let e=N(1);return h(t._const),X(l._enum),r.tokens[r.tokens.length-1].type=t._enum,Lr(),P(e),!0}case t._var:case t._let:{let e=N(1);return Lt(r.type!==t._var),P(e),!0}case t.name:{let e=N(1),n=r.contextualKeyword,s=!1;return n===l._global?(_1(),s=!0):s=gn(n,!0),P(e),s}default:return!1}}function u1(){return gn(r.contextualKeyword,!0)}function ll(e){switch(e){case l._declare:{let n=r.tokens.length-1;if(cl())return r.tokens[n].type=t._declare,!0;break}case l._global:if(a(t.braceL))return Dr(),!0;break;default:return gn(e,!1)}return!1}function gn(e,n){switch(e){case l._abstract:if(at(n)&&a(t._class))return r.tokens[r.tokens.length-1].type=t._abstract,ve(!0,!1),!0;break;case l._enum:if(at(n)&&a(t.name))return r.tokens[r.tokens.length-1].type=t._enum,Lr(),!0;break;case l._interface:if(at(n)&&a(t.name)){let s=N(n?2:1);return nl(),P(s),!0}break;case l._module:if(at(n)){if(a(t.string)){let s=N(n?2:1);return _1(),P(s),!0}else if(a(t.name)){let s=N(n?2:1);return Nr(),P(s),!0}}break;case l._namespace:if(at(n)&&a(t.name)){let s=N(n?2:1);return Nr(),P(s),!0}break;case l._type:if(at(n)&&a(t.name)){let s=N(n?2:1);return sl(),P(s),!0}break;default:break}return!1}function at(e){return e?(m(),!0):!me()}function ul(){let e=r.snapshot();return kn(),Ae(),Yc(),h(t.arrow),r.error?(r.restoreFromSnapshot(e),!1):(qe(!0),!0)}function Or(){r.type===t.bitShiftL&&(r.pos-=1,C(t.lessThan)),lt()}function lt(){let e=N(0);for(h(t.lessThan);!a(t.greaterThan)&&!r.error;)J(),f(t.comma);e?(h(t.greaterThan),P(e)):(P(e),on(),h(t.greaterThan),r.tokens[r.tokens.length-1].isType=!0)}function Fr(){if(a(t.name))switch(r.contextualKeyword){case l._abstract:case l._declare:case l._enum:case l._interface:case l._module:case l._namespace:case l._type:return!0;default:break}return!1}function y1(e,n){if(a(t.colon)&&Pt(t.colon),!a(t.braceL)&&me()){let s=r.tokens.length-1;for(;s>=0&&(r.tokens[s].start>=e||r.tokens[s].type===t._default||r.tokens[s].type===t._export);)r.tokens[s].isType=!0,s--;return}qe(!1,n)}function I1(e,n,s){if(!Z()&&f(t.bang)){r.tokens[r.tokens.length-1].type=t.nonNullAssertion;return}if(a(t.lessThan)||a(t.bitShiftL)){let o=r.snapshot();if(!n&&jr()&&ul())return;if(Or(),!n&&f(t.parenL)?(r.tokens[r.tokens.length-1].subscriptStartIndex=e,_e()):a(t.backQuote)?_n():(r.type===t.greaterThan||r.type!==t.parenL&&r.type&t.IS_EXPRESSION_START&&!Z())&&A(),r.error)r.restoreFromSnapshot(o);else return}else!n&&a(t.questionDot)&&$()===t.lessThan&&(m(),r.tokens[e].isOptionalChainStart=!0,r.tokens[r.tokens.length-1].subscriptStartIndex=e,lt(),h(t.parenL),_e());Rt(e,n,s)}function T1(){if(f(t._import))return y(l._type)&&$()!==t.eq&&X(l._type),xn(),!0;if(f(t.eq))return Q(),q(),!0;if(K(l._as))return X(l._namespace),E(),q(),!0;if(y(l._type)){let e=$();(e===t.braceL||e===t.star)&&m()}return!1}function b1(){if(E(),a(t.comma)||a(t.braceR)){r.tokens[r.tokens.length-1].identifierRole=I.ImportDeclaration;return}if(E(),a(t.comma)||a(t.braceR)){r.tokens[r.tokens.length-1].identifierRole=I.ImportDeclaration,r.tokens[r.tokens.length-2].isType=!0,r.tokens[r.tokens.length-1].isType=!0;return}if(E(),a(t.comma)||a(t.braceR)){r.tokens[r.tokens.length-3].identifierRole=I.ImportAccess,r.tokens[r.tokens.length-1].identifierRole=I.ImportDeclaration;return}E(),r.tokens[r.tokens.length-3].identifierRole=I.ImportAccess,r.tokens[r.tokens.length-1].identifierRole=I.ImportDeclaration,r.tokens[r.tokens.length-4].isType=!0,r.tokens[r.tokens.length-3].isType=!0,r.tokens[r.tokens.length-2].isType=!0,r.tokens[r.tokens.length-1].isType=!0}function w1(){if(E(),a(t.comma)||a(t.braceR)){r.tokens[r.tokens.length-1].identifierRole=I.ExportAccess;return}if(E(),a(t.comma)||a(t.braceR)){r.tokens[r.tokens.length-1].identifierRole=I.ExportAccess,r.tokens[r.tokens.length-2].isType=!0,r.tokens[r.tokens.length-1].isType=!0;return}if(E(),a(t.comma)||a(t.braceR)){r.tokens[r.tokens.length-3].identifierRole=I.ExportAccess;return}E(),r.tokens[r.tokens.length-3].identifierRole=I.ExportAccess,r.tokens[r.tokens.length-4].isType=!0,r.tokens[r.tokens.length-3].isType=!0,r.tokens[r.tokens.length-2].isType=!0,r.tokens[r.tokens.length-1].isType=!0}function S1(){if(y(l._abstract)&&$()===t._class)return r.type=t._abstract,m(),ve(!0,!0),!0;if(y(l._interface)){let e=N(2);return gn(l._interface,!0),P(e),!0}return!1}function E1(){if(r.type===t._const){let e=we();if(e.type===t.name&&e.contextualKeyword===l._enum)return h(t._const),X(l._enum),r.tokens[r.tokens.length-1].type=t._enum,Lr(),!0}return!1}function A1(e){let n=r.tokens.length;vt([l._abstract,l._readonly,l._declare,l._static,l._override]);let s=r.tokens.length;if(m1()){let i=e?n-1:n;for(let c=i;c=k.length){A("Unterminated JSX contents");return}let s=k.charCodeAt(r.pos);if(s===p.lessThan||s===p.leftCurlyBrace){if(r.pos===r.start){if(s===p.lessThan){r.pos++,C(t.jsxTagStart);return}lr(s);return}e&&!n?C(t.jsxEmptyText):C(t.jsxText);return}s===p.lineFeed?e=!0:s!==p.space&&s!==p.carriageReturn&&s!==p.tab&&(n=!0),r.pos++}}function ml(e){for(r.pos++;;){if(r.pos>=k.length){A("Unterminated string constant");return}if(k.charCodeAt(r.pos)===e){r.pos++;break}r.pos++}C(t.string)}function dl(){let e;do{if(r.pos>k.length){A("Unexpectedly reached the end of input.");return}e=k.charCodeAt(++r.pos)}while(se[e]||e===p.dash);C(t.jsxName)}function Br(){pe()}function M1(e){if(Br(),!f(t.colon)){r.tokens[r.tokens.length-1].identifierRole=e;return}Br()}function B1(){let e=r.tokens.length;M1(I.Access);let n=!1;for(;a(t.dot);)n=!0,pe(),Br();if(!n){let s=r.tokens[e],o=k.charCodeAt(s.start);o>=p.lowercaseA&&o<=p.lowercaseZ&&(s.identifierRole=null)}}function kl(){switch(r.type){case t.braceL:m(),Q(),pe();return;case t.jsxTagStart:qr(),pe();return;case t.string:pe();return;default:A("JSX value should be either an expression or a quoted JSX text")}}function xl(){h(t.ellipsis),Q()}function gl(e){if(a(t.jsxTagEnd))return!1;B1(),D&&x1();let n=!1;for(;!a(t.slash)&&!a(t.jsxTagEnd)&&!r.error;){if(f(t.braceL)){n=!0,h(t.ellipsis),Y(),pe();continue}n&&r.end-r.start===3&&k.charCodeAt(r.start)===p.lowercaseK&&k.charCodeAt(r.start+1)===p.lowercaseE&&k.charCodeAt(r.start+2)===p.lowercaseY&&(r.tokens[e].jsxRole=le.KeyAfterPropSpread),M1(I.ObjectKey),a(t.eq)&&(pe(),kl())}let s=a(t.slash);return s&&pe(),s}function _l(){a(t.jsxTagEnd)||B1()}function q1(){let e=r.tokens.length-1;r.tokens[e].jsxRole=le.NoChildren;let n=0;if(!gl(e))for(ft();;)switch(r.type){case t.jsxTagStart:if(pe(),a(t.slash)){pe(),_l(),r.tokens[e].jsxRole!==le.KeyAfterPropSpread&&(n===1?r.tokens[e].jsxRole=le.OneChild:n>1&&(r.tokens[e].jsxRole=le.StaticChildren));return}n++,q1(),ft();break;case t.jsxText:n++,ft();break;case t.jsxEmptyText:ft();break;case t.braceL:m(),a(t.ellipsis)?(xl(),ft(),n+=2):(a(t.braceR)||(n++,Q()),ft());break;default:A();return}}function qr(){pe(),q1()}function pe(){r.tokens.push(new je),cr(),r.start=r.pos;let e=k.charCodeAt(r.pos);if(Se[e])dl();else if(e===p.quotationMark||e===p.apostrophe)ml(e);else switch(++r.pos,e){case p.greaterThan:C(t.jsxTagEnd);break;case p.lessThan:C(t.jsxTagStart);break;case p.slash:C(t.slash);break;case p.equalsTo:C(t.eq);break;case p.leftCurlyBrace:C(t.braceL);break;case p.dot:C(t.dot);break;case p.colon:C(t.colon);break;default:A()}}function ft(){r.tokens.push(new je),r.start=r.pos,hl()}function $1(e){if(a(t.question)){let n=$();if(n===t.colon||n===t.comma||n===t.parenR)return}$r(e)}function U1(){rn(t.question),a(t.colon)&&(D?ct():O&&Ce())}var Ur=class{constructor(n){this.stop=n}};function Q(e=!1){if(Y(e),a(t.comma))for(;f(t.comma);)Y(e)}function Y(e=!1,n=!1){return D?O1(e,n):O?Y1(e,n):de(e,n)}function de(e,n){if(a(t._yield))return Ol(),!1;(a(t.parenL)||a(t.name)||a(t._yield))&&(r.potentialArrowAt=r.start);let s=yl(e);return n&&Gr(),r.type&t.IS_ASSIGN?(m(),Y(e),!1):s}function yl(e){return Tl(e)?!0:(Il(e),!1)}function Il(e){D||O?$1(e):$r(e)}function $r(e){f(t.question)&&(Y(),h(t.colon),Y(e))}function Tl(e){let n=r.tokens.length;return ut()?!0:(yn(n,-1,e),!1)}function yn(e,n,s){if(D&&(t._in&t.PRECEDENCE_MASK)>n&&!Z()&&(K(l._as)||K(l._satisfies))){let i=N(1);J(),P(i),on(),yn(e,n,s);return}let o=r.type&t.PRECEDENCE_MASK;if(o>0&&(!s||!a(t._in))&&o>n){let i=r.type;m(),i===t.nullishCoalescing&&(r.tokens[r.tokens.length-1].nullishStartIndex=e);let c=r.tokens.length;ut(),yn(c,i&t.IS_RIGHT_ASSOCIATIVE?o-1:o,s),i===t.nullishCoalescing&&(r.tokens[e].numNullishCoalesceStarts++,r.tokens[r.tokens.length-1].numNullishCoalesceEnds++),yn(e,n,s)}}function ut(){if(D&&!st&&f(t.lessThan))return k1(),!1;if(y(l._module)&&or()===p.leftCurlyBrace&&!tn())return Fl(),!1;if(r.type&t.IS_PREFIX)return m(),ut(),!1;if(Hr())return!0;for(;r.type&t.IS_POSTFIX&&!ee();)r.type===t.preIncDec&&(r.type=t.postIncDec),m();return!1}function Hr(){let e=r.tokens.length;return ke()?!0:(Vr(e),r.tokens.length>e&&r.tokens[e].isOptionalChainStart&&(r.tokens[r.tokens.length-1].isOptionalChainEnd=!0),!1)}function Vr(e,n=!1){O?Z1(e,n):Wr(e,n)}function Wr(e,n=!1){let s=new Ur(!1);do bl(e,n,s);while(!s.stop&&!r.error)}function bl(e,n,s){D?I1(e,n,s):O?G1(e,n,s):Rt(e,n,s)}function Rt(e,n,s){if(!n&&f(t.doubleColon))Xr(),s.stop=!0,Vr(e,n);else if(a(t.questionDot)){if(r.tokens[e].isOptionalChainStart=!0,n&&$()===t.parenL){s.stop=!0;return}m(),r.tokens[r.tokens.length-1].subscriptStartIndex=e,f(t.bracketL)?(Q(),h(t.bracketR)):f(t.parenL)?_e():In()}else if(f(t.dot))r.tokens[r.tokens.length-1].subscriptStartIndex=e,In();else if(f(t.bracketL))r.tokens[r.tokens.length-1].subscriptStartIndex=e,Q(),h(t.bracketR);else if(!n&&a(t.parenL))if(jr()){let o=r.snapshot(),i=r.tokens.length;m(),r.tokens[r.tokens.length-1].subscriptStartIndex=e;let c=Fe();r.tokens[r.tokens.length-1].contextId=c,_e(),r.tokens[r.tokens.length-1].contextId=c,wl()&&(r.restoreFromSnapshot(o),s.stop=!0,r.scopeDepth++,Ae(),Sl(i))}else{m(),r.tokens[r.tokens.length-1].subscriptStartIndex=e;let o=Fe();r.tokens[r.tokens.length-1].contextId=o,_e(),r.tokens[r.tokens.length-1].contextId=o}else a(t.backQuote)?_n():s.stop=!0}function jr(){return r.tokens[r.tokens.length-1].contextualKeyword===l._async&&!ee()}function _e(){let e=!0;for(;!f(t.parenR)&&!r.error;){if(e)e=!1;else if(h(t.comma),f(t.parenR))break;W1(!1)}}function wl(){return a(t.colon)||a(t.arrow)}function Sl(e){D?D1():O&&K1(),h(t.arrow),ht(e)}function Xr(){let e=r.tokens.length;ke(),Vr(e,!0)}function ke(){if(f(t.modulo))return E(),!1;if(a(t.jsxText)||a(t.jsxEmptyText))return De(),!1;if(a(t.lessThan)&&st)return r.type=t.jsxTagStart,qr(),m(),!1;let e=r.potentialArrowAt===r.start;switch(r.type){case t.slash:case t.assign:yo();case t._super:case t._this:case t.regexp:case t.num:case t.bigint:case t.decimal:case t.string:case t._null:case t._true:case t._false:return m(),!1;case t._import:return m(),a(t.dot)&&(r.tokens[r.tokens.length-1].type=t.name,m(),E()),!1;case t.name:{let n=r.tokens.length,s=r.start,o=r.contextualKeyword;return E(),o===l._await?(Dl(),!1):o===l._async&&a(t._function)&&!ee()?(m(),Ee(s,!1),!1):e&&o===l._async&&!ee()&&a(t.name)?(r.scopeDepth++,ge(!1),h(t.arrow),ht(n),!0):a(t._do)&&!ee()?(m(),Pe(),!1):e&&!ee()&&a(t.arrow)?(r.scopeDepth++,mn(!1),h(t.arrow),ht(n),!0):(r.tokens[r.tokens.length-1].identifierRole=I.Access,!1)}case t._do:return m(),Pe(),!1;case t.parenL:return H1(e);case t.bracketL:return m(),V1(t.bracketR,!0),!1;case t.braceL:return Ct(!1,!1),!1;case t._function:return El(),!1;case t.at:Sn();case t._class:return ve(!1),!1;case t._new:return vl(),!1;case t.backQuote:return _n(),!1;case t.doubleColon:return m(),Xr(),!1;case t.hash:{let n=or();return Se[n]||n===p.backslash?In():m(),!1}default:return A(),!1}}function In(){f(t.hash),E()}function El(){let e=r.start;E(),f(t.dot)&&E(),Ee(e,!1)}function De(){m()}function Dt(){h(t.parenL),Q(),h(t.parenR)}function H1(e){let n=r.snapshot(),s=r.tokens.length;h(t.parenL);let o=!0;for(;!a(t.parenR)&&!r.error;){if(o)o=!1;else if(h(t.comma),a(t.parenR))break;if(a(t.ellipsis)){Ar(!1),Gr();break}else Y(!1,!0)}return h(t.parenR),e&&Al()&&Tn()?(r.restoreFromSnapshot(n),r.scopeDepth++,Ae(),Tn(),ht(s),r.error?(r.restoreFromSnapshot(n),H1(!1),!1):!0):!1}function Al(){return a(t.colon)||!ee()}function Tn(){return D?F1():O?Q1():f(t.arrow)}function Gr(){(D||O)&&U1()}function vl(){if(h(t._new),f(t.dot)){E();return}Cl(),O&&J1(),f(t.parenL)&&V1(t.parenR)}function Cl(){Xr(),f(t.questionDot)}function _n(){for(ye(),ye();!a(t.backQuote)&&!r.error;)h(t.dollarBraceL),Q(),ye(),ye();m()}function Ct(e,n){let s=Fe(),o=!0;for(m(),r.tokens[r.tokens.length-1].contextId=s;!f(t.braceR)&&!r.error;){if(o)o=!1;else if(h(t.comma),f(t.braceR))break;let i=!1;if(a(t.ellipsis)){let c=r.tokens.length;if(Er(),e&&(r.tokens.length===c+2&&mn(n),f(t.braceR)))break;continue}e||(i=f(t.star)),!e&&y(l._async)?(i&&A(),E(),a(t.colon)||a(t.parenL)||a(t.braceR)||a(t.eq)||a(t.comma)||(a(t.star)&&(m(),i=!0),Be(s))):Be(s),Ll(e,n,s)}r.tokens[r.tokens.length-1].contextId=s}function Pl(e){return!e&&(a(t.string)||a(t.num)||a(t.bracketL)||a(t.name)||!!(r.type&t.IS_KEYWORD))}function Nl(e,n){let s=r.start;return a(t.parenL)?(e&&A(),bn(s,!1),!0):Pl(e)?(Be(n),bn(s,!1),!0):!1}function Rl(e,n){if(f(t.colon)){e?St(n):Y(!1);return}let s;e?r.scopeDepth===0?s=I.ObjectShorthandTopLevelDeclaration:n?s=I.ObjectShorthandBlockScopedDeclaration:s=I.ObjectShorthandFunctionScopedDeclaration:s=I.ObjectShorthand,r.tokens[r.tokens.length-1].identifierRole=s,St(n,!0)}function Ll(e,n,s){D?N1():O&&z1(),Nl(e,s)||Rl(e,n)}function Be(e){O&&wn(),f(t.bracketL)?(r.tokens[r.tokens.length-1].contextId=e,Y(),h(t.bracketR),r.tokens[r.tokens.length-1].contextId=e):(a(t.num)||a(t.string)||a(t.bigint)||a(t.decimal)?ke():In(),r.tokens[r.tokens.length-1].identifierRole=I.ObjectKey,r.tokens[r.tokens.length-1].contextId=e)}function bn(e,n){let s=Fe();r.scopeDepth++;let o=r.tokens.length;Ae(n,s),Jr(e,s);let c=r.tokens.length;r.scopes.push(new ie(o,c,!0)),r.scopeDepth--}function ht(e){qe(!0);let n=r.tokens.length;r.scopes.push(new ie(e,n,!0)),r.scopeDepth--}function Jr(e,n=0){D?y1(e,n):O?X1(n):qe(!1,n)}function qe(e,n=0){e&&!a(t.braceL)?Y():Pe(!0,n)}function V1(e,n=!1){let s=!0;for(;!f(e)&&!r.error;){if(s)s=!1;else if(h(t.comma),f(e))break;W1(n)}}function W1(e){e&&a(t.comma)||(a(t.ellipsis)?(Er(),Gr()):a(t.question)?m():Y(!1,!0))}function E(){m(),r.tokens[r.tokens.length-1].type=t.name}function Dl(){ut()}function Ol(){m(),!a(t.semi)&&!ee()&&(f(t.star),Y())}function Fl(){X(l._module),h(t.braceL),pt(t.braceR)}function jl(e){return(e.type===t.name||!!(e.type&t.IS_KEYWORD))&&e.contextualKeyword!==l._from}function be(e){let n=N(0);h(e||t.colon),ce(),P(n)}function ei(){h(t.modulo),X(l._checks),f(t.parenL)&&(Q(),h(t.parenR))}function Yr(){let e=N(0);h(t.colon),a(t.modulo)?ei():(ce(),a(t.modulo)&&ei()),P(e)}function Ml(){m(),Qr(!0)}function Bl(){m(),E(),a(t.lessThan)&&xe(),h(t.parenL),Kr(),h(t.parenR),Yr(),q()}function zr(){a(t._class)?Ml():a(t._function)?Bl():a(t._var)?ql():K(l._module)?f(t.dot)?Hl():$l():y(l._type)?Vl():y(l._opaque)?Wl():y(l._interface)?Xl():a(t._export)?Ul():A()}function ql(){m(),ii(),q()}function $l(){for(a(t.string)?ke():E(),h(t.braceL);!a(t.braceR)&&!r.error;)a(t._import)?(m(),oo()):A();h(t.braceR)}function Ul(){h(t._export),f(t._default)?a(t._function)||a(t._class)?zr():(ce(),q()):a(t._var)||a(t._function)||a(t._class)||y(l._opaque)?zr():a(t.star)||a(t.braceL)||y(l._interface)||y(l._type)||y(l._opaque)?ro():A()}function Hl(){X(l._exports),Ce(),q()}function Vl(){m(),eo()}function Wl(){m(),to(!0)}function Xl(){m(),Qr()}function Qr(e=!1){if(Pn(),a(t.lessThan)&&xe(),f(t._extends))do En();while(!e&&f(t.comma));if(y(l._mixins)){m();do En();while(f(t.comma))}if(y(l._implements)){m();do En();while(f(t.comma))}An(e,!1,e)}function En(){si(!1),a(t.lessThan)&&$e()}function Zr(){Qr()}function Pn(){E()}function eo(){Pn(),a(t.lessThan)&&xe(),be(t.eq),q()}function to(e){X(l._type),Pn(),a(t.lessThan)&&xe(),a(t.colon)&&be(t.colon),e||be(t.eq),q()}function Gl(){wn(),ii(),f(t.eq)&&ce()}function xe(){let e=N(0);a(t.lessThan)||a(t.typeParameterStart)?m():A();do Gl(),a(t.greaterThan)||h(t.comma);while(!a(t.greaterThan)&&!r.error);h(t.greaterThan),P(e)}function $e(){let e=N(0);for(h(t.lessThan);!a(t.greaterThan)&&!r.error;)ce(),a(t.greaterThan)||h(t.comma);h(t.greaterThan),P(e)}function Jl(){if(X(l._interface),f(t._extends))do En();while(f(t.comma));An(!1,!1,!1)}function no(){a(t.num)||a(t.string)?ke():E()}function zl(){$()===t.colon?(no(),be()):ce(),h(t.bracketR),be()}function Kl(){no(),h(t.bracketR),h(t.bracketR),a(t.lessThan)||a(t.parenL)?so():(f(t.question),be())}function so(){for(a(t.lessThan)&&xe(),h(t.parenL);!a(t.parenR)&&!a(t.ellipsis)&&!r.error;)vn(),a(t.parenR)||h(t.comma);f(t.ellipsis)&&vn(),h(t.parenR),be()}function Yl(){so()}function An(e,n,s){let o;for(n&&a(t.braceBarL)?(h(t.braceBarL),o=t.braceBarR):(h(t.braceL),o=t.braceR);!a(o)&&!r.error;){if(s&&y(l._proto)){let i=$();i!==t.colon&&i!==t.question&&(m(),e=!1)}if(e&&y(l._static)){let i=$();i!==t.colon&&i!==t.question&&m()}if(wn(),f(t.bracketL))f(t.bracketL)?Kl():zl();else if(a(t.parenL)||a(t.lessThan))Yl();else{if(y(l._get)||y(l._set)){let i=$();(i===t.name||i===t.string||i===t.num)&&m()}Ql()}Zl()}h(o)}function Ql(){if(a(t.ellipsis)){if(h(t.ellipsis),f(t.comma)||f(t.semi),a(t.braceR))return;ce()}else no(),a(t.lessThan)||a(t.parenL)?so():(f(t.question),be())}function Zl(){!f(t.semi)&&!f(t.comma)&&!a(t.braceR)&&!a(t.braceBarR)&&A()}function si(e){for(e||E();f(t.dot);)E()}function eu(){si(!0),a(t.lessThan)&&$e()}function tu(){h(t._typeof),ri()}function nu(){for(h(t.bracketL);r.pos0&&n0?this.tokens[this.tokenIndex-1].end:0,this.tokenIndex0&&this.tokenAtRelativeIndex(-1).type===t._delete?n.isAsyncOperation?this.resultCode+=this.helperManager.getHelperName("asyncOptionalChainDelete"):this.resultCode+=this.helperManager.getHelperName("optionalChainDelete"):n.isAsyncOperation?this.resultCode+=this.helperManager.getHelperName("asyncOptionalChain"):this.resultCode+=this.helperManager.getHelperName("optionalChain"),this.resultCode+="([")}}appendTokenSuffix(){let n=this.currentToken();if(n.isOptionalChainEnd&&!this.disableESTransforms&&(this.resultCode+="])"),n.numNullishCoalesceEnds&&!this.disableESTransforms)for(let s=0;s ${s}require`);let o=this.tokens.currentToken().contextId;if(o==null)throw new Error("Expected context ID on dynamic import invocation.");for(this.tokens.copyToken();!this.tokens.matchesContextIdAndLabel(t.parenR,o);)this.rootTransformer.processToken();this.tokens.replaceToken(s?")))":"))");return}if(this.removeImportAndDetectIfShouldElide())this.tokens.removeToken();else{let s=this.tokens.stringValue();this.tokens.replaceTokenTrimmingLeftWhitespace(this.importProcessor.claimImportCode(s)),this.tokens.appendCode(this.importProcessor.claimImportCode(s))}Ne(this.tokens),this.tokens.matches1(t.semi)&&this.tokens.removeToken()}removeImportAndDetectIfShouldElide(){if(this.tokens.removeInitialToken(),this.tokens.matchesContextual(l._type)&&!this.tokens.matches1AtIndex(this.tokens.currentIndex()+1,t.comma)&&!this.tokens.matchesContextualAtIndex(this.tokens.currentIndex()+1,l._from))return this.removeRemainingImport(),!0;if(this.tokens.matches1(t.name)||this.tokens.matches1(t.star))return this.removeRemainingImport(),!1;if(this.tokens.matches1(t.string))return!1;let n=!1,s=!1;for(;!this.tokens.matches1(t.string);)(!n&&this.tokens.matches1(t.braceL)||this.tokens.matches1(t.comma))&&(this.tokens.removeToken(),this.tokens.matches1(t.braceR)||(s=!0),(this.tokens.matches2(t.name,t.comma)||this.tokens.matches2(t.name,t.braceR)||this.tokens.matches4(t.name,t.name,t.name,t.comma)||this.tokens.matches4(t.name,t.name,t.name,t.braceR))&&(n=!0)),this.tokens.removeToken();return this.keepUnusedImports?!1:this.isTypeScriptTransformEnabled?!n:this.isFlowTransformEnabled?s&&!n:!1}removeRemainingImport(){for(;!this.tokens.matches1(t.string);)this.tokens.removeToken()}processIdentifier(){let n=this.tokens.currentToken();if(n.shadowsGlobal)return!1;if(n.identifierRole===I.ObjectShorthand)return this.processObjectShorthand();if(n.identifierRole!==I.Access)return!1;let s=this.importProcessor.getIdentifierReplacement(this.tokens.identifierNameForToken(n));if(!s)return!1;let o=this.tokens.currentIndex()+1;for(;o=2&&this.tokens.matches1AtIndex(n-2,t.dot)||n>=2&&[t._var,t._let,t._const].includes(this.tokens.tokens[n-2].type))return!1;let o=this.importProcessor.resolveExportBinding(this.tokens.identifierNameForToken(s));return o?(this.tokens.copyToken(),this.tokens.appendCode(` ${o} =`),!0):!1}processComplexAssignment(){let n=this.tokens.currentIndex(),s=this.tokens.tokens[n-1];if(s.type!==t.name||s.shadowsGlobal||n>=2&&this.tokens.matches1AtIndex(n-2,t.dot))return!1;let o=this.importProcessor.resolveExportBinding(this.tokens.identifierNameForToken(s));return o?(this.tokens.appendCode(` = ${o}`),this.tokens.copyToken(),!0):!1}processPreIncDec(){let n=this.tokens.currentIndex(),s=this.tokens.tokens[n+1];if(s.type!==t.name||s.shadowsGlobal||n+2=1&&this.tokens.matches1AtIndex(n-1,t.dot))return!1;let i=this.tokens.identifierNameForToken(s),c=this.importProcessor.resolveExportBinding(i);if(!c)return!1;let u=this.tokens.rawCodeForToken(o),d=this.importProcessor.getIdentifierReplacement(i)||i;if(u==="++")this.tokens.replaceToken(`(${d} = ${c} = ${d} + 1, ${d} - 1)`);else if(u==="--")this.tokens.replaceToken(`(${d} = ${c} = ${d} - 1, ${d} + 1)`);else throw new Error(`Unexpected operator: ${u}`);return this.tokens.removeToken(),!0}processExportDefault(){let n=!0;if(this.tokens.matches4(t._export,t._default,t._function,t.name)||this.tokens.matches5(t._export,t._default,t.name,t._function,t.name)&&this.tokens.matchesContextualAtIndex(this.tokens.currentIndex()+2,l._async)){this.tokens.removeInitialToken(),this.tokens.removeToken();let s=this.processNamedFunction();this.tokens.appendCode(` exports.default = ${s};`)}else if(this.tokens.matches4(t._export,t._default,t._class,t.name)||this.tokens.matches5(t._export,t._default,t._abstract,t._class,t.name)||this.tokens.matches3(t._export,t._default,t.at)){this.tokens.removeInitialToken(),this.tokens.removeToken(),this.copyDecorators(),this.tokens.matches1(t._abstract)&&this.tokens.removeToken();let s=this.rootTransformer.processNamedClass();this.tokens.appendCode(` exports.default = ${s};`)}else if($t(this.isTypeScriptTransformEnabled,this.keepUnusedImports,this.tokens,this.declarationInfo))n=!1,this.tokens.removeInitialToken(),this.tokens.removeToken(),this.tokens.removeToken();else if(this.reactHotLoaderTransformer){let s=this.nameManager.claimFreeName("_default");this.tokens.replaceToken(`let ${s}; exports.`),this.tokens.copyToken(),this.tokens.appendCode(` = ${s} =`),this.reactHotLoaderTransformer.setExtractedDefaultExportName(s)}else this.tokens.replaceToken("exports."),this.tokens.copyToken(),this.tokens.appendCode(" =");n&&(this.hadDefaultExport=!0)}copyDecorators(){for(;this.tokens.matches1(t.at);)if(this.tokens.copyToken(),this.tokens.matches1(t.parenL))this.tokens.copyExpectedToken(t.parenL),this.rootTransformer.processBalancedCode(),this.tokens.copyExpectedToken(t.parenR);else{for(this.tokens.copyExpectedToken(t.name);this.tokens.matches1(t.dot);)this.tokens.copyExpectedToken(t.dot),this.tokens.copyExpectedToken(t.name);this.tokens.matches1(t.parenL)&&(this.tokens.copyExpectedToken(t.parenL),this.rootTransformer.processBalancedCode(),this.tokens.copyExpectedToken(t.parenR))}}processExportVar(){this.isSimpleExportVar()?this.processSimpleExportVar():this.processComplexExportVar()}isSimpleExportVar(){let n=this.tokens.currentIndex();if(n++,n++,!this.tokens.matches1AtIndex(n,t.name))return!1;for(n++;ns.call(n,...u)),n=void 0)}return s}var Fn="jest",Ku=["mock","unmock","enableAutomock","disableAutomock"],Wt=class e extends G{__init(){this.hoistedFunctionNames=[]}constructor(n,s,o,i){super(),this.rootTransformer=n,this.tokens=s,this.nameManager=o,this.importProcessor=i,e.prototype.__init.call(this)}process(){return this.tokens.currentToken().scopeDepth===0&&this.tokens.matches4(t.name,t.dot,t.name,t.parenL)&&this.tokens.identifierName()===Fn?zu([this,"access",n=>n.importProcessor,"optionalAccess",n=>n.getGlobalNames,"call",n=>n(),"optionalAccess",n=>n.has,"call",n=>n(Fn)])?!1:this.extractHoistedCalls():!1}getHoistedCode(){return this.hoistedFunctionNames.length>0?this.hoistedFunctionNames.map(n=>`${n}();`).join(""):""}extractHoistedCalls(){this.tokens.removeToken();let n=!1;for(;this.tokens.matches3(t.dot,t.name,t.parenL);){let s=this.tokens.identifierNameAtIndex(this.tokens.currentIndex()+1);if(Ku.includes(s)){let i=this.nameManager.claimFreeName("__jestHoist");this.hoistedFunctionNames.push(i),this.tokens.replaceToken(`function ${i}(){${Fn}.`),this.tokens.copyToken(),this.tokens.copyToken(),this.rootTransformer.processBalancedCode(),this.tokens.copyExpectedToken(t.parenR),this.tokens.appendCode(";}"),n=!1}else n?this.tokens.copyToken():this.tokens.replaceToken(`${Fn}.`),this.tokens.copyToken(),this.tokens.copyToken(),this.rootTransformer.processBalancedCode(),this.tokens.copyExpectedToken(t.parenR),n=!0}return!0}};var Xt=class extends G{constructor(n){super(),this.tokens=n}process(){if(this.tokens.matches1(t.num)){let n=this.tokens.currentTokenCode();if(n.includes("_"))return this.tokens.replaceToken(n.replace(/_/g,"")),!0}return!1}};var Gt=class extends G{constructor(n,s){super(),this.tokens=n,this.nameManager=s}process(){return this.tokens.matches2(t._catch,t.braceL)?(this.tokens.copyToken(),this.tokens.appendCode(` (${this.nameManager.claimFreeName("e")})`),!0):!1}};var Jt=class extends G{constructor(n,s){super(),this.tokens=n,this.nameManager=s}process(){if(this.tokens.matches1(t.nullishCoalescing)){let o=this.tokens.currentToken();return this.tokens.tokens[o.nullishStartIndex].isAsyncOperation?this.tokens.replaceTokenTrimmingLeftWhitespace(", async () => ("):this.tokens.replaceTokenTrimmingLeftWhitespace(", () => ("),!0}if(this.tokens.matches1(t._delete)&&this.tokens.tokenAtRelativeIndex(1).isOptionalChainStart)return this.tokens.removeInitialToken(),!0;let s=this.tokens.currentToken().subscriptStartIndex;if(s!=null&&this.tokens.tokens[s].isOptionalChainStart&&this.tokens.tokenAtRelativeIndex(-1).type!==t._super){let o=this.nameManager.claimFreeName("_"),i;if(s>0&&this.tokens.matches1AtIndex(s-1,t._delete)&&this.isLastSubscriptInChain()?i=`${o} => delete ${o}`:i=`${o} => ${o}`,this.tokens.tokens[s].isAsyncOperation&&(i=`async ${i}`),this.tokens.matches2(t.questionDot,t.parenL)||this.tokens.matches2(t.questionDot,t.lessThan))this.justSkippedSuper()&&this.tokens.appendCode(".bind(this)"),this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalCall', ${i}`);else if(this.tokens.matches2(t.questionDot,t.bracketL))this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalAccess', ${i}`);else if(this.tokens.matches1(t.questionDot))this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'optionalAccess', ${i}.`);else if(this.tokens.matches1(t.dot))this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'access', ${i}.`);else if(this.tokens.matches1(t.bracketL))this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'access', ${i}[`);else if(this.tokens.matches1(t.parenL))this.justSkippedSuper()&&this.tokens.appendCode(".bind(this)"),this.tokens.replaceTokenTrimmingLeftWhitespace(`, 'call', ${i}(`);else throw new Error("Unexpected subscript operator in optional chain.");return!0}return!1}isLastSubscriptInChain(){let n=0;for(let s=this.tokens.currentIndex()+1;;s++){if(s>=this.tokens.tokens.length)throw new Error("Reached the end of the code while finding the end of the access chain.");if(this.tokens.tokens[s].isOptionalChainStart?n++:this.tokens.tokens[s].isOptionalChainEnd&&n--,n<0)return!0;if(n===0&&this.tokens.tokens[s].subscriptStartIndex!=null)return!1}}justSkippedSuper(){let n=0,s=this.tokens.currentIndex()-1;for(;;){if(s<0)throw new Error("Reached the start of the code while finding the start of the access chain.");if(this.tokens.tokens[s].isOptionalChainStart?n--:this.tokens.tokens[s].isOptionalChainEnd&&n++,n<0)return!1;if(n===0&&this.tokens.tokens[s].subscriptStartIndex!=null)return this.tokens.tokens[s-1].type===t._super;s--}}};var zt=class extends G{constructor(n,s,o,i){super(),this.rootTransformer=n,this.tokens=s,this.importProcessor=o,this.options=i}process(){let n=this.tokens.currentIndex();if(this.tokens.identifierName()==="createReactClass"){let s=this.importProcessor&&this.importProcessor.getIdentifierReplacement("createReactClass");return s?this.tokens.replaceToken(`(0, ${s})`):this.tokens.copyToken(),this.tryProcessCreateClassCall(n),!0}if(this.tokens.matches3(t.name,t.dot,t.name)&&this.tokens.identifierName()==="React"&&this.tokens.identifierNameAtIndex(this.tokens.currentIndex()+2)==="createClass"){let s=this.importProcessor&&this.importProcessor.getIdentifierReplacement("React")||"React";return s?(this.tokens.replaceToken(s),this.tokens.copyToken(),this.tokens.copyToken()):(this.tokens.copyToken(),this.tokens.copyToken(),this.tokens.copyToken()),this.tryProcessCreateClassCall(n),!0}return!1}tryProcessCreateClassCall(n){let s=this.findDisplayName(n);s&&this.classNeedsDisplayName()&&(this.tokens.copyExpectedToken(t.parenL),this.tokens.copyExpectedToken(t.braceL),this.tokens.appendCode(`displayName: '${s}',`),this.rootTransformer.processBalancedCode(),this.tokens.copyExpectedToken(t.braceR),this.tokens.copyExpectedToken(t.parenR))}findDisplayName(n){return n<2?null:this.tokens.matches2AtIndex(n-2,t.name,t.eq)?this.tokens.identifierNameAtIndex(n-2):n>=2&&this.tokens.tokens[n-2].identifierRole===I.ObjectKey?this.tokens.identifierNameAtIndex(n-2):this.tokens.matches2AtIndex(n-2,t._export,t._default)?this.getDisplayNameFromFilename():null}getDisplayNameFromFilename(){let s=(this.options.filePath||"unknown").split("/"),o=s[s.length-1],i=o.lastIndexOf("."),c=i===-1?o:o.slice(0,i);return c==="index"&&s[s.length-2]?s[s.length-2]:c}classNeedsDisplayName(){let n=this.tokens.currentIndex();if(!this.tokens.matches2(t.parenL,t.braceL))return!1;let s=n+1,o=this.tokens.tokens[s].contextId;if(o==null)throw new Error("Expected non-null context ID on object open-brace.");for(;n({variableName:o,uniqueLocalName:o}));return this.extractedDefaultExportName&&s.push({variableName:this.extractedDefaultExportName,uniqueLocalName:"default"}),` -;(function () { - var reactHotLoader = require('react-hot-loader').default; - var leaveModule = require('react-hot-loader').leaveModule; - if (!reactHotLoader) { - return; - } -${s.map(({variableName:o,uniqueLocalName:i})=>` reactHotLoader.register(${o}, "${i}", ${JSON.stringify(this.filePath||"")});`).join(` -`)} - leaveModule(module); -})();`}process(){return!1}};var Yu=new Set(["break","case","catch","class","const","continue","debugger","default","delete","do","else","export","extends","finally","for","function","if","import","in","instanceof","new","return","super","switch","this","throw","try","typeof","var","void","while","with","yield","enum","implements","interface","let","package","private","protected","public","static","await","false","null","true"]);function jn(e){if(e.length===0||!Se[e.charCodeAt(0)])return!1;for(let n=1;n` var ${u};`).join("");for(let u of this.transformers)s+=u.getHoistedCode();let o="";for(let u of this.transformers)o+=u.getSuffixCode();let i=this.tokens.finish(),{code:c}=i;if(c.startsWith("#!")){let u=c.indexOf(` -`);return u===-1&&(u=c.length,c+=` -`),{code:c.slice(0,u+1)+s+c.slice(u+1)+o,mappings:this.shiftMappings(i.mappings,s.length)}}else return{code:s+c+o,mappings:this.shiftMappings(i.mappings,s.length)}}processBalancedCode(){let n=0,s=0;for(;!this.tokens.isAtEnd();){if(this.tokens.matches1(t.braceL)||this.tokens.matches1(t.dollarBraceL))n++;else if(this.tokens.matches1(t.braceR)){if(n===0)return;n--}if(this.tokens.matches1(t.parenL))s++;else if(this.tokens.matches1(t.parenR)){if(s===0)return;s--}this.processToken()}}processToken(){if(this.tokens.matches1(t._class)){this.processClass();return}for(let n of this.transformers)if(n.process())return;this.tokens.copyToken()}processNamedClass(){if(!this.tokens.matches2(t._class,t.name))throw new Error("Expected identifier for exported class name.");let n=this.tokens.identifierNameAtIndex(this.tokens.currentIndex()+1);return this.processClass(),n}processClass(){let n=lo(this,this.tokens,this.nameManager,this.disableESTransforms),s=(n.headerInfo.isExpression||!n.headerInfo.className)&&n.staticInitializerNames.length+n.instanceInitializerNames.length>0,o=n.headerInfo.className;s&&(o=this.nameManager.claimFreeName("_class"),this.generatedVariables.push(o),this.tokens.appendCode(` (${o} =`));let c=this.tokens.currentToken().contextId;if(c==null)throw new Error("Expected class to have a context ID.");for(this.tokens.copyExpectedToken(t._class);!this.tokens.matchesContextIdAndLabel(t.braceL,c);)this.processToken();this.processClassBody(n,o);let u=n.staticInitializerNames.map(d=>`${o}.${d}()`);s?this.tokens.appendCode(`, ${u.map(d=>`${d}, `).join("")}${o})`):n.staticInitializerNames.length>0&&this.tokens.appendCode(` ${u.map(d=>`${d};`).join(" ")}`)}processClassBody(n,s){let{headerInfo:o,constructorInsertPos:i,constructorInitializerStatements:c,fields:u,instanceInitializerNames:d,rangesToRemove:x}=n,g=0,_=0,w=this.tokens.currentToken().contextId;if(w==null)throw new Error("Expected non-null context ID on class.");this.tokens.copyExpectedToken(t.braceL),this.isReactHotLoaderTransformEnabled&&this.tokens.appendCode("__reactstandin__regenerateByEval(key, code) {this[key] = eval(code);}");let S=c.length+d.length>0;if(i===null&&S){let v=this.makeConstructorInitCode(c,d,s);if(o.hasSuperclass){let j=this.nameManager.claimFreeName("args");this.tokens.appendCode(`constructor(...${j}) { super(...${j}); ${v}; }`)}else this.tokens.appendCode(`constructor() { ${v}; }`)}for(;!this.tokens.matchesContextIdAndLabel(t.braceR,w);)if(g=x[_].start){for(this.tokens.currentIndex()`${o}.prototype.${i}.call(this)`)].join(";")}processPossibleArrowParamEnd(){if(this.tokens.matches2(t.parenR,t.colon)&&this.tokens.tokenAtRelativeIndex(1).isType){let n=this.tokens.currentIndex()+1;for(;this.tokens.tokens[n].isType;)n++;if(this.tokens.matches1AtIndex(n,t.arrow)){for(this.tokens.removeInitialToken();this.tokens.currentIndex()"),!0}}return!1}processPossibleAsyncArrowWithTypeParams(){if(!this.tokens.matchesContextual(l._async)&&!this.tokens.matches1(t._async))return!1;let n=this.tokens.tokenAtRelativeIndex(1);if(n.type!==t.lessThan||!n.isType)return!1;let s=this.tokens.currentIndex()+1;for(;this.tokens.tokens[s].isType;)s++;if(this.tokens.matches1AtIndex(s,t.parenL)){for(this.tokens.replaceToken("async ("),this.tokens.removeInitialToken();this.tokens.currentIndex(){if(e.length!==0)return e.length===1?e[0]:e};globalThis.__frameosFragment=Bi;globalThis.__frameosJsx=(e,n,...s)=>{let o=n?{...n}:{},i=sp(s),c=Object.prototype.hasOwnProperty.call(o,"children")?o.children:void 0;Object.prototype.hasOwnProperty.call(o,"children")&&delete o.children;let u=i??c;return e===Bi?u??null:(u!==void 0&&(o.children=u),{type:e,props:o})};globalThis.__frameosTranspile=(e,n={})=>Mi(e,{filePath:n.filePath??"",transforms:n.transforms??np,jsxRuntime:"classic",jsxPragma:"__frameosJsx",jsxFragmentPragma:"__frameosFragment",production:!0}).code;})(); diff --git a/frameos/frameos.nimble b/frameos/frameos.nimble index 58c8299a6..66b47edf5 100644 --- a/frameos/frameos.nimble +++ b/frameos/frameos.nimble @@ -40,11 +40,11 @@ task build_quickjs, "Build QuickJS": echo "Using prebuilt QuickJS." return echo "Downloading and building QuickJS from source..." - exec "curl -L -o quickjs.tar.xz https://bellard.org/quickjs/quickjs-2025-04-26.tar.xz" - exec "echo '2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf quickjs.tar.xz' | sha256sum -c -" + exec "curl -L -o quickjs.tar.xz https://bellard.org/quickjs/quickjs-2026-06-04.tar.xz" + exec "echo 'b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a quickjs.tar.xz' | sha256sum -c -" exec "tar -xf quickjs.tar.xz" exec "rm quickjs.tar.xz" - exec "mv quickjs-2025-04-26 quickjs" + exec "mv quickjs-2026-06-04 quickjs" exec "cd quickjs && make" task test, "Run tests": diff --git a/frameos/frontend/build.mjs b/frameos/frontend/build.mjs index 4802adf08..0e9f67b38 100644 --- a/frameos/frontend/build.mjs +++ b/frameos/frontend/build.mjs @@ -17,14 +17,11 @@ const isWatch = process.argv.includes('--watch') const outputDir = path.resolve(__dirname, '../assets/compiled/frame_web') const staticDir = path.join(outputDir, 'static') -const vendorOutputDir = path.resolve(__dirname, '../assets/compiled/vendor') await import('../../frontend/scripts/generateRepoApps.mjs') await fs.rm(outputDir, { recursive: true, force: true }) await fs.mkdir(staticDir, { recursive: true }) -await fs.rm(vendorOutputDir, { recursive: true, force: true }) -await fs.mkdir(vendorOutputDir, { recursive: true }) await fs.copyFile(path.resolve(__dirname, 'src/index.html'), path.join(outputDir, 'index.html')) const postcssPlugins = [ @@ -134,35 +131,9 @@ const buildOptions = { } if (isWatch) { - const vendorBuildContext = await context({ - absWorkingDir: __dirname, - entryPoints: ['src/sucrase.ts'], - bundle: true, - format: 'iife', - globalName: '__frameosSucraseBundle', - platform: 'browser', - target: ['es2020'], - minify: true, - write: true, - outfile: path.join(vendorOutputDir, 'sucrase.js'), - }) const buildContext = await context(buildOptions) - await Promise.all([vendorBuildContext.watch(), buildContext.watch()]) + await buildContext.watch() console.log(`👀 Watching ${staticDir}`) } else { - await Promise.all([ - build({ - absWorkingDir: __dirname, - entryPoints: ['src/sucrase.ts'], - bundle: true, - format: 'iife', - globalName: '__frameosSucraseBundle', - platform: 'browser', - target: ['es2020'], - minify: true, - write: true, - outfile: path.join(vendorOutputDir, 'sucrase.js'), - }), - build(buildOptions), - ]) + await build(buildOptions) } diff --git a/frameos/frontend/src/sucrase.ts b/frameos/frontend/src/sucrase.ts deleted file mode 100644 index bb6c8469b..000000000 --- a/frameos/frontend/src/sucrase.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { transform } from 'sucrase' - -type TransformName = 'jsx' | 'typescript' | 'imports' - -interface TranspileOptions { - filePath?: string - transforms?: TransformName[] -} - -const defaultTransforms: TransformName[] = ['typescript', 'jsx'] - -const fragmentMarker = Symbol.for('frameos.fragment') - -const normalizeChildren = (children: unknown[]): unknown => { - if (children.length === 0) { - return undefined - } - if (children.length === 1) { - return children[0] - } - return children -} - -;(globalThis as typeof globalThis & Record).__frameosFragment = fragmentMarker -;(globalThis as typeof globalThis & Record).__frameosJsx = ( - type: unknown, - props: Record | null, - ...children: unknown[] -): unknown => { - const nextProps = props ? { ...props } : {} - const explicitChildren = normalizeChildren(children) - const propChildren = Object.prototype.hasOwnProperty.call(nextProps, 'children') ? nextProps.children : undefined - - if (Object.prototype.hasOwnProperty.call(nextProps, 'children')) { - delete nextProps.children - } - - const normalizedChildren = explicitChildren ?? propChildren - if (type === fragmentMarker) { - return normalizedChildren ?? null - } - - if (normalizedChildren !== undefined) { - nextProps.children = normalizedChildren - } - - return { - type, - props: nextProps, - } -} - -;(globalThis as typeof globalThis & Record).__frameosTranspile = ( - code: string, - options: TranspileOptions = {} -): string => { - const result = transform(code, { - filePath: options.filePath ?? '', - transforms: options.transforms ?? defaultTransforms, - jsxRuntime: 'classic', - jsxPragma: '__frameosJsx', - jsxFragmentPragma: '__frameosFragment', - production: true, - }) - return result.code -} diff --git a/frameos/src/frameos/interpreter.nim b/frameos/src/frameos/interpreter.nim index 4eabd9125..da9d12fe6 100644 --- a/frameos/src/frameos/interpreter.nim +++ b/frameos/src/frameos/interpreter.nim @@ -1,7 +1,7 @@ import frameos/types import frameos/values -import frameos/js_runtime -import frameos/js_app_runtime +import frameos/js_runtime/app_runtime +import frameos/js_runtime/runtime import frameos/channels import frameos/runtime_diagnostics import tables, json, os, zippy, chroma, pixie, jsony, sequtils, options, strutils, times diff --git a/frameos/src/frameos/js_runtime/README.md b/frameos/src/frameos/js_runtime/README.md new file mode 100644 index 000000000..d5e1356dc --- /dev/null +++ b/frameos/src/frameos/js_runtime/README.md @@ -0,0 +1,198 @@ +# FrameOS JavaScript Runtime + +This directory contains the JavaScript support used by the on-device FrameOS +runtime. It covers two related paths: + +- Scene snippets and code nodes, compiled by `runtime.nim`. +- Repository JavaScript apps, compiled and hosted by `app_runtime.nim`. + +Both paths end by passing JavaScript directly to the bundled QuickJS engine. The +native transpiler therefore only removes or rewrites syntax that QuickJS cannot +run in the form FrameOS receives it. Modern JavaScript that the bundled QuickJS +accepts should pass through unchanged. + +## File Origins and Licenses + +FrameOS is licensed under the repository license, AGPL-3.0. Most files in this +directory are FrameOS source files. `burrito.nim` is the exception: it is copied +from Burrito under MIT and modified locally. The file-level lineage is below. + +### FrameOS Runtime Files + +- `runtime.nim` is FrameOS code extracted from the older interpreter runtime. + It owns the scene-snippet bridge to QuickJS: context setup, global helpers, + state/args/context proxies, console logging, JSX runtime helpers, runtime + value conversion, source-location registration, and cleanup. +- `app_runtime.nim` is FrameOS code for repo-provided JavaScript apps. It wraps + an app module, exposes the `frameos` app API to QuickJS, manages app lifecycle + calls, handles image references, and converts JS return values back to + FrameOS values. +- `source_map.nim` is FrameOS code. It is not a standard source-map generator. + It stores a compact generated line/column table that is enough to rewrite + QuickJS compile/runtime error locations back to the original app or snippet + source. + +### Native Sucrase-Compatible Port + +- `tokens.nim`, `parser.nim`, `token_processor.nim`, and `transpiler.nim` are + FrameOS code written as a native Nim reimplementation of the subset of + Sucrase needed by FrameOS. +- The public shape intentionally mirrors Sucrase concepts such as + `TransformOptions`, `TransformResult`, token labels, parser annotations, and + `TokenProcessor`-style rewriting so behavior can be compared against + upstream Sucrase. +- The implementation is not a vendored copy of Sucrase. It is a compatibility + slice designed for single-file FrameOS apps/snippets and for the QuickJS + execution target. + +Sucrase attribution: + +- Upstream project: https://github.com/alangpierce/sucrase +- Version used for parity and dependency reference: `3.35.1` from + `frameos/frontend/package.json` and `pnpm-lock.yaml`. +- License: MIT. +- Copyright notice used by Sucrase: `Copyright (c) 2012-2018 various + contributors (see AUTHORS)`. +- Primary author/project maintainer attribution: Alan Pierce and Sucrase + contributors. +- Sucrase also credits Babel/Babylon and Acorn ancestry. Babel/Babylon and + Acorn contributors should be preserved in attribution when copying concepts + from those parser layers through Sucrase. + +The native port started from the runtime need to remove the QuickJS-hosted +Sucrase compiler bundle from devices. During development, upstream-style +fixtures were checked against npm Sucrase through +`tools/tests/test_native_js_transpiler_parity.py`, while the native CLI in +`tools/native_js_transpile.nim` exposed `script`, `module`, `tokens`, and +`parse` modes for parity tests and diagnostics. + +### QuickJS and Burrito + +The JS engine and Nim binding are outside this directory but are part of this +runtime stack: + +- `burrito.nim` is copied from + https://github.com/tapsterbot/burrito/blob/main/src/burrito/qjs.nim and then + adjusted for FrameOS build/runtime needs. Burrito is MIT licensed with + copyright attribution to Tapster Robotics, Inc. +- QuickJS is downloaded/built by `frameos.nimble` as `quickjs-2026-06-04`. + QuickJS is MIT licensed with copyright attribution to Fabrice Bellard and + Charlie Gordon. + +FrameOS uses Burrito as the thin Nim/QuickJS FFI layer. The code in this +directory deliberately keeps most app/snippet semantics in FrameOS code and +uses QuickJS only to execute the resulting JavaScript. + +## Runtime Responsibilities + +`runtime.nim` handles interpreted scene JavaScript: + +- Creates one QuickJS context per interpreted scene. +- Registers Nim bridge functions exposed to JS: `getState`, `getArg`, + `getContext`, `jsLog`, `parseTs`, `format`, and `now`. +- Installs global JS proxies for `state`, `args`, and `context`. +- Installs FrameOS classic JSX helpers: + `__frameosJsx(...)` and `__frameosFragment`. +- Compiles code nodes, inline code snippets, and one-shot eval snippets into + named JS functions. +- Wraps snippets in a JSON envelope so ordinary values return as strings rather + than crossing the Nim/QuickJS boundary as arbitrary `JSValue`s. +- Coerces returned envelope JSON to FrameOS `Value` instances using expected + output types where available. +- Logs JS compile/runtime errors through the scene logger. +- Registers compact source-location data and rewrites QuickJS error stacks back + to app/snippet source lines and columns. +- Serializes scene JS access behind `sceneJsLock`; QuickJS contexts are not + treated as thread-safe. + +`app_runtime.nim` handles repo JavaScript apps: + +- Builds a CommonJS-style module wrapper around app source. +- Runs the native module transform before loading the wrapper into QuickJS. +- Exposes a `frameos` API object to JS apps, including logging, state updates, + image operations, sleep scheduling, HTTP helpers, and context access. +- Calls exported app lifecycle functions such as `init` and `get`. +- Tracks persistent and transient image references so overwritten dynamic image + fields can be released. +- Maps app runtime errors through the same compact source-location mechanism. + +## Native Transpiler Policy + +The native transpiler is intentionally smaller than Sucrase. It should support +the TypeScript/JSX/module syntax that FrameOS users paste into single-file apps +or snippets, then preserve the rest for QuickJS. + +The current transform set is: + +- TypeScript erasure for common annotations, type-only declarations/imports, + interfaces, type aliases, assertions, `satisfies`, non-null assertions, + modifiers, overloads, `declare`, abstract members, generics, constructor + parameter properties, and enums. +- JSX lowering to the FrameOS classic runtime: + `__frameosJsx(type, props, ...children)` and `__frameosFragment`. +- Module rewriting for app modules into the CommonJS-style `exports` object + expected by the app wrapper. +- Modern ES preservation for syntax accepted by bundled QuickJS, including + optional chaining, nullish coalescing, numeric separators, optional catch + binding, regex literals, class fields, private fields, BigInt, and logical + assignment. + +The current non-goals are: + +- Full Babel/Sucrase parser parity. +- React automatic JSX runtime or development metadata. +- Babel/Sucrase interop helpers unless FrameOS runtime behavior needs them. +- Lowering JavaScript that QuickJS already runs natively. +- Standard `.map` source-map file generation. Runtime diagnostics only need the + compact line/column table in `source_map.nim`. + +Backend/editor validation still uses npm Sucrase from `frameos/frontend` for +user-facing diagnostics. The device runtime no longer needs a vendored Sucrase +compiler bundle. + +## Source Locations + +Transpiled code is passed directly to QuickJS, so there is no consumer for a +separate source-map file during normal execution. Instead, transform functions +return a `SourceLineMap` alongside generated code. + +The map records: + +- Generated filename and original source filename. +- Generated line to original source line. +- Sparse generated line/column segments to original source line/column. + +Runtime wrappers compose their wrapper map with the transpiler map and register +the result per QuickJS context. When QuickJS returns an error stack containing +`filename:line:column`, `rewriteQuickJsLocations` rewrites it to the original +source location before it is logged. + +This is deliberately compact: it gives better compile/runtime error positions +without carrying a full source-map artifact through the runtime. + +## Test Coverage + +Focused tests for this directory live in: + +- `src/frameos/js_runtime/tests/test_js_tokens.nim` +- `src/frameos/js_runtime/tests/test_js_parser_processor.nim` +- `src/frameos/js_runtime/tests/test_js_transpiler.nim` +- `src/frameos/js_runtime/tests/test_js_runtime_helpers.nim` +- `src/frameos/js_runtime/tests/test_js_app_runtime.nim` +- `src/frameos/js_runtime/tests/test_scene_runtime_cleanup.nim` +- `tools/tests/test_native_js_transpiler_parity.py` + +The parity harness compares selected native output or runtime behavior against +npm Sucrase. Prefer adding a focused fixture there when changing tokenizer, +parser, TypeScript, JSX, or module behavior. + +## Maintenance Notes + +- Add transforms only for syntax QuickJS cannot execute or for TypeScript/JSX + syntax that must be erased before QuickJS sees it. +- Keep runtime errors mapped back to original app/snippet source. If a transform + moves user code across lines or columns, update the compact source map. +- Keep source attribution in this README if code is copied or closely ported + from an upstream project. +- Keep npm Sucrase available for backend/editor diagnostics unless native + diagnostics become good enough for that user-facing path. diff --git a/frameos/src/frameos/js_app_runtime.nim b/frameos/src/frameos/js_runtime/app_runtime.nim similarity index 66% rename from frameos/src/frameos/js_app_runtime.nim rename to frameos/src/frameos/js_runtime/app_runtime.nim index 7665c7475..ca38259cf 100644 --- a/frameos/src/frameos/js_app_runtime.nim +++ b/frameos/src/frameos/js_runtime/app_runtime.nim @@ -2,12 +2,12 @@ import std/[base64, json, options, strformat, strutils, tables] import pixie import frameos/apps as frameos_apps -import frameos/js_runtime +import frameos/js_runtime/runtime import frameos/types import frameos/values import frameos/utils/http_client import frameos/utils/image -import lib/burrito +import frameos/js_runtime/burrito type JsAppRuntime* = ref object @@ -24,7 +24,9 @@ type JsAppEvalEnv = ref object runtime: JsAppRuntime owner: AppRoot + configJson: JsonNode context: ExecutionContext + contextImageJson: JsonNode var jsAppEnvByCtx = initTable[ptr JSContext, JsAppEvalEnv]() @@ -109,6 +111,132 @@ proc jsFetchText(ctx: ptr JSContext, url: JSValue): JSValue {.nimcall.} = frameos_apps.logError(e.owner, "JS app fetchText failed: " & err.msg) return nimStringToJS(ctx, "") +proc storeTransientImageJson(runtime: JsAppRuntime, image: Image): JsonNode + +proc jsGetAppMeta(ctx: ptr JSContext, key: JSValue): JSValue {.nimcall.} = + let e = env(ctx) + if e == nil: + return jsUndefSentinel(ctx) + + case toNimString(ctx, key) + of "nodeId": + return nimIntToJS(ctx, e.owner.nodeId.int32) + of "nodeName": + return nimStringToJS(ctx, e.owner.nodeName) + of "category": + return nimStringToJS(ctx, e.runtime.category) + else: + return jsUndefSentinel(ctx) + +proc jsGetAppConfig(ctx: ptr JSContext, key: JSValue): JSValue {.nimcall.} = + let e = env(ctx) + if e == nil: + return jsUndefSentinel(ctx) + let configJson = if e.configJson.isNil: %*{} else: e.configJson + let keyStr = toNimString(ctx, key) + if configJson.kind == JObject and configJson.hasKey(keyStr): + return jsonToJS(ctx, configJson[keyStr]) + return jsUndefSentinel(ctx) + +proc jsGetAppState(ctx: ptr JSContext, key: JSValue): JSValue {.nimcall.} = + let e = env(ctx) + if e == nil: + return jsUndefSentinel(ctx) + let state = e.owner.scene.state + let keyStr = toNimString(ctx, key) + if not state.isNil and state.kind == JObject and state.hasKey(keyStr): + return jsonToJS(ctx, state[keyStr]) + return jsUndefSentinel(ctx) + +proc jsGetAppFrame(ctx: ptr JSContext, key: JSValue): JSValue {.nimcall.} = + let e = env(ctx) + if e == nil: + return jsUndefSentinel(ctx) + + case toNimString(ctx, key) + of "width": + return nimIntToJS(ctx, e.owner.frameConfig.width.int32) + of "height": + return nimIntToJS(ctx, e.owner.frameConfig.height.int32) + of "rotate": + return nimIntToJS(ctx, e.owner.frameConfig.rotate.int32) + of "assetsPath": + return nimStringToJS(ctx, e.owner.frameConfig.assetsPath) + of "timeZone": + return nimStringToJS(ctx, e.owner.frameConfig.timeZone) + else: + return jsUndefSentinel(ctx) + +proc jsGetAppContext(ctx: ptr JSContext, key: JSValue): JSValue {.nimcall.} = + let e = env(ctx) + if e == nil: + return jsUndefSentinel(ctx) + + case toNimString(ctx, key) + of "event": + return nimStringToJS(ctx, e.context.event) + of "hasImage": + return nimBoolToJS(ctx, e.context.hasImage) + of "payload": + if e.context.payload.isNil: + return jsNull(ctx) + return jsonToJS(ctx, e.context.payload) + of "loopIndex": + return nimIntToJS(ctx, e.context.loopIndex.int32) + of "loopKey": + return nimStringToJS(ctx, e.context.loopKey) + of "nextSleep": + return nimFloatToJS(ctx, e.context.nextSleep) + of "image": + if e.context.hasImage and not e.context.image.isNil: + if e.contextImageJson.isNil: + e.contextImageJson = e.runtime.storeTransientImageJson(e.context.image) + return jsonToJS(ctx, e.contextImageJson) + return jsUndefSentinel(ctx) + of "imageWidth": + if e.context.hasImage and not e.context.image.isNil: + return nimIntToJS(ctx, e.context.image.width.int32) + return jsUndefSentinel(ctx) + of "imageHeight": + if e.context.hasImage and not e.context.image.isNil: + return nimIntToJS(ctx, e.context.image.height.int32) + return jsUndefSentinel(ctx) + else: + return jsUndefSentinel(ctx) + +proc jsGetAppKeys(ctx: ptr JSContext, scope: JSValue): JSValue {.nimcall.} = + let e = env(ctx) + if e == nil: + return jsonToJS(ctx, %*[]) + + var keys: seq[string] = @[] + case toNimString(ctx, scope) + of "config": + let configJson = if e.configJson.isNil: %*{} else: e.configJson + if configJson.kind == JObject: + for key in configJson.keys: + keys.add(key) + of "state": + let state = e.owner.scene.state + if not state.isNil and state.kind == JObject: + for key in state.keys: + keys.add(key) + of "frame": + keys = @["width", "height", "rotate", "assetsPath", "timeZone"] + of "context": + keys = @["event", "hasImage", "payload", "loopIndex", "loopKey", "nextSleep"] + if e.context.hasImage and not e.context.image.isNil: + keys.add("image") + keys.add("imageWidth") + keys.add("imageHeight") + else: + discard + + let arr = newJArray() + for key in keys: + arr.add(%*key) + return jsonToJS(ctx, arr) + proc newJsAppRuntime*(category: string, outputType: string, source: string): JsAppRuntime = return JsAppRuntime( category: category, @@ -285,10 +413,32 @@ proc ensureReady(runtime: JsAppRuntime) = runtime.js.registerFunction("jsSetNextSleep", jsSetNextSleep) runtime.js.registerFunction("jsSetState", jsSetState) runtime.js.registerFunction("jsFetchText", jsFetchText) + runtime.js.registerFunction("jsGetAppMeta", jsGetAppMeta) + runtime.js.registerFunction("jsGetAppConfig", jsGetAppConfig) + runtime.js.registerFunction("jsGetAppState", jsGetAppState) + runtime.js.registerFunction("jsGetAppFrame", jsGetAppFrame) + runtime.js.registerFunction("jsGetAppContext", jsGetAppContext) + runtime.js.registerFunction("jsGetAppKeys", jsGetAppKeys) discard runtime.js.eval(""" "use strict"; const __jsReplacer = (k, v) => (typeof v === 'bigint') ? { __bigint: v.toString() } : v; + globalThis.__frameosStringify = (v) => JSON.stringify(v, __jsReplacer); + const __frameosUnwrap = (v) => (v && v.__frameosUndef === true) ? undefined : v; + const __frameosProxy = (scope, getter) => new Proxy({}, { + get(_, k) { return (typeof k === "string") ? __frameosUnwrap(getter(k)) : undefined; }, + has(_, k) { return typeof k === "string" && jsGetAppKeys(scope).includes(k); }, + ownKeys() { return jsGetAppKeys(scope); }, + getOwnPropertyDescriptor(_, k) { + return (typeof k === "string" && jsGetAppKeys(scope).includes(k)) + ? { enumerable: true, configurable: true } + : undefined; + }, + }); + const __frameosAppConfig = __frameosProxy("config", jsGetAppConfig); + const __frameosAppState = __frameosProxy("state", jsGetAppState); + const __frameosAppFrame = __frameosProxy("frame", jsGetAppFrame); + globalThis.__frameosContext = __frameosProxy("context", jsGetAppContext); const frameos = { image: (spec = {}) => ({ __frameosType: "image", ...spec }), svg: (svg, spec = {}) => ({ __frameosType: "image", svg, ...spec }), @@ -313,6 +463,14 @@ proc ensureReady(runtime: JsAppRuntime) = logError: (...args) => jsAppLog("error", JSON.stringify(args, __jsReplacer)), }); } + globalThis.__frameosAppInstance = __frameosWrapApp({ + get nodeId() { return __frameosUnwrap(jsGetAppMeta("nodeId")); }, + get nodeName() { return __frameosUnwrap(jsGetAppMeta("nodeName")); }, + get category() { return __frameosUnwrap(jsGetAppMeta("category")); }, + config: __frameosAppConfig, + state: __frameosAppState, + frame: __frameosAppFrame, + }); function __frameosExports() { if (globalThis.__frameosModule && globalThis.__frameosModule.default) { return globalThis.__frameosModule.default; @@ -320,66 +478,22 @@ proc ensureReady(runtime: JsAppRuntime) = return globalThis.__frameosModule || {}; } function __frameosInvoke(name) { - try { - const mod = __frameosExports(); - const fn = mod && mod[name]; - const value = typeof fn === "function" - ? fn(globalThis.__frameosAppInstance, globalThis.__frameosContext) - : undefined; - return JSON.stringify({ ok: true, value }, __jsReplacer); - } catch (error) { - return JSON.stringify({ - ok: false, - error: { - message: String(error && error.message || error), - stack: String(error && error.stack || error), - }, - }, __jsReplacer); - } + const mod = __frameosExports(); + const fn = mod && mod[name]; + return typeof fn === "function" + ? fn(globalThis.__frameosAppInstance, globalThis.__frameosContext) + : undefined; } """ & sceneJsPrelude) - discard runtime.js.eval(transpileModuleSource(runtime.source, "")) + let filename = "" + let transformed = transpileModuleSourceWithMap(runtime.source, filename) + try: + discard runtime.js.eval(transformed.code, filename) + except CatchableError as error: + raise newException(JSException, error.msg.mapJsErrorText(transformed.sourceMap)) + registerJsSourceMap(runtime.js.context, transformed.sourceMap) runtime.ready = true -proc buildAppJson(runtime: JsAppRuntime, owner: AppRoot, configJson: JsonNode): JsonNode = - result = %*{ - "nodeId": owner.nodeId.int, - "nodeName": owner.nodeName, - "category": runtime.category, - "config": if configJson.isNil: %*{} else: configJson, - "state": if owner.scene.state.isNil: %*{} else: owner.scene.state, - "frame": { - "width": owner.frameConfig.width, - "height": owner.frameConfig.height, - "rotate": owner.frameConfig.rotate, - "assetsPath": owner.frameConfig.assetsPath, - "timeZone": owner.frameConfig.timeZone, - }, - } - -proc buildContextJson(runtime: JsAppRuntime, context: ExecutionContext): JsonNode = - result = %*{ - "event": context.event, - "hasImage": context.hasImage, - "payload": if context.payload.isNil: newJNull() else: context.payload, - "loopIndex": context.loopIndex, - "loopKey": context.loopKey, - "nextSleep": context.nextSleep, - } - if context.hasImage and not context.image.isNil: - result["image"] = runtime.storeTransientImageJson(context.image) - result["imageWidth"] = %* context.image.width - result["imageHeight"] = %* context.image.height - -proc setCallGlobals(runtime: JsAppRuntime, owner: AppRoot, configJson: JsonNode, context: ExecutionContext) = - let appJson = buildAppJson(runtime, owner, configJson) - let contextJson = buildContextJson(runtime, context) - discard runtime.js.eval( - "globalThis.__frameosAppInstance = __frameosWrapApp(Object.assign(globalThis.__frameosAppInstance || {}, " & - $appJson & "));" - ) - discard runtime.js.eval("globalThis.__frameosContext = " & $contextJson & ";") - proc defaultImageWidth(owner: AppRoot, context: ExecutionContext, spec: JsonNode): int = if spec.kind == JObject and spec.hasKey("width"): return valueFromJsonByType(spec["width"], "integer").asInt().int @@ -494,29 +608,67 @@ proc toValue(runtime: JsAppRuntime, owner: AppRoot, context: ExecutionContext, p else: return VNone() -proc invoke(runtime: JsAppRuntime, owner: AppRoot, configJson: JsonNode, context: ExecutionContext, fnName: string): JsonNode = +proc toValue(runtime: JsAppRuntime, owner: AppRoot, context: ExecutionContext, payload: JSValueConst, expectedType: string): Value = + let ctx = runtime.js.context + + if jsIsUndefined(payload) or jsIsNull(payload): + if expectedType.len > 0: + return valueFromJsonByType(newJNull(), expectedType) + return VNone() + + if expectedType == "image" or jsIsObject(payload) or JS_IsArray(ctx, payload) != 0: + return runtime.toValue(owner, context, jsValueToJson(ctx, payload), expectedType) + + if expectedType.len > 0: + if expectedType in ["string", "text"]: + return VString(toNimString(ctx, payload)) + return valueFromJsonByType(jsValueToJson(ctx, payload), expectedType) + + if jsIsString(payload): + return VString(toNimString(ctx, payload)) + if jsIsBool(payload): + return VBool(toNimBool(ctx, payload)) + if jsIsNumber(payload): + let f = toNimFloat(ctx, payload) + if f >= low(int64).float64 and f <= high(int64).float64: + let i = f.int64 + if i.float64 == f: + return VInt(i) + return VFloat(f) + if jsIsBigInt(ctx, payload): + try: + return VInt(toNimInt64Ext(ctx, payload)) + except CatchableError: + return VString(toNimString(ctx, payload)) + + runtime.toValue(owner, context, jsValueToJson(ctx, payload), expectedType) + +proc invoke(runtime: JsAppRuntime, owner: AppRoot, configJson: JsonNode, context: ExecutionContext, fnName: string): JSValue = ensureReady(runtime) - setCallGlobals(runtime, owner, configJson, context) - jsAppEnvByCtx[runtime.js.context] = JsAppEvalEnv(runtime: runtime, owner: owner, context: context) - let response = + let ctx = runtime.js.context + let fnNameValue = nimStringToJS(ctx, fnName) + defer: JS_FreeValue(ctx, fnNameValue) + + jsAppEnvByCtx[ctx] = JsAppEvalEnv(runtime: runtime, owner: owner, configJson: configJson, context: context) + result = try: - runtime.js.eval(&"""__frameosInvoke("{fnName}")""") + callGlobalFunction(ctx, "__frameosInvoke", [fnNameValue]) finally: - if jsAppEnvByCtx.hasKey(runtime.js.context): - jsAppEnvByCtx.del(runtime.js.context) + if jsAppEnvByCtx.hasKey(ctx): + jsAppEnvByCtx.del(ctx) - let parsed = parseJson(response) - if not parsed{"ok"}.getBool(): - frameos_apps.logError(owner, &"JS app {fnName} failed: " & parsed{"error"}{"message"}.getStr()) - if parsed{"error"}{"stack"}.getStr().len > 0: + if JS_IsException(result) != 0: + let details = mappedJsExceptionDetails(ctx) + frameos_apps.logError(owner, &"JS app {fnName} failed: " & details.message) + if details.stack.len > 0: frameos_apps.log(owner, %*{ "event": "jsApp:error", "nodeId": owner.nodeId.int, "nodeName": owner.nodeName, - "stack": parsed{"error"}{"stack"}.getStr() + "stack": details.stack }) - return newJNull() - return parsed{"value"} + JS_FreeValue(ctx, result) + return jsNull(ctx) proc init*(runtime: JsAppRuntime, owner: AppRoot, configJson: JsonNode) = if runtime.initialized: @@ -530,13 +682,15 @@ proc init*(runtime: JsAppRuntime, owner: AppRoot, configJson: JsonNode) = loopKey: ".", nextSleep: -1 ) - discard runtime.invoke(owner, configJson, context, "init") + let payload = runtime.invoke(owner, configJson, context, "init") + JS_FreeValue(runtime.js.context, payload) runtime.initialized = true proc get*(runtime: JsAppRuntime, owner: AppRoot, configJson: JsonNode, context: ExecutionContext): Value = runtime.init(owner, configJson) try: let payload = runtime.invoke(owner, configJson, context, "get") + defer: JS_FreeValue(runtime.js.context, payload) return toValue(runtime, owner, context, payload, runtime.outputType) finally: runtime.clearTransientImages() @@ -545,6 +699,7 @@ proc run*(runtime: JsAppRuntime, owner: AppRoot, configJson: JsonNode, context: runtime.init(owner, configJson) try: let payload = runtime.invoke(owner, configJson, context, "run") + defer: JS_FreeValue(runtime.js.context, payload) if runtime.category == "render": let value = toValue(runtime, owner, context, payload, "image") if value.kind == fkImage and not value.asImage().isNil: diff --git a/frameos/src/lib/burrito.nim b/frameos/src/frameos/js_runtime/burrito.nim similarity index 95% rename from frameos/src/lib/burrito.nim rename to frameos/src/frameos/js_runtime/burrito.nim index 2a915f75e..528e85a5c 100644 --- a/frameos/src/lib/burrito.nim +++ b/frameos/src/frameos/js_runtime/burrito.nim @@ -12,7 +12,7 @@ ## ## **Example: Basic Usage** ## ```nim -## import burrito +## import frameos/js_runtime/burrito ## ## # Create a QuickJS instance ## var js = newQuickJS() @@ -33,7 +33,7 @@ ## ## **Example: Embedded REPL** ## ```nim -## import burrito +## import frameos/js_runtime/burrito ## ## # Create QuickJS with full standard library support ## var js = newQuickJS(configWithBothLibs()) @@ -54,7 +54,7 @@ ## ## **Example: Bytecode Compilation** ## ```nim -## import burrito +## import frameos/js_runtime/burrito ## ## var js = newQuickJS() ## @@ -132,7 +132,10 @@ proc js_std_init_handlers*(rt: ptr JSRuntime) proc js_std_free_handlers*(rt: ptr JSRuntime) proc js_std_await*(ctx: ptr JSContext, val: JSValue): JSValue proc js_std_loop*(ctx: ptr JSContext) -proc js_module_loader*(ctx: ptr JSContext, module_name: cstring, opaque: pointer): ptr JSModuleDef {.cdecl.} +proc js_module_loader*(ctx: ptr JSContext, module_name: cstring, opaque: pointer, + attributes: JSValueConst): ptr JSModuleDef {.cdecl.} +proc js_module_check_attributes*(ctx: ptr JSContext, opaque: pointer, + attributes: JSValueConst): cint {.cdecl.} {.pop.} @@ -154,6 +157,7 @@ proc JS_NewObject*(ctx: ptr JSContext): JSValue # Getting values from JSValue proc JS_ToInt32*(ctx: ptr JSContext, pres: ptr int32, val: JSValueConst): cint proc JS_ToFloat64*(ctx: ptr JSContext, pres: ptr float64, val: JSValueConst): cint +proc JS_ToInt64Ext*(ctx: ptr JSContext, pres: ptr int64, val: JSValueConst): cint proc JS_ToCString*(ctx: ptr JSContext, val: JSValueConst): cstring proc JS_FreeCString*(ctx: ptr JSContext, str: cstring) proc JS_ToBool*(ctx: ptr JSContext, val: JSValueConst): cint @@ -207,6 +211,12 @@ proc JS_DeleteProperty*(ctx: ptr JSContext, thisObj: JSValueConst, prop: JSAtom, # Array functions proc JS_NewArray*(ctx: ptr JSContext): JSValue +proc JS_IsArray*(ctx: ptr JSContext, val: JSValueConst): cint +proc JS_IsFunction*(ctx: ptr JSContext, val: JSValueConst): cint +proc JS_Call*(ctx: ptr JSContext, funcObj: JSValueConst, thisObj: JSValueConst, argc: cint, + argv: ptr JSValueConst): JSValue +proc JS_JSONStringify*(ctx: ptr JSContext, obj: JSValueConst, replacer: JSValueConst, + space0: JSValueConst): JSValue # Atom functions (for property names) proc JS_NewAtom*(ctx: ptr JSContext, str: cstring): JSAtom @@ -217,7 +227,10 @@ proc JS_AtomToString*(ctx: ptr JSContext, atom: JSAtom): JSValue # Module loading proc JS_SetModuleLoaderFunc*(rt: ptr JSRuntime, module_normalize: pointer, module_loader: proc(ctx: ptr JSContext, moduleName: cstring, opaque: pointer): ptr JSModuleDef {.cdecl.}, opaque: pointer) - +proc JS_SetModuleLoaderFunc2*(rt: ptr JSRuntime, module_normalize: pointer, module_loader: proc(ctx: ptr JSContext, + moduleName: cstring, opaque: pointer, attributes: JSValueConst): ptr JSModuleDef {.cdecl.}, + module_check_attrs: proc(ctx: ptr JSContext, opaque: pointer, attributes: JSValueConst): cint {.cdecl.}, + opaque: pointer) # Promise-related functions proc JS_PromiseState*(ctx: ptr JSContext, promise: JSValueConst): cint proc JS_PromiseResult*(ctx: ptr JSContext, promise: JSValueConst): JSValue @@ -375,6 +388,33 @@ proc toNimFloat*(ctx: ptr JSContext, val: JSValueConst): float64 = proc toNimBool*(ctx: ptr JSContext, val: JSValueConst): bool = JS_ToBool(ctx, val) != 0 +proc toNimInt64Ext*(ctx: ptr JSContext, val: JSValueConst): int64 = + var res: int64 + if JS_ToInt64Ext(ctx, addr res, val) != 0: + raise newException(JSException, "Failed to convert JSValue to int64") + result = res + +proc jsIsUndefined*(val: JSValueConst): bool = + {.emit: "return JS_IsUndefined(`val`);".} + +proc jsIsNull*(val: JSValueConst): bool = + {.emit: "return JS_IsNull(`val`);".} + +proc jsIsBool*(val: JSValueConst): bool = + {.emit: "return JS_IsBool(`val`);".} + +proc jsIsNumber*(val: JSValueConst): bool = + {.emit: "return JS_IsNumber(`val`);".} + +proc jsIsString*(val: JSValueConst): bool = + {.emit: "return JS_IsString(`val`);".} + +proc jsIsObject*(val: JSValueConst): bool = + {.emit: "return JS_IsObject(`val`);".} + +proc jsIsBigInt*(ctx: ptr JSContext, val: JSValueConst): bool = + {.emit: "return JS_IsBigInt(`ctx`, `val`);".} + # Conversion from Nim types to JSValue proc nimStringToJS*(ctx: ptr JSContext, str: string): JSValue = JS_NewStringLen(ctx, str.cstring, str.len.csize_t) @@ -927,9 +967,11 @@ proc newQuickJS*(config: QuickJSConfig = defaultConfig()): QuickJS = if config.enableStdHandlers: js_std_init_handlers(rt) - # Set up module loader for ES6 modules (critical for std/os modules) - if config.includeStdLib or config.includeOsLib: - JS_SetModuleLoaderFunc(rt, nil, js_module_loader, nil) + # The libc js_module_loader signature has drifted across QuickJS releases while + # JS_SetModuleLoaderFunc stayed on the three-argument loader callback. Avoid + # passing that version-sensitive symbol directly; FrameOS only uses isolated + # contexts in production, and std/os modules are initialized explicitly below + # for legacy Burrito callers. # Initialize std module if requested if config.includeStdLib: @@ -943,9 +985,6 @@ proc newQuickJS*(config: QuickJSConfig = defaultConfig()): QuickJS = if config.includeStdLib or config.includeOsLib: js_std_add_helpers(ctx, 0, nil) - # Set up module loader for proper module resolution - JS_SetModuleLoaderFunc(rt, nil, js_module_loader, nil) - # Create context data for function registry let contextData = cast[ptr BurritoContextData](alloc0(sizeof(BurritoContextData))) contextData.functions = initTable[int32, NimFunctionEntry]() diff --git a/frameos/src/frameos/js_runtime/parser.nim b/frameos/src/frameos/js_runtime/parser.nim new file mode 100644 index 000000000..433530917 --- /dev/null +++ b/frameos/src/frameos/js_runtime/parser.nim @@ -0,0 +1,506 @@ +# Lightweight Sucrase-style parser/traverser annotations for the native +# FrameOS JS transpiler. This module consumes the raw tokenizer stream and +# annotates token roles used by token-driven transformers. + +import std/strutils + +import ./tokens + +type + ParsedFile* = object + tokens*: seq[JsToken] + scopes*: seq[Scope] + +proc raw(code: string, token: JsToken): string = + if token.start >= 0 and token.`end` <= code.len and token.start <= token.`end`: + code[token.start.. 0: dec parenDepth + of ttBraceL: inc braceDepth + of ttBraceR: + if braceDepth == 0 and parenDepth == 0 and bracketDepth == 0: + return i + if braceDepth > 0: dec braceDepth + of ttBracketL: inc bracketDepth + of ttBracketR: + if bracketDepth > 0: dec bracketDepth + of ttSemi: + if parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + return i + of ttEof: + return i + else: + discard + max(0, tokens.len - 1) + +proc isTypeBoundary(typ: TokenType): bool = + typ in {ttComma, ttSemi, ttEq, ttBraceR, ttParenR, ttBracketR, ttArrow, ttEof} + +proc markRangeType(tokens: var seq[JsToken], first, lastInclusive: int) = + if first < 0 or lastInclusive < first: + return + for i in first..min(lastInclusive, tokens.len - 1): + tokens[i].isType = true + +proc prevTokenIndex(tokens: seq[JsToken], index: int): int = + result = index - 1 + while result >= 0 and tokens[result].typ == ttEof: + dec result + +proc roleForDeclaration(scopeDepth: int, functionScoped: bool): IdentifierRole = + if scopeDepth == 0: + irTopLevelDeclaration + elif functionScoped: + irFunctionScopedDeclaration + else: + irBlockScopedDeclaration + +proc annotateScopes(tokens: var seq[JsToken]): seq[Scope] = + var scopeDepth = 0 + var stack: seq[tuple[index: int, isFunction: bool]] = @[] + var nextContextId = 1 + var pendingFunctionBrace = false + + for i in 0.. 0: + dec scopeDepth + tokens[i].scopeDepth = scopeDepth + if stack.len > 0: + let opened = stack.pop() + tokens[i].contextId = tokens[opened.index].contextId + result.add(Scope( + startTokenIndex: opened.index, + endTokenIndex: i, + isFunctionScope: opened.isFunction, + )) + else: + discard + + result.add(Scope(startTokenIndex: 0, endTokenIndex: max(0, tokens.len - 1), isFunctionScope: true)) + +proc markBindingList(tokens: var seq[JsToken], start, stop: int, role: IdentifierRole) = + var i = start + var expectingBinding = true + var depth = 0 + while i <= stop and i < tokens.len: + case tokens[i].typ + of ttBraceL, ttBracketL, ttParenL: + inc depth + of ttBraceR, ttBracketR, ttParenR: + if depth > 0: dec depth + of ttComma: + if depth == 0: + expectingBinding = true + of ttName: + if expectingBinding: + tokens[i].identifierRole = role + expectingBinding = false + of ttEq: + expectingBinding = false + else: + discard + inc i + +proc annotateVarDeclarations(tokens: var seq[JsToken]) = + var i = 0 + while i < tokens.len: + if tokens[i].typ in {ttConst, ttLet, ttVar}: + let role = roleForDeclaration(tokens[i].scopeDepth, tokens[i].typ == ttVar) + var j = i + 1 + var expectingBinding = true + var depth = 0 + while j < tokens.len: + case tokens[j].typ + of ttSemi, ttEof: + break + of ttBraceL, ttBracketL, ttParenL: + inc depth + of ttBraceR, ttBracketR, ttParenR: + if depth == 0 and tokens[j].typ == ttParenR: + break + if depth > 0: dec depth + of ttComma: + if depth == 0: + expectingBinding = true + of ttEq: + expectingBinding = false + of ttName: + if expectingBinding: + tokens[j].identifierRole = role + expectingBinding = false + else: + discard + inc j + i = j + inc i + +proc annotateFunctionAndClassDeclarations(tokens: var seq[JsToken]) = + for i in 0..= 0: + markBindingList(tokens, paren + 1, close - 1, irFunctionScopedDeclaration) + + if tokens[i].typ == ttClass: + let nameIndex = i + 1 + if nameIndex < tokens.len and tokens[nameIndex].typ == ttName: + tokens[nameIndex].identifierRole = roleForDeclaration(tokens[i].scopeDepth, false) + +proc annotateImportsExports(code: string, tokens: var seq[JsToken]) = + var i = 0 + while i < tokens.len: + if tokens[i].typ == ttImport: + let stmtEnd = findStatementEnd(tokens, i) + var j = i + 1 + if j < tokens.len and tokens[j].typ == ttType: + markRangeType(tokens, i, stmtEnd) + elif j < tokens.len and tokens[j].typ != ttParenL and tokens[j].typ != ttDot and tokens[j].typ != ttString: + if tokens[j].typ == ttName: + tokens[j].identifierRole = irImportDeclaration + inc j + if j < tokens.len and tokens[j].typ == ttComma: + inc j + if j < tokens.len and tokens[j].typ == ttStar: + if j + 2 < tokens.len and tokens[j + 1].typ == ttAs and tokens[j + 2].typ == ttName: + tokens[j + 2].identifierRole = irImportDeclaration + elif j < tokens.len and tokens[j].typ == ttBraceL: + let close = findMatching(tokens, j, ttBraceL, ttBraceR) + var k = j + 1 + while k >= 0 and close >= 0 and k < close: + if tokens[k].typ == ttName or tokens[k].typ == ttType: + if k + 1 < close and tokens[k + 1].typ == ttAs: + tokens[k].identifierRole = irImportAccess + if k + 2 < close and tokens[k + 2].typ == ttName: + tokens[k + 2].identifierRole = irImportDeclaration + k += 2 + elif k > j + 1 and tokens[k - 1].typ == ttAs: + tokens[k].identifierRole = irImportDeclaration + else: + tokens[k].identifierRole = irImportDeclaration + inc k + i = stmtEnd + + elif tokens[i].typ == ttExport: + let stmtEnd = findStatementEnd(tokens, i) + tokens[i].rhsEndIndex = stmtEnd + var j = i + 1 + if j < tokens.len and tokens[j].typ == ttType: + markRangeType(tokens, i, stmtEnd) + elif j < tokens.len and tokens[j].typ == ttBraceL: + let close = findMatching(tokens, j, ttBraceL, ttBraceR) + var k = j + 1 + while k >= 0 and close >= 0 and k < close: + if tokens[k].typ == ttName or tokens[k].typ == ttType: + if k == j + 1 or tokens[k - 1].typ in {ttComma, ttBraceL}: + tokens[k].identifierRole = irExportAccess + inc k + elif j < tokens.len and tokens[j].typ in {ttConst, ttLet, ttVar, ttFunction, ttClass, ttEnum}: + discard + i = stmtEnd + inc i + +proc annotateObjectKeys(code: string, tokens: var seq[JsToken]) = + for i in 0..= 0 and tokens[i].typ notin {ttSemi, ttEof}: + if tokens[i].typ in {ttImport, ttExport}: + return true + dec i + false + +proc isPropertyAccessName(tokens: seq[JsToken], index: int): bool = + let prev = prevTokenIndex(tokens, index) + prev >= 0 and tokens[prev].typ in {ttDot, ttQuestionDot} + +proc isLikelyTernaryColon(tokens: seq[JsToken], index: int): bool = + var depth = 0 + var i = index - 1 + while i >= 0: + case tokens[i].typ + of ttParenR, ttBraceR, ttBracketR: + inc depth + of ttParenL, ttBraceL, ttBracketL, ttDollarBraceL: + if depth == 0: + return false + dec depth + of ttQuestion: + if depth == 0: + return i != index - 1 + of ttComma, ttSemi: + if depth == 0: + return false + else: + discard + dec i + false + +proc annotateTypeSpans(code: string, tokens: var seq[JsToken]) = + var i = 0 + while i < tokens.len: + if tokens[i].typ == ttType: + var j = i + 1 + if j < tokens.len and tokens[j].typ == ttName: + while j < tokens.len and tokens[j].typ != ttEq and tokens[j].typ != ttEof: + inc j + if j < tokens.len and tokens[j].typ == ttEq: + let endIndex = findStatementEnd(tokens, i) + markRangeType(tokens, i, endIndex) + i = endIndex + + if tokens[i].typ == ttName and tokens[i].contextualKeyword == ckInterface: + let endIndex = + block: + var brace = i + while brace < tokens.len and tokens[brace].typ != ttBraceL and tokens[brace].typ != ttEof: + inc brace + if brace < tokens.len and tokens[brace].typ == ttBraceL: + let close = findMatching(tokens, brace, ttBraceL, ttBraceR) + if close >= 0: close else: findStatementEnd(tokens, i) + else: + findStatementEnd(tokens, i) + markRangeType(tokens, i, endIndex) + i = endIndex + + if tokens[i].typ == ttColon and not isLikelyTernaryColon(tokens, i): + let prev = prevTokenIndex(tokens, i) + if prev >= 0 and tokens[prev].identifierRole != irObjectKey: + var j = i + 1 + var depth = 0 + while j < tokens.len: + if tokens[j].typ in {ttLessThan, ttBraceL, ttBracketL, ttParenL}: + inc depth + elif tokens[j].typ in {ttGreaterThan, ttBraceR, ttBracketR, ttParenR}: + if depth == 0: + break + dec depth + if depth == 0 and isTypeBoundary(tokens[j].typ): + break + inc j + markRangeType(tokens, i, j - 1) + i = max(i, j - 1) + + if (tokens[i].typ == ttAs or (tokens[i].typ == ttName and tokens[i].contextualKeyword == ckSatisfies)) and + not inImportExportStatement(tokens, i) and + not isPropertyAccessName(tokens, i) and + tokens[i].identifierRole != irObjectKey: + var j = i + 1 + while j < tokens.len and not isTypeBoundary(tokens[j].typ): + inc j + markRangeType(tokens, i, j - 1) + i = max(i, j - 1) + + if tokens[i].typ == ttLessThan: + var prev = i - 1 + while prev >= 0 and tokens[prev].typ == ttEof: + dec prev + let close = findMatching(tokens, i, ttLessThan, ttGreaterThan) + if close > i and prev >= 0 and tokens[prev].typ in {ttName, ttFunction, ttClass, ttParenR}: + var after = close + 1 + if after < tokens.len and tokens[after].typ in {ttParenL, ttBraceL, ttExtends, ttImplements}: + markRangeType(tokens, i, close) + i = close + elif close > i and close + 1 < tokens.len and tokens[close + 1].typ == ttParenL: + let parenClose = findMatching(tokens, close + 1, ttParenL, ttParenR) + if parenClose > close and parenClose + 1 < tokens.len and tokens[parenClose + 1].typ == ttArrow: + markRangeType(tokens, i, close) + i = close + inc i + +proc annotateJsxRoles(code: string, tokens: var seq[JsToken]) = + var stack: seq[tuple[start: int, explicitChildren: int, hasSpread: bool, propSpreadSeen: bool]] = @[] + var i = 0 + while i < tokens.len: + if tokens[i].typ == ttJsxTagStart: + let isClosing = i + 1 < tokens.len and tokens[i + 1].typ == ttSlash + if isClosing: + if stack.len > 0: + let item = stack.pop() + if tokens[item.start].jsxRole != jsxKeyAfterPropSpread: + tokens[item.start].jsxRole = + if item.explicitChildren == 0: jsxNoChildren + elif item.explicitChildren == 1 and not item.hasSpread: jsxOneChild + else: jsxStaticChildren + if stack.len > 0: + stack[^1].explicitChildren += 1 + inc i + continue + + var selfClosing = false + var propSpreadSeen = false + var keyAfterSpread = false + var seenTagName = false + var j = i + 1 + while j < tokens.len and tokens[j].typ != ttJsxTagEnd: + if tokens[j].typ == ttBraceL and j + 1 < tokens.len and tokens[j + 1].typ == ttEllipsis: + propSpreadSeen = true + if tokens[j].typ == ttJsxName: + if not seenTagName: + seenTagName = true + elif j + 1 < tokens.len and tokens[j + 1].typ in {ttEq, ttJsxTagEnd, ttSlash}: + tokens[j].identifierRole = irObjectKey + if propSpreadSeen and tokens[j].typ == ttJsxName and raw(code, tokens[j]) == "key": + keyAfterSpread = true + if tokens[j].typ == ttSlash: + selfClosing = true + inc j + + if keyAfterSpread: + tokens[i].jsxRole = jsxKeyAfterPropSpread + elif selfClosing: + tokens[i].jsxRole = jsxNoChildren + if stack.len > 0: + stack[^1].explicitChildren += 1 + else: + tokens[i].jsxRole = jsxNoChildren + stack.add((i, 0, false, propSpreadSeen)) + i = j + elif stack.len > 0: + if tokens[i].typ == ttJsxText: + stack[^1].explicitChildren += 1 + elif tokens[i].typ == ttBraceL: + let next = i + 1 + if next < tokens.len and tokens[next].typ == ttEllipsis: + stack[^1].hasSpread = true + stack[^1].explicitChildren += 1 + else: + let close = findMatching(tokens, i, ttBraceL, ttBraceR) + if close < 0 or close == i + 1: + discard + else: + stack[^1].explicitChildren += 1 + inc i + +proc annotateOptionalAndNullish(tokens: var seq[JsToken]) = + for i in 0.. 0 and tokens[start].typ notin {ttComma, ttSemi, ttParenL, ttBraceL, ttBracketL, ttEq}: + dec start + if start < i and tokens[start].typ in {ttComma, ttSemi, ttParenL, ttBraceL, ttBracketL, ttEq}: + inc start + tokens[start].numNullishCoalesceStarts += 1 + var finish = i + 1 + while finish < tokens.len and tokens[finish].typ notin {ttComma, ttSemi, ttParenR, ttBraceR, ttBracketR, ttEof}: + inc finish + if finish > i + 1: + tokens[finish - 1].numNullishCoalesceEnds += 1 + + if tokens[i].typ == ttQuestionDot: + var start = i - 1 + while start > 0 and tokens[start].typ notin {ttComma, ttSemi, ttParenL, ttBraceL, ttBracketL, ttEq}: + dec start + if start < i and tokens[start].typ in {ttComma, ttSemi, ttParenL, ttBraceL, ttBracketL, ttEq}: + inc start + tokens[start].isOptionalChainStart = true + var finish = i + 1 + while finish < tokens.len and tokens[finish].typ notin {ttComma, ttSemi, ttParenR, ttBraceR, ttBracketR, ttEof}: + inc finish + if finish > i + 1: + tokens[finish - 1].isOptionalChainEnd = true + tokens[i].subscriptStartIndex = start + +proc annotateAccessIdentifiers(tokens: var seq[JsToken]) = + for i in 0.. 0: + result.add(" [" & fields.join(",") & "]") + +proc formatAnnotatedTokens*(code: string, file: ParsedFile): string = + for token in file.tokens: + if result.len > 0: + result.add("\n") + result.add(formatAnnotatedToken(code, token)) diff --git a/frameos/src/frameos/js_runtime.nim b/frameos/src/frameos/js_runtime/runtime.nim similarity index 52% rename from frameos/src/frameos/js_runtime.nim rename to frameos/src/frameos/js_runtime/runtime.nim index 91231d524..362ccf97d 100644 --- a/frameos/src/frameos/js_runtime.nim +++ b/frameos/src/frameos/js_runtime/runtime.nim @@ -1,14 +1,16 @@ -# frameos/src/frameos/js_runtime.nim +# frameos/src/frameos/js_runtime/runtime.nim # Centralized JavaScript runtime helpers for interpreted scenes. # Extracted from interpreter.nim so the JS bridge can be reused anywhere. import frameos/types import frameos/values -import assets/vendor as vendorAssets +import frameos/js_runtime/source_map +import frameos/js_runtime/transpiler +import frameos/js_runtime/burrito import lib/tz -import lib/burrito import tables, json, strutils, locks import chrono, times +import pixie # ------------------------- # Internal evaluation scope @@ -24,12 +26,12 @@ type outputTypes: Table[string, string] targetField: string -var evalEnvByCtx = initTable[ptr JSContext, EvalEnv]() +var jsSourceMapsByCtx = initTable[ptr JSContext, Table[string, SourceLineMap]]() +var currentEvalCtx: ptr JSContext +var currentEvalEnv: EvalEnv var tzName = "" -var compilerJsLock: Lock -var compilerJs: QuickJS -var compilerJsReady = false -initLock(compilerJsLock) +var sceneJsLock: Lock +initLock(sceneJsLock) const sceneJsPrelude* = """ const __frameosFragment = Symbol.for("frameos.fragment"); @@ -100,26 +102,46 @@ proc getCodeSnippet(node: DiagramNode): string = return node.data["code"].getStr() "" -proc ensureCompilerJsLocked() = - if compilerJsReady: - return - compilerJs = newQuickJS() - discard compilerJs.eval(vendorAssets.getAsset("assets/compiled/vendor/sucrase.js")) - compilerJsReady = true - proc transpileSource*(source: string, filename: string): string = if source.len == 0: return source - withLock compilerJsLock: - ensureCompilerJsLocked() - result = compilerJs.eval("__frameosTranspile(\"" & jsQuote(source) & "\", { filePath: \"" & jsQuote(filename) & "\" })") + transformFrameosScript(source, filename) + +proc transpileSourceWithMap*(source: string, filename: string): TransformResult = + if source.len == 0: + return TransformResult(code: source, sourceMap: identitySourceLineMap(source, filename, filename)) + transform(source, TransformOptions(filePath: filename, transforms: @["typescript", "jsx"])) proc transpileModuleSource*(source: string, filename: string): string = if source.len == 0: return source - withLock compilerJsLock: - ensureCompilerJsLocked() - result = compilerJs.eval("__frameosTranspile(\"" & jsQuote(source) & "\", { filePath: \"" & jsQuote(filename) & "\", transforms: [\"typescript\", \"jsx\", \"imports\"] })") + transformFrameosModule(source, filename) + +proc transpileModuleSourceWithMap*(source: string, filename: string): TransformResult = + if source.len == 0: + return TransformResult(code: source, sourceMap: identitySourceLineMap(source, filename, filename)) + transform(source, TransformOptions(filePath: filename, transforms: @["typescript", "jsx", "imports"])) + +proc registerJsSourceMap*(ctx: ptr JSContext, sourceMap: SourceLineMap) = + if ctx == nil or sourceMap.generatedName.len == 0: + return + if not jsSourceMapsByCtx.hasKey(ctx): + jsSourceMapsByCtx[ctx] = initTable[string, SourceLineMap]() + jsSourceMapsByCtx[ctx][sourceMap.generatedName] = sourceMap + +proc clearJsSourceMaps*(ctx: ptr JSContext) = + if ctx != nil and jsSourceMapsByCtx.hasKey(ctx): + jsSourceMapsByCtx.del(ctx) + +proc mapJsErrorText*(ctx: ptr JSContext, text: string): string = + result = text + if ctx == nil or not jsSourceMapsByCtx.hasKey(ctx): + return + for _, sourceMap in jsSourceMapsByCtx[ctx]: + result = result.rewriteQuickJsLocations(sourceMap) + +proc mapJsErrorText*(text: string, sourceMap: SourceLineMap): string = + text.rewriteQuickJsLocations(sourceMap) proc logCompileError( scene: InterpretedFrameScene, @@ -140,12 +162,32 @@ proc logCompileError( "snippet": snippet }) +proc logCompileError( + scene: InterpretedFrameScene, + nodeId: NodeId, + sourceKind: string, + sourceName: string, + snippet: string, + error: ref CatchableError, + sourceMap: SourceLineMap +) = + scene.logger.log(%*{ + "event": "interpreter:jsCompileError", + "sceneId": scene.id.string, + "nodeId": nodeId.int, + "sourceKind": sourceKind, + "sourceName": sourceName, + "error": error.msg.mapJsErrorText(sourceMap), + "stacktrace": error.getStackTrace().mapJsErrorText(sourceMap), + "snippet": snippet + }) + # ------------------------- # Build JS envelope function # ------------------------- -proc buildEnvelopeFunction(code: string, argNames: seq[string], fnName: string): string = - ## Create a named function returning a BigInt-safe JSON envelope (no re-parsing each call). +proc buildEnvelopeFunctionWithMap(code: string, argNames: seq[string], fnName: string, filename: string): tuple[code: string, sourceMap: SourceLineMap] = + ## Create a named function returning a BigInt-safe JSON envelope. var decls = newSeq[string]() for rawName in argNames: let lc = rawName.toLowerAscii @@ -154,32 +196,68 @@ proc buildEnvelopeFunction(code: string, argNames: seq[string], fnName: string): continue let ident = toJsIdent(rawName) decls.add("const " & ident & " = __args[\"" & jsQuote(rawName) & "\"];") - let declBlock = decls.join("\n") - - result = """ -function """ & fnName & """() { - "use strict"; - """ & declBlock & """ - try { - const __v = ((state, args, context) => (""" & code & """))(__state, __args, __context); - const __k = (__v === null) ? "null" : (Array.isArray(__v) ? "array" : typeof __v); - const json = (typeof __v === 'undefined') - ? JSON.stringify({ k: __k }) - : JSON.stringify({ k: __k, v: __v }, __jsReplacer); - return json; - } catch (e) { - const msg = (e && e.stack) ? e.stack : String(e); - return JSON.stringify({ k: "error", v: { message: String(e && e.message || e), stack: msg } }); - } -} -""" + + var mapLines: seq[int] = @[0] + var mapSegments: seq[SourceMapSegment] = @[] + template addGeneratedLine(line: string, sourceLine: int = 0) = + if result.code.len > 0: + result.code.add("\n") + result.code.add(line) + mapLines.add(sourceLine) + + addGeneratedLine("function " & fnName & "() {") + addGeneratedLine(" \"use strict\";") + for decl in decls: + addGeneratedLine(" " & decl) + addGeneratedLine(" try {") + + let sourceLines = code.splitLines() + if sourceLines.len == 0: + addGeneratedLine(" const __v = ((state, args, context) => ())(__state, __args, __context);") + else: + for index, line in sourceLines: + let sourceLine = index + 1 + if index == 0 and index == sourceLines.high: + let prefix = " const __v = ((state, args, context) => (" + addGeneratedLine(prefix & line & "))(__state, __args, __context);", sourceLine) + mapSegments.add(SourceMapSegment(generatedLine: mapLines.len - 1, generatedColumn: prefix.len + 1, sourceLine: sourceLine, sourceColumn: 1)) + elif index == 0: + let prefix = " const __v = ((state, args, context) => (" + addGeneratedLine(prefix & line, sourceLine) + mapSegments.add(SourceMapSegment(generatedLine: mapLines.len - 1, generatedColumn: prefix.len + 1, sourceLine: sourceLine, sourceColumn: 1)) + elif index == sourceLines.high: + addGeneratedLine(line & "))(__state, __args, __context);", sourceLine) + mapSegments.add(SourceMapSegment(generatedLine: mapLines.len - 1, generatedColumn: 1, sourceLine: sourceLine, sourceColumn: 1)) + else: + addGeneratedLine(line, sourceLine) + mapSegments.add(SourceMapSegment(generatedLine: mapLines.len - 1, generatedColumn: 1, sourceLine: sourceLine, sourceColumn: 1)) + addGeneratedLine(" const __k = (__v === null) ? \"null\" : (Array.isArray(__v) ? \"array\" : typeof __v);") + addGeneratedLine(" const json = (typeof __v === 'undefined')") + addGeneratedLine(" ? JSON.stringify({ k: __k })") + addGeneratedLine(" : JSON.stringify({ k: __k, v: __v }, __jsReplacer);") + addGeneratedLine(" return json;") + addGeneratedLine(" } catch (e) {") + addGeneratedLine(" const msg = (e && e.stack) ? e.stack : String(e);") + addGeneratedLine(" return JSON.stringify({ k: \"error\", v: { message: String(e && e.message || e), stack: msg } });") + addGeneratedLine(" }") + addGeneratedLine("}") + + result.sourceMap = SourceLineMap( + generatedName: filename, + sourceName: filename, + generatedToSourceLine: mapLines, + segments: mapSegments + ) + +proc buildEnvelopeFunction(code: string, argNames: seq[string], fnName: string): string = + buildEnvelopeFunctionWithMap(code, argNames, fnName, "").code # ------------------------- # QuickJS bridge utilities # ------------------------- proc env(ctx: ptr JSContext): EvalEnv = - if evalEnvByCtx.hasKey(ctx): evalEnvByCtx[ctx] else: nil + if currentEvalCtx == ctx: currentEvalEnv else: nil # Return an object sentinel to represent "undefined" without using JS_UNDEFINED across the C boundary. proc jsUndefSentinel(ctx: ptr JSContext): JSValue {.inline.} = @@ -209,7 +287,7 @@ proc jsLog(ctx: ptr JSContext, level: JSValue, payloadJson: JSValue): JSValue {. return jsUndefSentinel(ctx) # Convert Nim JsonNode -> JSValue (objects/arrays included). -proc jsonToJS(ctx: ptr JSContext, j: JsonNode): JSValue = +proc jsonToJS*(ctx: ptr JSContext, j: JsonNode): JSValue = if j.isNil: return jsNull(ctx) case j.kind of JNull: return jsNull(ctx) @@ -237,8 +315,28 @@ proc jsonToJS(ctx: ptr JSContext, j: JsonNode): JSValue = inc idx return arr -proc valueToJS(ctx: ptr JSContext, v: Value): JSValue = - jsonToJS(ctx, valueToJson(v)) +proc valueToJS*(ctx: ptr JSContext, v: Value): JSValue = + case v.kind + of fkString, fkText: + return nimStringToJS(ctx, v.s) + of fkFloat: + return nimFloatToJS(ctx, v.f) + of fkInteger: + if v.i >= low(int32).int64 and v.i <= high(int32).int64: + return nimIntToJS(ctx, v.i.int32) + return nimFloatToJS(ctx, v.i.float64) + of fkBoolean: + return nimBoolToJS(ctx, v.b) + of fkColor: + return nimStringToJS(ctx, v.col.toHtmlHex) + of fkJson: + return jsonToJS(ctx, v.j) + of fkNode: + return nimIntToJS(ctx, v.nId.int32) + of fkScene: + return nimStringToJS(ctx, v.sId.string) + of fkImage, fkNone: + return jsNull(ctx) proc jsGetState(ctx: ptr JSContext, k: JSValue): JSValue {.nimcall.} = let key = toNimString(ctx, k) @@ -352,41 +450,172 @@ proc envelopeToValue(env: JsonNode, expectedType: string): Value = else: return Value(kind: fkNone) +proc stringifyJsValue*(ctx: ptr JSContext, val: JSValueConst): string = + let globalObj = JS_GetGlobalObject(ctx) + defer: JS_FreeValue(ctx, globalObj) + + let stringifyFn = JS_GetPropertyStr(ctx, globalObj, "__frameosStringify") + defer: JS_FreeValue(ctx, stringifyFn) + + if JS_IsFunction(ctx, stringifyFn) != 0: + var argv: array[1, JSValueConst] + argv[0] = val + let strVal = JS_Call(ctx, stringifyFn, globalObj, 1.cint, addr argv[0]) + defer: JS_FreeValue(ctx, strVal) + if JS_IsException(strVal) == 0: + return toNimString(ctx, strVal) + + let fallback = JS_JSONStringify(ctx, val, jsUndefined(ctx), jsUndefined(ctx)) + defer: JS_FreeValue(ctx, fallback) + if JS_IsException(fallback) == 0: + return toNimString(ctx, fallback) + return "null" + +proc jsValueToJson*(ctx: ptr JSContext, val: JSValueConst): JsonNode = + if jsIsUndefined(val) or jsIsNull(val): + return newJNull() + if jsIsBool(val): + return %*toNimBool(ctx, val) + if jsIsNumber(val): + let f = toNimFloat(ctx, val) + if f >= low(int64).float64 and f <= high(int64).float64: + let i = f.int64 + if i.float64 == f: + return %*i + return %*f + if jsIsBigInt(ctx, val): + try: + return %*toNimInt64Ext(ctx, val) + except CatchableError: + return %*toNimString(ctx, val) + if jsIsString(val): + return %*toNimString(ctx, val) + + let jsonText = stringifyJsValue(ctx, val) + try: + return parseJson(jsonText) + except CatchableError: + return %*jsonText + +proc jsValueToValue*(ctx: ptr JSContext, val: JSValueConst, expectedType: string = ""): Value = + if expectedType.len > 0: + if jsIsUndefined(val) or jsIsNull(val): + return valueFromJsonByType(newJNull(), expectedType) + case expectedType + of "float": + if jsIsNumber(val): + return VFloat(toNimFloat(ctx, val)) + of "integer": + if jsIsNumber(val): + return VInt(toNimFloat(ctx, val).int64) + of "boolean": + if jsIsBool(val): + return VBool(toNimBool(ctx, val)) + of "string": + if jsIsString(val): + return VString(toNimString(ctx, val)) + of "text": + if jsIsString(val): + return VText(toNimString(ctx, val)) + of "json": + return VJson(jsValueToJson(ctx, val)) + else: + discard + return valueFromJsonByType(jsValueToJson(ctx, val), expectedType) + + if jsIsUndefined(val): + return Value(kind: fkNone) + if jsIsNull(val): + return Value(kind: fkJson, j: newJNull()) + if jsIsBool(val): + return Value(kind: fkBoolean, b: toNimBool(ctx, val)) + if jsIsNumber(val): + let f = toNimFloat(ctx, val) + if f >= low(int64).float64 and f <= high(int64).float64: + let i = f.int64 + if i.float64 == f: + return Value(kind: fkInteger, i: i) + return Value(kind: fkFloat, f: f) + if jsIsBigInt(ctx, val): + try: + return Value(kind: fkInteger, i: toNimInt64Ext(ctx, val)) + except CatchableError: + return Value(kind: fkString, s: toNimString(ctx, val)) + if jsIsString(val): + return Value(kind: fkString, s: toNimString(ctx, val)) + + return Value(kind: fkJson, j: jsValueToJson(ctx, val)) + +proc jsExceptionDetails*(ctx: ptr JSContext): tuple[message: string, stack: string] = + let exception = JS_GetException(ctx) + defer: JS_FreeValue(ctx, exception) + if jsIsObject(exception): + let messageVal = JS_GetPropertyStr(ctx, exception, "message") + defer: JS_FreeValue(ctx, messageVal) + let stackVal = JS_GetPropertyStr(ctx, exception, "stack") + defer: JS_FreeValue(ctx, stackVal) + result.message = toNimString(ctx, messageVal) + result.stack = toNimString(ctx, stackVal) + else: + result.message = toNimString(ctx, exception) + result.stack = result.message + if result.message.len == 0: + result.message = "JavaScript error" + +proc mappedJsExceptionDetails*(ctx: ptr JSContext): tuple[message: string, stack: string] = + result = jsExceptionDetails(ctx) + result.message = mapJsErrorText(ctx, result.message) + result.stack = mapJsErrorText(ctx, result.stack) + +proc callGlobalFunction*(ctx: ptr JSContext, fnName: string, args: openArray[JSValueConst] = []): JSValue = + let globalObj = JS_GetGlobalObject(ctx) + defer: JS_FreeValue(ctx, globalObj) + let fn = JS_GetPropertyStr(ctx, globalObj, fnName.cstring) + defer: JS_FreeValue(ctx, fn) + if args.len == 0: + return JS_Call(ctx, fn, globalObj, 0.cint, nil) + var argv = newSeq[JSValueConst](args.len) + for i, arg in args: + argv[i] = arg + return JS_Call(ctx, fn, globalObj, args.len.cint, addr argv[0]) + # ------------------------- # Scene JS context # ------------------------- proc ensureSceneJs*(scene: InterpretedFrameScene) = - if scene.jsReady: return - scene.js = newQuickJS() - # Register bridge functions ONCE per scene/context - scene.js.registerFunction("getState", jsGetState) - scene.js.registerFunction("getArg", jsGetArg) - scene.js.registerFunction("getContext", jsGetContext) - scene.js.registerFunction("jsLog", jsLog) - scene.js.registerFunction("parseTs", jsChronoParseTs) - scene.js.registerFunction("format", jsChronoFormat) - scene.js.registerFunction("now", jsChronoNow) - discard scene.js.eval(""" - "use strict"; - const __jsReplacer = (k, v) => - (typeof v === 'bigint') ? { __bigint: v.toString() } - : (v === undefined ? null : v); - const console = { - log: (...a) => jsLog("log", JSON.stringify(a, __jsReplacer)), - warn: (...a) => jsLog("warn", JSON.stringify(a, __jsReplacer)), - error: (...a) => jsLog("error", JSON.stringify(a, __jsReplacer)), - }; - const __frameosUnwrap = (v) => (v && v.__frameosUndef === true) ? undefined : v; - const __state = new Proxy({}, { get(_, k) { return (typeof k === 'string') ? __frameosUnwrap(getState(k)) : undefined; } }); - const __args = new Proxy({}, { get(_, k) { return (typeof k === 'string') ? __frameosUnwrap(getArg(k)) : undefined; } }); - const __context = new Proxy({}, { get(_, k) { return (typeof k === 'string') ? __frameosUnwrap(getContext(k)) : undefined; } }); - """ & sceneJsPrelude) - # Initialize registries - scene.jsFuncNameByNode = initTable[NodeId, string]() - scene.codeInlineFuncNameByNodeArg = initTable[NodeId, Table[string, string]]() - scene.appInlineFuncNameByNodeArg = initTable[NodeId, Table[string, string]]() - scene.jsReady = true + withLock sceneJsLock: + if scene.jsReady: return + scene.js = newQuickJS() + # Register bridge functions ONCE per scene/context + scene.js.registerFunction("getState", jsGetState) + scene.js.registerFunction("getArg", jsGetArg) + scene.js.registerFunction("getContext", jsGetContext) + scene.js.registerFunction("jsLog", jsLog) + scene.js.registerFunction("parseTs", jsChronoParseTs) + scene.js.registerFunction("format", jsChronoFormat) + scene.js.registerFunction("now", jsChronoNow) + discard scene.js.eval(""" + "use strict"; + const __jsReplacer = (k, v) => + (typeof v === 'bigint') ? { __bigint: v.toString() } + : (v === undefined ? null : v); + globalThis.__frameosStringify = (v) => JSON.stringify(v, __jsReplacer); + const console = { + log: (...a) => jsLog("log", JSON.stringify(a, __jsReplacer)), + warn: (...a) => jsLog("warn", JSON.stringify(a, __jsReplacer)), + error: (...a) => jsLog("error", JSON.stringify(a, __jsReplacer)), + }; + const __frameosUnwrap = (v) => (v && v.__frameosUndef === true) ? undefined : v; + const __state = new Proxy({}, { get(_, k) { return (typeof k === 'string') ? __frameosUnwrap(getState(k)) : undefined; } }); + const __args = new Proxy({}, { get(_, k) { return (typeof k === 'string') ? __frameosUnwrap(getArg(k)) : undefined; } }); + const __context = new Proxy({}, { get(_, k) { return (typeof k === 'string') ? __frameosUnwrap(getContext(k)) : undefined; } }); + """ & sceneJsPrelude) + # Initialize registries + scene.jsFuncNameByNode = initTable[NodeId, string]() + scene.codeInlineFuncNameByNodeArg = initTable[NodeId, Table[string, string]]() + scene.appInlineFuncNameByNodeArg = initTable[NodeId, Table[string, string]]() + scene.jsReady = true # ------------------------- # Code function naming @@ -417,12 +646,17 @@ proc compileInlineFn(scene: InterpretedFrameScene, nameBuilder: InlineNameProc) = ensureSceneJs(scene) let fnName = nameBuilder(scene, nodeId, name) + let filename = "" + var sourceMap = buildEnvelopeFunctionWithMap(snippet, @[], fnName, filename).sourceMap try: - let src = transpileSource(buildEnvelopeFunction(snippet, @[], fnName), - "") - discard scene.js.eval(src) + let envelope = buildEnvelopeFunctionWithMap(snippet, @[], fnName, filename) + let transformed = transpileSourceWithMap(envelope.code, filename) + sourceMap = composeSourceLineMaps(transformed.sourceMap, envelope.sourceMap).withGeneratedName(filename) + withLock sceneJsLock: + discard scene.js.eval(transformed.code, filename) + registerJsSourceMap(scene.js.context, sourceMap) except CatchableError as e: - logCompileError(scene, nodeId, "inline", name, snippet, e) + logCompileError(scene, nodeId, "inline", name, snippet, e, sourceMap) raise if not mappingRef.hasKey(nodeId): mappingRef[nodeId] = initTable[string, string]() @@ -457,12 +691,17 @@ proc compileCodeFn*(scene: InterpretedFrameScene, node: DiagramNode) = if k notin argNames: argNames.add(k) let fnName = uniqueCodeFnName(scene, node.id) + let filename = "" + var sourceMap = buildEnvelopeFunctionWithMap(codeSnippet, argNames, fnName, filename).sourceMap try: - let src = transpileSource(buildEnvelopeFunction(codeSnippet, argNames, fnName), - "") - discard scene.js.eval(src) + let envelope = buildEnvelopeFunctionWithMap(codeSnippet, argNames, fnName, filename) + let transformed = transpileSourceWithMap(envelope.code, filename) + sourceMap = composeSourceLineMaps(transformed.sourceMap, envelope.sourceMap).withGeneratedName(filename) + withLock sceneJsLock: + discard scene.js.eval(transformed.code, filename) + registerJsSourceMap(scene.js.context, sourceMap) except CatchableError as e: - logCompileError(scene, node.id, "code", "codeJS", codeSnippet, e) + logCompileError(scene, node.id, "code", "codeJS", codeSnippet, e, sourceMap) raise scene.jsFuncNameByNode[node.id] = fnName @@ -499,13 +738,16 @@ proc callCompiledFn*(scene: InterpretedFrameScene, targetField: targetField ) - evalEnvByCtx[scene.js.context] = e var envelopeJson = "" - try: - envelopeJson = scene.js.eval(fnName & "()") - finally: - if evalEnvByCtx.hasKey(scene.js.context): - evalEnvByCtx.del(scene.js.context) + withLock sceneJsLock: + currentEvalCtx = scene.js.context + currentEvalEnv = e + try: + envelopeJson = scene.js.eval(fnName & "()") + finally: + if currentEvalCtx == scene.js.context: + currentEvalCtx = nil + currentEvalEnv = nil var parsed: JsonNode try: @@ -515,12 +757,14 @@ proc callCompiledFn*(scene: InterpretedFrameScene, let kind = parsed{"k"}.getStr() if kind == "error": + let message = mapJsErrorText(scene.js.context, parsed{"v"}{"message"}.getStr()) + let stack = mapJsErrorText(scene.js.context, parsed{"v"}{"stack"}.getStr()) scene.logger.log(%*{ "event": "interpreter:jsError", "sceneId": scene.id.string, "nodeId": nodeId.int, - "message": parsed{"v"}{"message"}.getStr(), - "stack": parsed{"v"}{"stack"}.getStr() + "message": message, + "stack": stack }) if expectedType.len > 0: if expectedType == "string": return Value(kind: fkString, s: "") @@ -573,12 +817,17 @@ proc evalSnippet*( ensureSceneJs(scene) inc anonCounter let fnName = "__frameos_eval_" & $(nodeId.int) & "_" & $anonCounter + let filename = "" + var sourceMap = buildEnvelopeFunctionWithMap(code, argNames, fnName, filename).sourceMap try: - let src = transpileSource(buildEnvelopeFunction(code, argNames, fnName), - "") - discard scene.js.eval(src) + let envelope = buildEnvelopeFunctionWithMap(code, argNames, fnName, filename) + let transformed = transpileSourceWithMap(envelope.code, filename) + sourceMap = composeSourceLineMaps(transformed.sourceMap, envelope.sourceMap).withGeneratedName(filename) + withLock sceneJsLock: + discard scene.js.eval(transformed.code, filename) + registerJsSourceMap(scene.js.context, sourceMap) except CatchableError as e: - logCompileError(scene, nodeId, "eval", fnName, code, e) + logCompileError(scene, nodeId, "eval", fnName, code, e, sourceMap) raise var outs = outputTypes @@ -602,25 +851,22 @@ proc transpileSourceForTest*(source: string, filename: string = ""): strin transpileSource(source, filename) proc cleanupCompilerJs*() = - withLock compilerJsLock: - if not compilerJsReady: - return - if compilerJs.runtime != nil: - compilerJs.runPendingJobs() - JS_RunGC(compilerJs.runtime) - compilerJs.close() - compilerJsReady = false + discard proc cleanupSceneJs*(scene: InterpretedFrameScene) = - if not scene.jsReady: - return - if scene.js.context != nil and evalEnvByCtx.hasKey(scene.js.context): - evalEnvByCtx.del(scene.js.context) - if scene.js.runtime != nil: - scene.js.runPendingJobs() - JS_RunGC(scene.js.runtime) - scene.js.close() - scene.jsReady = false - scene.jsFuncNameByNode = initTable[NodeId, string]() - scene.codeInlineFuncNameByNodeArg = initTable[NodeId, Table[string, string]]() - scene.appInlineFuncNameByNodeArg = initTable[NodeId, Table[string, string]]() + withLock sceneJsLock: + if not scene.jsReady: + return + if scene.js.context != nil and currentEvalCtx == scene.js.context: + currentEvalCtx = nil + currentEvalEnv = nil + if scene.js.context != nil: + clearJsSourceMaps(scene.js.context) + if scene.js.runtime != nil: + scene.js.runPendingJobs() + JS_RunGC(scene.js.runtime) + scene.js.close() + scene.jsReady = false + scene.jsFuncNameByNode = initTable[NodeId, string]() + scene.codeInlineFuncNameByNodeArg = initTable[NodeId, Table[string, string]]() + scene.appInlineFuncNameByNodeArg = initTable[NodeId, Table[string, string]]() diff --git a/frameos/src/frameos/js_runtime/source_map.nim b/frameos/src/frameos/js_runtime/source_map.nim new file mode 100644 index 000000000..cbd987940 --- /dev/null +++ b/frameos/src/frameos/js_runtime/source_map.nim @@ -0,0 +1,226 @@ +import std/[sequtils, strutils] + +type + SourceMapSegment* = object + generatedLine*: int + generatedColumn*: int + sourceLine*: int + sourceColumn*: int + + SourceLineMap* = object + generatedName*: string + sourceName*: string + generatedToSourceLine*: seq[int] + segments*: seq[SourceMapSegment] + +proc sourceLineCount*(source: string): int = + result = 1 + for ch in source: + if ch == '\n': + inc result + +proc emptySourceLineMap*(generatedName, sourceName: string, generatedLineCount = 1): SourceLineMap = + result.generatedName = generatedName + result.sourceName = sourceName + result.generatedToSourceLine = newSeq[int](max(1, generatedLineCount) + 1) + +proc identitySourceLineMap*(source, generatedName, sourceName: string): SourceLineMap = + result = emptySourceLineMap(generatedName, sourceName, source.sourceLineCount()) + for line in 1..= lcs[generatedIndex][sourceIndex + 1]: + inc generatedIndex + else: + inc sourceIndex + + for line in 1.. 0: + let estimated = lastSourceLine + (line - lastGeneratedLine) + if estimated >= 1 and estimated <= max(1, sourceLines.len): + result.generatedToSourceLine[line] = estimated + elif line <= sourceLines.len: + result.generatedToSourceLine[line] = line + if result.generatedToSourceLine[line] > 0 and line <= generatedLines.len and result.generatedToSourceLine[line] <= sourceLines.len: + result.addLineSegments(line, generatedLines[line - 1], result.generatedToSourceLine[line], sourceLines[result.generatedToSourceLine[line] - 1]) + +proc withGeneratedName*(sourceMap: SourceLineMap, generatedName: string): SourceLineMap = + result = sourceMap + result.generatedName = generatedName + +proc mapGeneratedLine*(sourceMap: SourceLineMap, generatedLine: int): int = + if generatedLine > 0 and generatedLine < sourceMap.generatedToSourceLine.len: + sourceMap.generatedToSourceLine[generatedLine] + else: + 0 + +proc mapGeneratedPosition*(sourceMap: SourceLineMap, generatedLine, generatedColumn: int): tuple[line: int, column: int] = + result.line = sourceMap.mapGeneratedLine(generatedLine) + result.column = if generatedColumn > 0: generatedColumn else: 1 + + var best: SourceMapSegment + var hasBest = false + for segment in sourceMap.segments: + if segment.generatedLine == generatedLine and segment.generatedColumn <= result.column: + if not hasBest or segment.generatedColumn > best.generatedColumn: + best = segment + hasBest = true + + if hasBest: + result.line = best.sourceLine + result.column = max(1, best.sourceColumn + (result.column - best.generatedColumn)) + +proc composeSourceLineMaps*(outer, inner: SourceLineMap): SourceLineMap = + result = emptySourceLineMap( + outer.generatedName, + if inner.sourceName.len > 0: inner.sourceName else: outer.sourceName, + max(0, outer.generatedToSourceLine.len - 1) + ) + for line in 1.. 0 and intermediateLine < inner.generatedToSourceLine.len: + result.generatedToSourceLine[line] = inner.generatedToSourceLine[intermediateLine] + for segment in outer.segments: + let mapped = inner.mapGeneratedPosition(segment.sourceLine, segment.sourceColumn) + if mapped.line > 0: + result.segments.add(SourceMapSegment( + generatedLine: segment.generatedLine, + generatedColumn: segment.generatedColumn, + sourceLine: mapped.line, + sourceColumn: mapped.column + )) + +proc addSourceSegment*(sourceMap: var SourceLineMap, generatedLine, generatedColumn, sourceLine, sourceColumn: int) = + if generatedLine <= 0 or generatedColumn <= 0 or sourceLine <= 0 or sourceColumn <= 0: + return + sourceMap.segments.add(SourceMapSegment( + generatedLine: generatedLine, + generatedColumn: generatedColumn, + sourceLine: sourceLine, + sourceColumn: sourceColumn + )) + +proc rewriteQuickJsLocations*(text: string, sourceMap: SourceLineMap): string = + if text.len == 0 or sourceMap.generatedName.len == 0: + return text + + var i = 0 + while i < text.len: + let at = text.find(sourceMap.generatedName & ":", i) + if at < 0: + result.add(text[i..^1]) + break + + result.add(text[i.. columnStart + + let generatedColumn = + if hasColumn: parseInt(text[columnStart.. 0: + result.add(sourceMap.sourceName) + result.add(":") + result.add($mapped.line) + if hasColumn: + result.add(":") + result.add($mapped.column) + else: + result.add(text[at..<(if hasColumn: columnEnd else: lineEnd)]) + i = if hasColumn: columnEnd else: lineEnd diff --git a/frameos/src/frameos/js_runtime/tests/test_js_app_runtime.nim b/frameos/src/frameos/js_runtime/tests/test_js_app_runtime.nim new file mode 100644 index 000000000..20a73d9e3 --- /dev/null +++ b/frameos/src/frameos/js_runtime/tests/test_js_app_runtime.nim @@ -0,0 +1,331 @@ +import std/[json, sequtils, strutils, tables, unittest] +import pixie + +import frameos/js_runtime/app_runtime +import frameos/types +import frameos/values + +proc testConfig(): FrameConfig = + FrameConfig( + width: 6, + height: 4, + rotate: 0, + scalingMode: "cover", + debug: true, + saveAssets: %*false, + assetsPath: "/tmp" + ) + +proc testLogger(config: FrameConfig): Logger = + var logger = Logger(frameConfig: config, enabled: true) + logger.log = proc(payload: JsonNode) = + discard payload + logger.enable = proc() = + logger.enabled = true + logger.disable = proc() = + logger.enabled = false + logger + +suite "js app runtime": + test "returns string, node, and image values": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 7.NodeId, nodeName: "jsText", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "data", + outputType = "text", + source = """export const get = (app: { config: { mode: string; message?: string; targetNode?: number } }, context: { event: string }) => { + if (app.config.mode === "image") { + return + } + if (app.config.mode === "node") { + return frameos.node(app.config.targetNode) + } + return `${app.config.message}:${context.event}` + }""" + ) + + let textValue = runtime.get(owner, %*{"message": "hello", "mode": "text"}, context) + check textValue.kind == fkString + check textValue.asString() == "hello:render" + + let nodeValue = runtime.get(owner, %*{"mode": "node", "targetNode": 9}, context) + check nodeValue.kind == fkNode + check nodeValue.asNode() == 9.NodeId + + let imageValue = runtime.get(owner, %*{"mode": "image"}, context) + check imageValue.kind == fkImage + check imageValue.asImage().width == 3 + check imageValue.asImage().height == 2 + + test "run can set next sleep, state, and draw a render image": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app-run".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 8.NodeId, nodeName: "jsLogic", scene: scene, frameConfig: config) + var image = newImage(4, 3) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: true, image: image, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "render", + outputType = "image", + source = """export function run(app: { config: { duration: number } }) { + frameos.setNextSleep(app.config.duration) + frameos.setState("lastDuration", app.config.duration) + return + }""" + ) + + runtime.run(owner, %*{"duration": 12.5}, context) + check abs(context.nextSleep - 12.5) < 0.0001 + check scene.state["lastDuration"].getFloat() == 12.5 + let pixel = context.image.data[context.image.dataIndex(0, 0)] + check pixel.r > 0 + check runtime.images.len == 0 + + test "clears transient context image refs after JS calls": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app-image-refs".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 9.NodeId, nodeName: "jsImageRefs", scene: scene, frameConfig: config) + + let runtime = newJsAppRuntime( + category = "data", + outputType = "image", + source = """export function get(app, context) { + return context.image + }""" + ) + + for i in 0..<3: + let image = newImage(4 + i, 3) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: true, image: image, loopIndex: i, loopKey: ".", nextSleep: -1) + let value = runtime.get(owner, %*{}, context) + check value.kind == fkImage + check value.asImage().width == 4 + i + check value.asImage().height == 3 + check runtime.images.len == 0 + + test "runs typed template literal interpolations": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app-template".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 11.NodeId, nodeName: "jsTemplate", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "data", + outputType = "text", + source = """export function get(app: FrameOSApp): string { + const label = app.config.label as string + return `${label as string}` + }""" + ) + + let value = runtime.get(owner, %*{"label": "FrameOS"}, context) + check value.kind == fkString + check value.asString() == "FrameOS" + + test "runs text app template init and get functions": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app-text-template".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 12.NodeId, nodeName: "jsText", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "data", + outputType = "text", + source = """export function init(app: FrameOSApp): void { + app.initialized = true + } + + export function get(app: FrameOSApp, context: FrameOSContext): string { + const eventLabel = context.event ? ` (${context.event})` : '' + return `${app.config.prefix}: ${app.config.message}${app.initialized ? eventLabel : ''}` + }""" + ) + + let value = runtime.get(owner, %*{"prefix": "FrameOS", "message": "Hello"}, context) + check value.kind == fkString + check value.asString() == "FrameOS: Hello (render)" + + test "runs image app template frameos.image output": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app-image-template".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 13.NodeId, nodeName: "jsImage", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "data", + outputType = "image", + source = """export function get(app: FrameOSApp): FrameOSImageSpec { + return frameos.image({ + width: app.config.width, + height: app.config.height, + color: app.config.color, + opacity: app.config.opacity, + }) + }""" + ) + + let value = runtime.get(owner, %*{"width": 5, "height": 3, "color": "#00ff00", "opacity": 0.5}, context) + check value.kind == fkImage + check value.asImage().width == 5 + check value.asImage().height == 3 + let pixel = value.asImage().data[value.asImage().dataIndex(0, 0)] + check pixel.g > 0 + check pixel.a > 0 + + test "runs logic app template logging path": + let config = testConfig() + var logged: seq[JsonNode] = @[] + var logger = testLogger(config) + logger.log = proc(payload: JsonNode) = + logged.add(payload) + let scene = FrameScene(id: "tests/js-app-logic-template".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 14.NodeId, nodeName: "jsLogic", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "logic", + outputType = "", + source = """export function run(app: FrameOSApp, context: FrameOSContext): void { + const stateKey = app.config.stateKey || 'jsLogicResult' + app.log('JS logic app ran', { event: context.event, stateKey }) + }""" + ) + + runtime.run(owner, %*{"stateKey": "customState"}, context) + check logged.len > 0 + check logged[^1]["event"].getStr() == "log:14:jsLogic" + check "JS logic app ran" in logged[^1]["message"].getStr() + check "customState" in logged[^1]["message"].getStr() + + test "runs modern ES syntax supported by QuickJS": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app-modern-es".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 15.NodeId, nodeName: "jsModernEs", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "data", + outputType = "integer", + source = """export function get(app: FrameOSApp): number { + class Counter { + static label = "counter" + #step = 1n + value = 1_000 + increment = () => { + this.value += Number(this.#step) + return this.value + } + } + try { + const counter = new Counter() + let configured = app.config?.nested?.count ?? 0 + configured ||= counter.increment() + const regex = /frame\s*os/i + return regex.test("Frame OS") && Counter.label === "counter" ? configured : 0 + } catch { + return -1 + } + }""" + ) + + let fallbackValue = runtime.get(owner, %*{}, context) + check fallbackValue.kind == fkInteger + check fallbackValue.asInt() == 1001 + + let configuredValue = runtime.get(owner, %*{"nested": {"count": 42}}, context) + check configuredValue.kind == fkInteger + check configuredValue.asInt() == 42 + + test "lazy app proxies support keys and spread": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app-proxy-keys".SceneId, frameConfig: config, state: %*{"seen": true}, logger: logger) + let owner = AppRoot(nodeId: 11.NodeId, nodeName: "jsProxyKeys", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "data", + outputType = "json", + source = """export function get(app, context) { + return { + configKeys: Object.keys(app.config).sort(), + stateKeys: Object.keys(app.state).sort(), + frameKeys: Object.keys(app.frame).sort(), + contextKeys: Object.keys(context).sort(), + spreadConfig: { ...app.config }, + } + }""" + ) + + let value = runtime.get(owner, %*{"message": "hello", "mode": "text"}, context) + check value.kind == fkJson + let payload = value.asJson() + check payload["configKeys"][0].getStr() == "message" + check payload["configKeys"][1].getStr() == "mode" + check payload["stateKeys"][0].getStr() == "seen" + check "width" in payload["frameKeys"].mapIt(it.getStr()) + check "event" in payload["contextKeys"].mapIt(it.getStr()) + check payload["spreadConfig"]["message"].getStr() == "hello" + + test "maps JS app runtime errors to original source lines": + let config = testConfig() + var logged: seq[JsonNode] = @[] + var logger = testLogger(config) + logger.log = proc(payload: JsonNode) = + logged.add(payload) + let scene = FrameScene(id: "tests/js-app-error-map".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 16.NodeId, nodeName: "jsErrorMap", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + let runtime = newJsAppRuntime( + category = "data", + outputType = "text", + source = """export function get(app: FrameOSApp): string { + const value: number = 1 + throw new Error("app mapped boom") + }""" + ) + + discard runtime.get(owner, %*{}, context) + let stackLogs = logged.filterIt("jsApp:error" in it{"event"}.getStr()) + check stackLogs.len == 1 + check ">:3:" in stackLogs[0]{"stack"}.getStr() + + test "releases overwritten dynamic field image refs": + let config = testConfig() + let logger = testLogger(config) + let scene = FrameScene(id: "tests/js-app-field-refs".SceneId, frameConfig: config, state: %*{}, logger: logger) + let runtime = newJsAppRuntime(category = "data", outputType = "image", source = "export const get = () => null") + let app = DynamicJsApp( + nodeId: 10.NodeId, + nodeName: "jsFieldRefs", + scene: scene, + frameConfig: config, + configJson: %*{}, + runtime: runtime + ) + + setDynamicJsAppField(app, "inputImage", VImage(newImage(4, 3))) + check runtime.images.len == 1 + let firstId = app.configJson["inputImage"]["id"].getInt() + check runtime.images.hasKey(firstId) + + setDynamicJsAppField(app, "inputImage", VImage(newImage(5, 3))) + check runtime.images.len == 1 + check not runtime.images.hasKey(firstId) + let secondId = app.configJson["inputImage"]["id"].getInt() + check secondId != firstId + check runtime.images.hasKey(secondId) + + setDynamicJsAppField(app, "inputImage", VString("not an image")) + check runtime.images.len == 0 diff --git a/frameos/src/frameos/js_runtime/tests/test_js_parser_processor.nim b/frameos/src/frameos/js_runtime/tests/test_js_parser_processor.nim new file mode 100644 index 000000000..4f7b3ebbb --- /dev/null +++ b/frameos/src/frameos/js_runtime/tests/test_js_parser_processor.nim @@ -0,0 +1,148 @@ +import std/[sequtils, strutils, unittest] + +import frameos/js_runtime/parser +import frameos/js_runtime/token_processor +import frameos/js_runtime/tokens + +proc tokensOf(code: string): seq[JsToken] = + parseJs(code).tokens + +proc tokenText(code: string, token: JsToken): string = + if token.start >= 0 and token.`end` <= code.len and token.start <= token.`end`: + code[token.start.. 0) + + test "marks import/export binding roles": + let code = """ +import DefaultThing, { value as renamed, other } from "pkg"; +export { renamed as publicName }; +export const answer = 42; +""" + check firstToken(code, "DefaultThing").identifierRole == irImportDeclaration + check firstToken(code, "value").identifierRole == irImportAccess + check firstToken(code, "renamed").identifierRole == irImportDeclaration + check firstToken(code, "other").identifierRole == irImportDeclaration + + let exportedRenamed = tokensOf(code).filterIt(tokenText(code, it) == "renamed" and it.identifierRole == irExportAccess) + check exportedRenamed.len == 1 + check firstToken(code, "answer").identifierRole == irTopLevelDeclaration + + test "marks TypeScript type spans": + let code = """ +type Alias = { value: T }; +interface Input { value?: string } +const answer: number = 42; +const label = answer as number satisfies number; +""" + let tokens = tokensOf(code) + for text in ["Alias", "Input"]: + check tokens.anyIt(tokenText(code, it) == text and it.isType) + check tokens.anyIt(tokenText(code, it) == ":" and it.isType) + check tokens.anyIt(tokenText(code, it) == "number" and it.isType) + check tokens.anyIt(tokenText(code, it) == "as" and it.isType) + check tokens.anyIt(tokenText(code, it) == "satisfies" and it.isType) + + test "marks JSX roles": + let code = """ +const empty =
; +const one =
{child}
; +const many =
{child}
; +const keyed =
; +""" + let allTokens = tokensOf(code) + var jsxStarts: seq[JsToken] = @[] + for index, token in allTokens: + if token.typ == ttJsxTagStart and (index + 1 >= allTokens.len or allTokens[index + 1].typ != ttSlash): + jsxStarts.add(token) + check jsxStarts[0].jsxRole == jsxNoChildren + check jsxStarts[1].jsxRole == jsxOneChild + check jsxStarts[2].jsxRole == jsxStaticChildren + check jsxStarts[^1].jsxRole == jsxKeyAfterPropSpread + + test "marks optional chain and nullish boundaries": + let code = "const result = app.config?.nested?.count ?? 1;" + let tokens = tokensOf(code) + check tokens.anyIt(it.isOptionalChainStart) + check tokens.anyIt(it.isOptionalChainEnd) + check tokens.anyIt(it.numNullishCoalesceStarts > 0) + check tokens.anyIt(it.numNullishCoalesceEnds > 0) + +suite "native js token processor": + test "copies all tokens while preserving source": + let code = "const value = 1;\n// trailing\n" + var processor = initTokenProcessor(code, tokenizeJs(code)) + check processor.copyAll().code == code + + test "removes annotated type tokens while preserving runtime whitespace": + let code = "const value: number = 1;\n" + let parsed = parseJs(code) + var processor = initTokenProcessor(code, parsed.tokens) + while not processor.isAtEnd(): + if processor.currentToken().isType: + processor.removeToken() + else: + processor.copyToken() + let output = processor.finish().code + check "value: number" notin output + check "const value = 1;" in output + + test "replaces tokens and records mappings": + let code = "const value = 1;" + var processor = initTokenProcessor(code, tokenizeJs(code)) + while not processor.isAtEnd(): + if processor.currentTokenCode() == "value": + processor.replaceToken("renamed") + else: + processor.copyToken() + let result = processor.finish() + check result.code == "const renamed = 1;" + var valueIndex = -1 + for index, token in tokenizeJs(code): + if tokenText(code, token) == "value": + valueIndex = index + break + check result.mappings[valueIndex] == "const ".len + + test "supports snapshots for lookahead-style rewrites": + let code = "const value = 1;" + var processor = initTokenProcessor(code, tokenizeJs(code)) + let snapshot = processor.snapshot() + processor.copyToken() + processor.copyToken() + let removed = processor.dangerouslyGetAndRemoveCodeSinceSnapshot(snapshot) + check removed == "const value" + processor.restoreToSnapshot(snapshot) + processor.replaceToken("let") + while not processor.isAtEnd(): + processor.copyToken() + check processor.finish().code == "let value = 1;" diff --git a/frameos/src/frameos/tests/test_js_runtime_helpers.nim b/frameos/src/frameos/js_runtime/tests/test_js_runtime_helpers.nim similarity index 83% rename from frameos/src/frameos/tests/test_js_runtime_helpers.nim rename to frameos/src/frameos/js_runtime/tests/test_js_runtime_helpers.nim index ffe8db88f..11aca8028 100644 --- a/frameos/src/frameos/tests/test_js_runtime_helpers.nim +++ b/frameos/src/frameos/js_runtime/tests/test_js_runtime_helpers.nim @@ -1,8 +1,8 @@ -import std/[json, strutils, unittest] +import std/[json, sequtils, strutils, unittest] -import ../js_runtime -import ../types -import ../values +import frameos/js_runtime/runtime +import frameos/types +import frameos/values proc testScene(): InterpretedFrameScene = InterpretedFrameScene( @@ -113,6 +113,27 @@ function demo(input: number) { cleanupSceneJs(scene) cleanupCompilerJs() + test "evalSnippet maps runtime errors to original source lines": + var logs: seq[JsonNode] = @[] + var scene = testScene() + scene.logger.log = proc(payload: JsonNode) = + logs.add(payload) + + let value = evalSnippet( + scene, + testContext(scene), + 2.NodeId, + "(() => {\n const count: number = 1\n throw new Error(\"mapped boom\")\n})()" + ) + + check value.kind == fkNone + let errorLogs = logs.filterIt(it{"event"}.getStr() == "interpreter:jsError") + check errorLogs.len == 1 + check "mapped boom" in errorLogs[0]{"message"}.getStr() + check ">:3:18" in errorLogs[0]{"stack"}.getStr() + cleanupSceneJs(scene) + cleanupCompilerJs() + test "cleanupSceneJs closes the quickjs runtime": var scene = testScene() ensureSceneJs(scene) diff --git a/frameos/src/frameos/js_runtime/tests/test_js_tokens.nim b/frameos/src/frameos/js_runtime/tests/test_js_tokens.nim new file mode 100644 index 000000000..bd5c2b22b --- /dev/null +++ b/frameos/src/frameos/js_runtime/tests/test_js_tokens.nim @@ -0,0 +1,72 @@ +import std/[sequtils, unittest] + +import frameos/js_runtime/tokens + +proc tokenNames(code: string): seq[string] = + tokenizeJs(code).mapIt(formatTokenType(it.typ)) + +suite "native js tokenizer": + test "recognizes plain expressions, division, regex, and modern operators": + check tokenNames("5/3/1") == @["num", "/", "num", "/", "num", "eof"] + check tokenNames("5 + /3/") == @["num", "+", "regexp", "eof"] + check tokenNames("a?.b ?? c && d || e") == @["name", "?.", "name", "??", "name", "&&", "name", "||", "name", "eof"] + check tokenNames("value ||= 1; count &&= 2; n ??= 3") == @["name", "_=", "num", ";", "name", "_=", "num", ";", "name", "_=", "num", "eof"] + + test "distinguishes JSX from relational operators": + check tokenNames("x2") == @["name", "<", "name", ">", "num", "eof"] + check tokenNames("x + < Hello / >") == @["name", "+", "jsxTagStart", "jsxName", "/", "jsxTagEnd", "eof"] + + test "recognizes nested JSX content": + let names = tokenNames(""" +
+ Hello, world! + +
+""") + check names == @[ + "jsxTagStart", "jsxName", "jsxName", "=", "string", "jsxTagEnd", + "jsxText", "jsxTagStart", "jsxName", "jsxName", "=", "{", "name", + "}", "/", "jsxTagEnd", "jsxEmptyText", "jsxTagStart", "/", "jsxName", + "jsxTagEnd", "eof", + ] + + test "recognizes template string boundaries and expressions": + check tokenNames("`Hello, ${name} ${surname}`") == @[ + "`", "template", "${", "name", "}", "template", "${", "name", "}", + "template", "`", "eof", + ] + + test "distinguishes pre-increment and post-increment": + check tokenNames(""" +a = b +++c +d++ +e = f++ +g = ++h +""") == @[ + "name", "=", "name", "++/--", "name", "name", "++/--", "name", "=", + "name", "++/--", "name", "=", "++/--", "name", "eof", + ] + + test "tracks contextual keywords": + let tokens = tokenizeJs("""import foo from "./foo.json" with {type: "json"};""") + check tokens.mapIt(formatTokenType(it.typ)) == @[ + "import", "name", "name", "string", "with", "{", "name", ":", "string", + "}", ";", "eof", + ] + check tokens[2].contextualKeyword == ckFrom + check tokens[6].contextualKeyword == ckType + + test "recognizes private-property punctuation": + check tokenNames(""" +class { + #x = 3 +} +this.#x = 3 +delete this?.#x +if (#x in obj) { } +""") == @[ + "class", "{", "#", "name", "=", "num", "}", "this", ".", "#", "name", + "=", "num", "delete", "this", "?.", "#", "name", "if", "(", "#", + "name", "in", "name", ")", "{", "}", "eof", + ] diff --git a/frameos/src/frameos/js_runtime/tests/test_js_transpiler.nim b/frameos/src/frameos/js_runtime/tests/test_js_transpiler.nim new file mode 100644 index 000000000..a841d1d3b --- /dev/null +++ b/frameos/src/frameos/js_runtime/tests/test_js_transpiler.nim @@ -0,0 +1,268 @@ +import std/[strutils, unittest] + +import frameos/js_runtime/transpiler + +suite "native js transpiler": + test "strips common TypeScript syntax": + let output = transformFrameosScript(""" +interface Removed { value: number } +type AlsoRemoved = { value: string }; +const answer: number = 42; +function demo(input: number): number { + return input as number; +} +const fn = ({count}: {count: number}) => count satisfies number; +""") + check "interface Removed" notin output + check "type AlsoRemoved" notin output + check "answer: number" notin output + check "input: number" notin output + check "as number" notin output + check "satisfies number" notin output + check "const answer = 42" in output + + test "lowers classic JSX to FrameOS runtime calls": + let output = transformFrameosScript(""" +function demo(input: number) { + return 0}>{input as number}; +} +""") + check "__frameosJsx(\"card\"" in output + check "\"active\": input > 0" in output + check "input: number" notin output + check "as number" notin output + + test "rewrites simple app module exports": + let output = transformFrameosModule(""" +export const makeImage = (app: { config: { message?: string } }) => { + return ; +} +export function run(app: { config: { duration: number } }) { + return app.config.duration; +} +export function get(app, context) { + return context.image; +} +""") + check output.startsWith("\"use strict\";") + check "const makeImage = (app) =>" in output + check "function run(app)" in output + check "function get(app, context)" in output + check "exports.get = get;" in output + check "exports.makeImage = makeImage;" in output + check "exports.run = run;" in output + check "__frameosJsx(\"image\"" in output + + test "rewrites broader export declarations": + let output = transformFrameosModule(""" +export const first = 1, second = 2; +export async function load(): Promise { + return first + second; +} +export default async function namedDefault(): Promise { + return load(); +} +""") + check "const first = 1, second = 2;" in output + check "exports.first = first;" in output + check "exports.second = second;" in output + check "async function load()" in output + check "exports.load = load;" in output + check "async function namedDefault()" in output + check "exports.default = namedDefault;" in output + + test "lowers TypeScript enums": + let output = transformFrameosModule(""" +export enum Mode { + First, + Second = First + 2, + Label = "label", + "spaced key" = 9, +} +""") + check "var Mode; (function (Mode)" in output + check "const First = 0;" in output + check "Mode[Mode[\"First\"] = First] = \"First\";" in output + check "const Second = First + 2;" in output + check "const Label = \"label\";" in output + check "Mode[\"spaced key\"] = 9" in output + check "exports.Mode = Mode;" in output + + test "rewrites static imports to CommonJS declarations": + let output = transformFrameosModule(""" +import "./setup"; +import DefaultThing, { value as renamed, other } from "pkg"; +import * as tools from "./tools"; +export { renamed as publicName }; +export { other as remoteOther } from "pkg2"; +export * as everything from "pkg3"; +export * from "pkg4"; +""") + check "require(\"./setup\");" in output + check "require(\"pkg\")" in output + check "var DefaultThing =" in output + check "var renamed =" in output + check ".value;" in output + check "var other =" in output + check ".other;" in output + check "var tools =" in output + check "exports.publicName = renamed;" in output + check "exports.remoteOther =" in output + check "exports.everything = require(\"pkg3\");" in output + check "Object.keys(" in output + + test "rewrites TypeScript import equals": + let output = transformFrameosModule(""" +import tool = require("toolkit"); +export const value = tool.value; +""") + check "const tool = require(\"toolkit\");" in output + check "exports.value = value;" in output + + test "lowers fragments and decodes JSX entities": + let output = transformFrameosScript(""" +const value = <>A < B !; +""") + check "__frameosJsx(__frameosFragment, null" in output + check "\"Tom & Jerry\"" in output + check "\"A < B !\"" in output + + test "strips generics and TypeScript-only modifiers": + let output = transformFrameosScript(""" +abstract class Box { + public readonly value: T; + getValue(): T { + return this.value; + } + constructor(value: T) { + this.value = identity(value); + } +} +function identity(value: T): T { + return value; +} +const arrow = (value: T): T => value; +""") + check "abstract" notin output + check "public" notin output + check "readonly" notin output + check "Box" notin output + check "identity" notin output + check "(value" notin output + check "getValue()" in output + check "getValue(): T" notin output + check "function identity(value)" in output + check "const arrow = (value)" in output + check "=> value" in output + + test "strips multiple variable declarator types without touching initializers": + let output = transformFrameosScript(""" +const first: number = 1, second: string = "two", obj = { label: "ok", nested: { count: 1 } }; +let third: boolean, fourth: number = 4; +""") + check "first: number" notin output + check "second: string" notin output + check "third: boolean" notin output + check "fourth: number" notin output + check "const first = 1, second = \"two\"" in output + check "obj = { label: \"ok\", nested: { count: 1 } }" in output + + test "strips types inside template literal interpolations": + let output = transformFrameosModule(""" +export function get(app: FrameOSApp): string { + const label = app.config.label as string; + const eventLabel = app.context.event ? ` (${app.context.event})` : ''; + return `${label as string}${(app.config.title satisfies string)}`; +} +""") + check "app: FrameOSApp" notin output + check "as string" notin output + check "satisfies string" notin output + check "app.context.event ? ` (${app.context.event})` : ''" in output + check "${label" in output + check "${(app.config.title" in output + + test "strips definite assignment member annotations": + let output = transformFrameosScript(""" +class AppState { + value!: string; + optional?: number; +} +""") + check "value!: string" notin output + check "optional?: number" notin output + check "value;" in output + check "optional;" in output + + test "preserves runtime type identifiers and object keys": + let output = transformFrameosScript(""" +type Alias = { label: string }; +interface Removed { label: string } +const type = "image"; +const spec = { type: "image", props: { type: "text" } }; +function get(type: string) { + return { type }; +} +""") + check "type Alias" notin output + check "interface Removed" notin output + check "const type = \"image\";" in output + check "{ type: \"image\", props: { type: \"text\" } }" in output + check "function get(type)" in output + + test "preserves runtime as and satisfies object keys": + let output = transformFrameosScript(""" +const metadata = { as: "alias", satisfies: true }; +const alias = metadata.as as string; +const ok = metadata.satisfies satisfies boolean; +""") + check "{ as: \"alias\", satisfies: true }" in output + check "metadata.as as string" notin output + check "metadata.satisfies satisfies boolean" notin output + check "const alias = metadata.as" in output + check "const ok = metadata.satisfies" in output + + test "preserves modern ES syntax supported by QuickJS": + let output = transformFrameosScript(""" +class Counter { + static label = "counter"; + #step = 1n; + value = 1_000; + increment = () => { + this.value += Number(this.#step); + return this.value; + } +} +try { + let result = app.config?.nested?.count ?? 0; + result ||= new Counter().increment(); + const regex = /type\s*:\s*image/g; + const ratio = result / 2; +} catch { + console.log("optional catch binding"); +} +""") + check "static label = \"counter\";" in output + check "#step = 1n;" in output + check "value = 1_000;" in output + check "this.value += Number(this.#step)" in output + check "app.config?.nested?.count ?? 0" in output + check "result ||= new Counter().increment()" in output + check "/type\\s*:\\s*image/g" in output + check "result / 2" in output + check "} catch {" in output + + test "lowers constructor parameter properties": + let output = transformFrameosModule(""" +class Box { + constructor(public value: string, private readonly count: number = 1) {} +} +export function get() { + return new Box("ok").value; +} +""") + check "constructor(value, count" in output + check "this.value = value;" in output + check "this.count = count;" in output + check "public value" notin output + check "private readonly" notin output diff --git a/frameos/src/frameos/tests/test_scene_runtime_cleanup.nim b/frameos/src/frameos/js_runtime/tests/test_scene_runtime_cleanup.nim similarity index 93% rename from frameos/src/frameos/tests/test_scene_runtime_cleanup.nim rename to frameos/src/frameos/js_runtime/tests/test_scene_runtime_cleanup.nim index 71ec247dc..84fb20582 100644 --- a/frameos/src/frameos/tests/test_scene_runtime_cleanup.nim +++ b/frameos/src/frameos/js_runtime/tests/test_scene_runtime_cleanup.nim @@ -1,8 +1,8 @@ import std/[json, tables, unittest] -import ../js_runtime -import ../scenes -import ../types +import frameos/js_runtime/runtime +import frameos/scenes +import frameos/types proc testLogger(): Logger = Logger( diff --git a/frameos/src/frameos/js_runtime/token_processor.nim b/frameos/src/frameos/js_runtime/token_processor.nim new file mode 100644 index 000000000..43c3c59da --- /dev/null +++ b/frameos/src/frameos/js_runtime/token_processor.nim @@ -0,0 +1,218 @@ +# TokenProcessor-style rewrite stream for the native FrameOS JS transpiler. +# It preserves original whitespace/comments between tokens and records +# token-index to output-position mappings for future diagnostics/source maps. + +import std/sequtils + +import ./tokens + +type + TokenProcessorSnapshot* = object + resultCode*: string + tokenIndex*: int + + TokenProcessorResult* = object + code*: string + mappings*: seq[int] + + TokenProcessor* = object + code*: string + tokens*: seq[JsToken] + resultCode*: string + resultMappings*: seq[int] + tokenIndex*: int + +proc initTokenProcessor*(code: string, tokens: seq[JsToken]): TokenProcessor = + TokenProcessor( + code: code, + tokens: tokens, + resultMappings: newSeqWith(tokens.len, -1), + ) + +proc isAtEnd*(processor: TokenProcessor): bool = + processor.tokenIndex >= processor.tokens.len + +proc currentIndex*(processor: TokenProcessor): int = + processor.tokenIndex + +proc currentToken*(processor: TokenProcessor): JsToken = + if processor.isAtEnd(): + raise newException(ValueError, "Unexpectedly reached end of input.") + processor.tokens[processor.tokenIndex] + +proc tokenAtRelativeIndex*(processor: TokenProcessor, relativeIndex: int): JsToken = + let index = processor.tokenIndex + relativeIndex + if index < 0 or index >= processor.tokens.len: + raise newException(ValueError, "Token lookaround out of bounds.") + processor.tokens[index] + +proc rawCodeForToken*(processor: TokenProcessor, token: JsToken): string = + if token.start >= 0 and token.`end` <= processor.code.len and token.start <= token.`end`: + processor.code[token.start..<token.`end`] + else: + "" + +proc currentTokenCode*(processor: TokenProcessor): string = + processor.rawCodeForToken(processor.currentToken()) + +proc identifierNameForToken*(processor: TokenProcessor, token: JsToken): string = + processor.rawCodeForToken(token) + +proc identifierName*(processor: TokenProcessor): string = + processor.identifierNameForToken(processor.currentToken()) + +proc identifierNameAtIndex*(processor: TokenProcessor, index: int): string = + processor.identifierNameForToken(processor.tokens[index]) + +proc stringValueForToken*(processor: TokenProcessor, token: JsToken): string = + let raw = processor.rawCodeForToken(token) + if raw.len >= 2: + raw[1..^2] + else: + "" + +proc stringValue*(processor: TokenProcessor): string = + processor.stringValueForToken(processor.currentToken()) + +proc matches1AtIndex*(processor: TokenProcessor, index: int, t1: TokenType): bool = + index >= 0 and index < processor.tokens.len and processor.tokens[index].typ == t1 + +proc matches2AtIndex*(processor: TokenProcessor, index: int, t1, t2: TokenType): bool = + processor.matches1AtIndex(index, t1) and processor.matches1AtIndex(index + 1, t2) + +proc matches3AtIndex*(processor: TokenProcessor, index: int, t1, t2, t3: TokenType): bool = + processor.matches2AtIndex(index, t1, t2) and processor.matches1AtIndex(index + 2, t3) + +proc matches1*(processor: TokenProcessor, t1: TokenType): bool = + processor.matches1AtIndex(processor.tokenIndex, t1) + +proc matches2*(processor: TokenProcessor, t1, t2: TokenType): bool = + processor.matches2AtIndex(processor.tokenIndex, t1, t2) + +proc matches3*(processor: TokenProcessor, t1, t2, t3: TokenType): bool = + processor.matches3AtIndex(processor.tokenIndex, t1, t2, t3) + +proc matchesContextualAtIndex*(processor: TokenProcessor, index: int, keyword: ContextualKeyword): bool = + processor.matches1AtIndex(index, ttName) and processor.tokens[index].contextualKeyword == keyword + +proc matchesContextual*(processor: TokenProcessor, keyword: ContextualKeyword): bool = + processor.matchesContextualAtIndex(processor.tokenIndex, keyword) + +proc matchesContextIdAndLabel*(processor: TokenProcessor, typ: TokenType, contextId: int): bool = + processor.matches1(typ) and processor.currentToken().contextId == contextId + +proc previousWhitespaceAndComments*(processor: TokenProcessor): string = + let start = + if processor.tokenIndex > 0: processor.tokens[processor.tokenIndex - 1].`end` + else: 0 + let finish = + if processor.tokenIndex < processor.tokens.len: processor.tokens[processor.tokenIndex].start + else: processor.code.len + if start >= 0 and finish >= start and finish <= processor.code.len: + processor.code[start..<finish] + else: + "" + +proc snapshot*(processor: TokenProcessor): TokenProcessorSnapshot = + TokenProcessorSnapshot(resultCode: processor.resultCode, tokenIndex: processor.tokenIndex) + +proc restoreToSnapshot*(processor: var TokenProcessor, snapshot: TokenProcessorSnapshot) = + processor.resultCode = snapshot.resultCode + processor.tokenIndex = snapshot.tokenIndex + +proc dangerouslyGetAndRemoveCodeSinceSnapshot*(processor: var TokenProcessor, snapshot: TokenProcessorSnapshot): string = + result = processor.resultCode[snapshot.resultCode.len..^1] + processor.resultCode = snapshot.resultCode + +proc appendTokenPrefix(processor: var TokenProcessor) = + discard + +proc appendTokenSuffix(processor: var TokenProcessor) = + discard + +proc replaceToken*(processor: var TokenProcessor, newCode: string) = + if processor.isAtEnd(): + raise newException(ValueError, "Cannot replace token at end of input.") + processor.resultCode.add(processor.previousWhitespaceAndComments()) + processor.appendTokenPrefix() + processor.resultMappings[processor.tokenIndex] = processor.resultCode.len + processor.resultCode.add(newCode) + processor.appendTokenSuffix() + inc processor.tokenIndex + +proc replaceTokenTrimmingLeftWhitespace*(processor: var TokenProcessor, newCode: string) = + let whitespace = processor.previousWhitespaceAndComments() + for ch in whitespace: + if ch in {'\n', '\r'}: + processor.resultCode.add(ch) + processor.appendTokenPrefix() + processor.resultMappings[processor.tokenIndex] = processor.resultCode.len + processor.resultCode.add(newCode) + processor.appendTokenSuffix() + inc processor.tokenIndex + +proc removeInitialToken*(processor: var TokenProcessor) = + processor.replaceToken("") + +proc removeToken*(processor: var TokenProcessor) = + processor.replaceTokenTrimmingLeftWhitespace("") + +proc copyToken*(processor: var TokenProcessor) = + if processor.isAtEnd(): + raise newException(ValueError, "Cannot copy token at end of input.") + processor.resultCode.add(processor.previousWhitespaceAndComments()) + processor.appendTokenPrefix() + processor.resultMappings[processor.tokenIndex] = processor.resultCode.len + processor.resultCode.add(processor.rawCodeForToken(processor.currentToken())) + processor.appendTokenSuffix() + inc processor.tokenIndex + +proc copyTokenWithPrefix*(processor: var TokenProcessor, prefix: string) = + if processor.isAtEnd(): + raise newException(ValueError, "Cannot copy token at end of input.") + processor.resultCode.add(processor.previousWhitespaceAndComments()) + processor.appendTokenPrefix() + processor.resultCode.add(prefix) + processor.resultMappings[processor.tokenIndex] = processor.resultCode.len + processor.resultCode.add(processor.rawCodeForToken(processor.currentToken())) + processor.appendTokenSuffix() + inc processor.tokenIndex + +proc copyExpectedToken*(processor: var TokenProcessor, tokenType: TokenType) = + if not processor.matches1(tokenType): + raise newException(ValueError, "Expected token " & formatTokenType(tokenType)) + processor.copyToken() + +proc appendCode*(processor: var TokenProcessor, code: string) = + processor.resultCode.add(code) + +proc nextToken*(processor: var TokenProcessor) = + if processor.isAtEnd(): + raise newException(ValueError, "Unexpectedly reached end of input.") + inc processor.tokenIndex + +proc previousToken*(processor: var TokenProcessor) = + if processor.tokenIndex > 0: + dec processor.tokenIndex + +proc removeBalancedCode*(processor: var TokenProcessor) = + var braceDepth = 0 + while not processor.isAtEnd(): + if processor.matches1(ttBraceL): + inc braceDepth + elif processor.matches1(ttBraceR): + if braceDepth == 0: + return + dec braceDepth + processor.removeToken() + +proc finish*(processor: var TokenProcessor): TokenProcessorResult = + if processor.tokenIndex != processor.tokens.len: + raise newException(ValueError, "Tried to finish processing tokens before reaching the end.") + processor.resultCode.add(processor.previousWhitespaceAndComments()) + TokenProcessorResult(code: processor.resultCode, mappings: processor.resultMappings) + +proc copyAll*(processor: var TokenProcessor): TokenProcessorResult = + while not processor.isAtEnd(): + processor.copyToken() + processor.finish() diff --git a/frameos/src/frameos/js_runtime/tokens.nim b/frameos/src/frameos/js_runtime/tokens.nim new file mode 100644 index 000000000..e427689a0 --- /dev/null +++ b/frameos/src/frameos/js_runtime/tokens.nim @@ -0,0 +1,1056 @@ +# Sucrase-compatible JavaScript/TypeScript/JSX token model for FrameOS. +# +# This is intentionally shaped after Sucrase 3.35.1's parser/tokenizer layer so +# the native transpiler can move from string-scanner passes to token-driven +# transforms incrementally. Sucrase is MIT licensed; see transpiler.nim for +# attribution context. + +import std/[strutils] + +type + TokenType* = enum + ttNum, + ttBigint, + ttDecimal, + ttRegexp, + ttString, + ttName, + ttEof, + ttBracketL, + ttBracketR, + ttBraceL, + ttBraceBarL, + ttBraceR, + ttBraceBarR, + ttParenL, + ttParenR, + ttComma, + ttSemi, + ttColon, + ttDoubleColon, + ttDot, + ttQuestion, + ttQuestionDot, + ttArrow, + ttTemplate, + ttEllipsis, + ttBackQuote, + ttDollarBraceL, + ttAt, + ttHash, + ttEq, + ttAssign, + ttPreIncDec, + ttPostIncDec, + ttBang, + ttTilde, + ttPipeline, + ttNullishCoalescing, + ttLogicalOR, + ttLogicalAND, + ttBitwiseOR, + ttBitwiseXOR, + ttBitwiseAND, + ttEquality, + ttLessThan, + ttGreaterThan, + ttRelationalOrEqual, + ttBitShiftL, + ttBitShiftR, + ttPlus, + ttMinus, + ttModulo, + ttStar, + ttSlash, + ttExponent, + ttJsxName, + ttJsxText, + ttJsxEmptyText, + ttJsxTagStart, + ttJsxTagEnd, + ttTypeParameterStart, + ttNonNullAssertion, + ttBreak, + ttCase, + ttCatch, + ttContinue, + ttDebugger, + ttDefault, + ttDo, + ttElse, + ttFinally, + ttFor, + ttFunction, + ttIf, + ttReturn, + ttSwitch, + ttThrow, + ttTry, + ttVar, + ttLet, + ttConst, + ttWhile, + ttWith, + ttNew, + ttThis, + ttSuper, + ttClass, + ttExtends, + ttExport, + ttImport, + ttYield, + ttNull, + ttTrue, + ttFalse, + ttIn, + ttInstanceof, + ttTypeof, + ttVoid, + ttDelete, + ttAsync, + ttGet, + ttSet, + ttDeclare, + ttReadonly, + ttAbstract, + ttStatic, + ttPublic, + ttPrivate, + ttProtected, + ttOverride, + ttAs, + ttEnum, + ttType, + ttImplements + + ContextualKeyword* = enum + ckNone, + ckAbstract, + ckAccessor, + ckAs, + ckAssert, + ckAsserts, + ckAsync, + ckAwait, + ckChecks, + ckConstructor, + ckDeclare, + ckEnum, + ckExports, + ckFrom, + ckGet, + ckGlobal, + ckImplements, + ckInfer, + ckInterface, + ckIs, + ckKeyof, + ckMixins, + ckModule, + ckNamespace, + ckOf, + ckOpaque, + ckOut, + ckOverride, + ckPrivate, + ckProtected, + ckProto, + ckPublic, + ckReadonly, + ckRequire, + ckSatisfies, + ckSet, + ckStatic, + ckSymbol, + ckType, + ckUnique, + ckUsing + + IdentifierRole* = enum + irNone, + irAccess, + irExportAccess, + irTopLevelDeclaration, + irFunctionScopedDeclaration, + irBlockScopedDeclaration, + irObjectShorthandTopLevelDeclaration, + irObjectShorthandFunctionScopedDeclaration, + irObjectShorthandBlockScopedDeclaration, + irObjectShorthand, + irImportDeclaration, + irObjectKey, + irImportAccess + + JSXRole* = enum + jsxRoleNone, + jsxNoChildren, + jsxOneChild, + jsxStaticChildren, + jsxKeyAfterPropSpread + + Scope* = object + startTokenIndex*: int + endTokenIndex*: int + isFunctionScope*: bool + + JsToken* = object + typ*: TokenType + contextualKeyword*: ContextualKeyword + start*: int + `end`*: int + scopeDepth*: int + isType*: bool + identifierRole*: IdentifierRole + jsxRole*: JSXRole + shadowsGlobal*: bool + isAsyncOperation*: bool + contextId*: int + rhsEndIndex*: int + isExpression*: bool + numNullishCoalesceStarts*: int + numNullishCoalesceEnds*: int + isOptionalChainStart*: bool + isOptionalChainEnd*: bool + subscriptStartIndex*: int + nullishStartIndex*: int + + TokenizeOptions* = object + jsx*: bool + typescript*: bool + + Mode = enum + modeNormal, + modeJsxTag, + modeJsxText, + modeTemplate + +const + keywordTypes = { + "break": ttBreak, + "case": ttCase, + "catch": ttCatch, + "continue": ttContinue, + "debugger": ttDebugger, + "default": ttDefault, + "do": ttDo, + "else": ttElse, + "finally": ttFinally, + "for": ttFor, + "function": ttFunction, + "if": ttIf, + "return": ttReturn, + "switch": ttSwitch, + "throw": ttThrow, + "try": ttTry, + "var": ttVar, + "let": ttLet, + "const": ttConst, + "while": ttWhile, + "with": ttWith, + "new": ttNew, + "this": ttThis, + "super": ttSuper, + "class": ttClass, + "extends": ttExtends, + "export": ttExport, + "import": ttImport, + "yield": ttYield, + "null": ttNull, + "true": ttTrue, + "false": ttFalse, + "in": ttIn, + "instanceof": ttInstanceof, + "typeof": ttTypeof, + "void": ttVoid, + "delete": ttDelete, + "async": ttAsync, + "get": ttGet, + "set": ttSet, + "declare": ttDeclare, + "readonly": ttReadonly, + "abstract": ttAbstract, + "static": ttStatic, + "public": ttPublic, + "private": ttPrivate, + "protected": ttProtected, + "override": ttOverride, + "as": ttAs, + "enum": ttEnum, + "type": ttType, + "implements": ttImplements, + } + contextualKeywords = { + "abstract": ckAbstract, + "accessor": ckAccessor, + "as": ckAs, + "assert": ckAssert, + "asserts": ckAsserts, + "async": ckAsync, + "await": ckAwait, + "checks": ckChecks, + "constructor": ckConstructor, + "declare": ckDeclare, + "enum": ckEnum, + "exports": ckExports, + "from": ckFrom, + "get": ckGet, + "global": ckGlobal, + "implements": ckImplements, + "infer": ckInfer, + "interface": ckInterface, + "is": ckIs, + "keyof": ckKeyof, + "mixins": ckMixins, + "module": ckModule, + "namespace": ckNamespace, + "of": ckOf, + "opaque": ckOpaque, + "out": ckOut, + "override": ckOverride, + "private": ckPrivate, + "protected": ckProtected, + "proto": ckProto, + "public": ckPublic, + "readonly": ckReadonly, + "require": ckRequire, + "satisfies": ckSatisfies, + "set": ckSet, + "static": ckStatic, + "symbol": ckSymbol, + "type": ckType, + "unique": ckUnique, + "using": ckUsing, + } + +proc defaultTokenizeOptions*(): TokenizeOptions = + TokenizeOptions(jsx: true, typescript: true) + +proc formatTokenType*(typ: TokenType): string = + case typ + of ttNum: "num" + of ttBigint: "bigint" + of ttDecimal: "decimal" + of ttRegexp: "regexp" + of ttString: "string" + of ttName: "name" + of ttEof: "eof" + of ttBracketL: "[" + of ttBracketR: "]" + of ttBraceL: "{" + of ttBraceBarL: "{|" + of ttBraceR: "}" + of ttBraceBarR: "|}" + of ttParenL: "(" + of ttParenR: ")" + of ttComma: "," + of ttSemi: ";" + of ttColon: ":" + of ttDoubleColon: "::" + of ttDot: "." + of ttQuestion: "?" + of ttQuestionDot: "?." + of ttArrow: "=>" + of ttTemplate: "template" + of ttEllipsis: "..." + of ttBackQuote: "`" + of ttDollarBraceL: "${" + of ttAt: "@" + of ttHash: "#" + of ttEq: "=" + of ttAssign: "_=" + of ttPreIncDec, ttPostIncDec: "++/--" + of ttBang: "!" + of ttTilde: "~" + of ttPipeline: "|>" + of ttNullishCoalescing: "??" + of ttLogicalOR: "||" + of ttLogicalAND: "&&" + of ttBitwiseOR: "|" + of ttBitwiseXOR: "^" + of ttBitwiseAND: "&" + of ttEquality: "==/!=" + of ttLessThan: "<" + of ttGreaterThan: ">" + of ttRelationalOrEqual: "<=/>=" + of ttBitShiftL: "<<" + of ttBitShiftR: ">>/>>>" + of ttPlus: "+" + of ttMinus: "-" + of ttModulo: "%" + of ttStar: "*" + of ttSlash: "/" + of ttExponent: "**" + of ttJsxName: "jsxName" + of ttJsxText: "jsxText" + of ttJsxEmptyText: "jsxEmptyText" + of ttJsxTagStart: "jsxTagStart" + of ttJsxTagEnd: "jsxTagEnd" + of ttTypeParameterStart: "typeParameterStart" + of ttNonNullAssertion: "nonNullAssertion" + of ttBreak: "break" + of ttCase: "case" + of ttCatch: "catch" + of ttContinue: "continue" + of ttDebugger: "debugger" + of ttDefault: "default" + of ttDo: "do" + of ttElse: "else" + of ttFinally: "finally" + of ttFor: "for" + of ttFunction: "function" + of ttIf: "if" + of ttReturn: "return" + of ttSwitch: "switch" + of ttThrow: "throw" + of ttTry: "try" + of ttVar: "var" + of ttLet: "let" + of ttConst: "const" + of ttWhile: "while" + of ttWith: "with" + of ttNew: "new" + of ttThis: "this" + of ttSuper: "super" + of ttClass: "class" + of ttExtends: "extends" + of ttExport: "export" + of ttImport: "import" + of ttYield: "yield" + of ttNull: "null" + of ttTrue: "true" + of ttFalse: "false" + of ttIn: "in" + of ttInstanceof: "instanceof" + of ttTypeof: "typeof" + of ttVoid: "void" + of ttDelete: "delete" + of ttAsync: "async" + of ttGet: "get" + of ttSet: "set" + of ttDeclare: "declare" + of ttReadonly: "readonly" + of ttAbstract: "abstract" + of ttStatic: "static" + of ttPublic: "public" + of ttPrivate: "private" + of ttProtected: "protected" + of ttOverride: "override" + of ttAs: "as" + of ttEnum: "enum" + of ttType: "type" + of ttImplements: "implements" + +proc formatContextualKeyword*(keyword: ContextualKeyword): string = + case keyword + of ckNone: "NONE" + of ckAbstract: "abstract" + of ckAccessor: "accessor" + of ckAs: "as" + of ckAssert: "assert" + of ckAsserts: "asserts" + of ckAsync: "async" + of ckAwait: "await" + of ckChecks: "checks" + of ckConstructor: "constructor" + of ckDeclare: "declare" + of ckEnum: "enum" + of ckExports: "exports" + of ckFrom: "from" + of ckGet: "get" + of ckGlobal: "global" + of ckImplements: "implements" + of ckInfer: "infer" + of ckInterface: "interface" + of ckIs: "is" + of ckKeyof: "keyof" + of ckMixins: "mixins" + of ckModule: "module" + of ckNamespace: "namespace" + of ckOf: "of" + of ckOpaque: "opaque" + of ckOut: "out" + of ckOverride: "override" + of ckPrivate: "private" + of ckProtected: "protected" + of ckProto: "proto" + of ckPublic: "public" + of ckReadonly: "readonly" + of ckRequire: "require" + of ckSatisfies: "satisfies" + of ckSet: "set" + of ckStatic: "static" + of ckSymbol: "symbol" + of ckType: "type" + of ckUnique: "unique" + of ckUsing: "using" + +proc formatIdentifierRole*(role: IdentifierRole): string = + case role + of irNone: "none" + of irAccess: "access" + of irExportAccess: "exportAccess" + of irTopLevelDeclaration: "topLevelDeclaration" + of irFunctionScopedDeclaration: "functionScopedDeclaration" + of irBlockScopedDeclaration: "blockScopedDeclaration" + of irObjectShorthandTopLevelDeclaration: "objectShorthandTopLevelDeclaration" + of irObjectShorthandFunctionScopedDeclaration: "objectShorthandFunctionScopedDeclaration" + of irObjectShorthandBlockScopedDeclaration: "objectShorthandBlockScopedDeclaration" + of irObjectShorthand: "objectShorthand" + of irImportDeclaration: "importDeclaration" + of irObjectKey: "objectKey" + of irImportAccess: "importAccess" + +proc formatJSXRole*(role: JSXRole): string = + case role + of jsxRoleNone: "none" + of jsxNoChildren: "noChildren" + of jsxOneChild: "oneChild" + of jsxStaticChildren: "staticChildren" + of jsxKeyAfterPropSpread: "keyAfterPropSpread" + +proc isIdentStart(c: char): bool = + c in {'a'..'z', 'A'..'Z', '_', '$'} or ord(c) >= 128 + +proc isIdentPart(c: char): bool = + isIdentStart(c) or c in {'0'..'9'} + +proc isWhitespace(c: char): bool = + c in {' ', '\t', '\n', '\r', '\v', '\f'} + +proc tokenCanEndExpression(typ: TokenType): bool = + typ in { + ttNum, ttBigint, ttDecimal, ttRegexp, ttString, ttName, ttBracketR, + ttBraceR, ttParenR, ttTemplate, ttBackQuote, ttPostIncDec, ttJsxTagEnd, + ttNull, ttTrue, ttFalse, ttThis, ttSuper + } + +proc shouldReadRegex(prev: TokenType): bool = + prev == ttEof or not tokenCanEndExpression(prev) or prev in { + ttReturn, ttThrow, ttCase, ttDelete, ttTypeof, ttVoid, ttNew, ttIn, + ttInstanceof + } + +proc skipSpace(code: string, pos: var int): bool = + while pos < code.len: + case code[pos] + of ' ', '\t', '\v', '\f': + inc pos + of '\n': + result = true + inc pos + of '\r': + result = true + inc pos + if pos < code.len and code[pos] == '\n': + inc pos + of '/': + if pos + 1 < code.len and code[pos + 1] == '/': + pos += 2 + while pos < code.len and code[pos] notin {'\n', '\r'}: + inc pos + elif pos + 1 < code.len and code[pos + 1] == '*': + pos += 2 + while pos + 1 < code.len and not (code[pos] == '*' and code[pos + 1] == '/'): + if code[pos] in {'\n', '\r'}: + result = true + inc pos + if pos + 1 >= code.len: + raise newException(ValueError, "Unterminated comment") + pos += 2 + else: + break + else: + if isWhitespace(code[pos]): + inc pos + else: + break + +proc makeToken(typ: TokenType, start, finish: int, contextualKeyword = ckNone): JsToken = + JsToken( + typ: typ, + contextualKeyword: contextualKeyword, + start: start, + `end`: finish, + contextId: -1, + rhsEndIndex: -1, + subscriptStartIndex: -1, + nullishStartIndex: -1, + ) + +proc readWordToken(code: string, pos: var int, jsxName = false): JsToken = + let start = pos + while pos < code.len: + if isIdentPart(code[pos]) or code[pos] == '-': + inc pos + elif code[pos] == '\\': + pos += 2 + if pos < code.len and code[pos] == '{': + while pos < code.len and code[pos] != '}': + inc pos + if pos < code.len: + inc pos + else: + break + let word = code[start..<pos] + if jsxName: + return makeToken(ttJsxName, start, pos) + var after = pos + while after < code.len and code[after] in {' ', '\t', '\n', '\r'}: + inc after + for pair in contextualKeywords: + if pair[0] == word and after < code.len and code[after] in {':', '?', '!'}: + return makeToken(ttName, start, pos, pair[1]) + if word == "import" and after < code.len and code[after] == '.': + return makeToken(ttName, start, pos) + for pair in keywordTypes: + if pair[0] == word: + return makeToken(pair[1], start, pos) + for pair in contextualKeywords: + if pair[0] == word: + return makeToken(ttName, start, pos, pair[1]) + makeToken(ttName, start, pos) + +proc readNumberToken(code: string, pos: var int, startsWithDot: bool): JsToken = + let start = pos + var isBigInt = false + var isDecimal = false + + template readInt() = + while pos < code.len and (code[pos] in {'0'..'9'} or code[pos] == '_'): + inc pos + + if startsWithDot: + inc pos + readInt() + elif pos + 1 < code.len and code[pos] == '0' and code[pos + 1] in {'x', 'X', 'o', 'O', 'b', 'B'}: + pos += 2 + while pos < code.len and (code[pos] in {'0'..'9', 'a'..'f', 'A'..'F'} or code[pos] == '_'): + inc pos + else: + readInt() + if pos < code.len and code[pos] == '.': + inc pos + readInt() + if pos < code.len and code[pos] in {'e', 'E'}: + inc pos + if pos < code.len and code[pos] in {'+', '-'}: + inc pos + readInt() + + if pos < code.len and code[pos] == 'n': + isBigInt = true + inc pos + elif pos < code.len and code[pos] == 'm': + isDecimal = true + inc pos + + makeToken(if isBigInt: ttBigint elif isDecimal: ttDecimal else: ttNum, start, pos) + +proc readStringToken(code: string, pos: var int): JsToken = + let start = pos + let quote = code[pos] + inc pos + while pos < code.len: + if code[pos] == '\\': + pos += min(2, code.len - pos) + elif code[pos] == quote: + inc pos + return makeToken(ttString, start, pos) + else: + inc pos + raise newException(ValueError, "Unterminated string constant") + +proc readRegexToken(code: string, pos: var int): JsToken = + let start = pos + var escaped = false + var inClass = false + inc pos + while pos < code.len: + let ch = code[pos] + if escaped: + escaped = false + else: + if ch == '[': + inClass = true + elif ch == ']' and inClass: + inClass = false + elif ch == '/' and not inClass: + inc pos + while pos < code.len and isIdentPart(code[pos]): + inc pos + return makeToken(ttRegexp, start, pos) + escaped = ch == '\\' + inc pos + raise newException(ValueError, "Unterminated regular expression") + +proc readTemplatePart(code: string, pos: var int, prev: TokenType): JsToken = + let start = pos + while pos < code.len: + if code[pos] == '\\': + pos += min(2, code.len - pos) + continue + if code[pos] == '`': + if pos == start and prev != ttTemplate: + return makeToken(ttTemplate, start, pos) + if pos == start: + inc pos + return makeToken(ttBackQuote, start, pos) + return makeToken(ttTemplate, start, pos) + if code[pos] == '$' and pos + 1 < code.len and code[pos + 1] == '{': + if pos == start and prev != ttTemplate: + return makeToken(ttTemplate, start, pos) + if pos == start: + pos += 2 + return makeToken(ttDollarBraceL, start, pos) + return makeToken(ttTemplate, start, pos) + inc pos + raise newException(ValueError, "Unterminated template") + +proc readJsxText(code: string, pos: var int): JsToken = + let start = pos + while pos < code.len and code[pos] notin {'<', '{'}: + inc pos + if pos == start: + return makeToken(ttJsxEmptyText, start, pos) + if code[start..<pos].strip().len == 0: + return makeToken(ttJsxEmptyText, start, pos) + makeToken(ttJsxText, start, pos) + +proc looksLikeGenericArrowStart(code: string, pos: int): bool = + var i = pos + 1 + while i < code.len and code[i] in {' ', '\t'}: + inc i + if i >= code.len or not isIdentStart(code[i]): + return false + while i < code.len and (isIdentPart(code[i]) or code[i] in {' ', '\t', ',', '?'}) : + inc i + if i >= code.len or code[i] != '>': + return false + inc i + while i < code.len and code[i] in {' ', '\t', '\n', '\r'}: + inc i + if i >= code.len or code[i] != '(': + return false + var depth = 0 + while i < code.len: + if code[i] == '(': + inc depth + elif code[i] == ')': + dec depth + if depth == 0: + inc i + break + inc i + while i < code.len and code[i] in {' ', '\t', '\n', '\r'}: + inc i + i + 1 < code.len and code[i] == '=' and code[i + 1] == '>' + +proc looksLikeJsxStart(code: string, pos: int, prev: TokenType): bool = + if pos >= code.len or code[pos] != '<': + return false + if looksLikeGenericArrowStart(code, pos): + return false + if prev != ttEof and tokenCanEndExpression(prev): + return false + var next = pos + 1 + while next < code.len and code[next] in {' ', '\t'}: + inc next + next < code.len and (isIdentStart(code[next]) or code[next] in {'/', '>'}) + +proc punctToken(code: string, pos: var int, prev: TokenType, hadNewline: bool): JsToken = + let start = pos + template finish(kind: TokenType, width: int): JsToken = + pos += width + makeToken(kind, start, pos) + + case code[pos] + of '#': finish(ttHash, 1) + of '.': + if pos + 1 < code.len and code[pos + 1] in {'0'..'9'}: + readNumberToken(code, pos, true) + elif pos + 2 < code.len and code[pos + 1] == '.' and code[pos + 2] == '.': + finish(ttEllipsis, 3) + else: + finish(ttDot, 1) + of '(': + finish(ttParenL, 1) + of ')': + finish(ttParenR, 1) + of ';': + finish(ttSemi, 1) + of ',': + finish(ttComma, 1) + of '[': + finish(ttBracketL, 1) + of ']': + finish(ttBracketR, 1) + of '{': + finish(ttBraceL, 1) + of '}': + finish(ttBraceR, 1) + of ':': + if pos + 1 < code.len and code[pos + 1] == ':': finish(ttDoubleColon, 2) + else: finish(ttColon, 1) + of '?': + if pos + 2 < code.len and code[pos + 1] == '?' and code[pos + 2] == '=': + finish(ttAssign, 3) + elif pos + 1 < code.len and code[pos + 1] == '?': + finish(ttNullishCoalescing, 2) + elif pos + 1 < code.len and code[pos + 1] == '.' and not (pos + 2 < code.len and code[pos + 2] in {'0'..'9'}): + finish(ttQuestionDot, 2) + else: + finish(ttQuestion, 1) + of '@': + finish(ttAt, 1) + of '`': + finish(ttBackQuote, 1) + of '/': + if shouldReadRegex(prev): + readRegexToken(code, pos) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttAssign, 2) + else: + finish(ttSlash, 1) + of '%': + if pos + 1 < code.len and code[pos + 1] == '=': finish(ttAssign, 2) + else: finish(ttModulo, 1) + of '*': + if pos + 2 < code.len and code[pos + 1] == '*' and code[pos + 2] == '=': + finish(ttAssign, 3) + elif pos + 1 < code.len and code[pos + 1] == '*': + finish(ttExponent, 2) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttAssign, 2) + else: + finish(ttStar, 1) + of '|': + if pos + 2 < code.len and code[pos + 1] == '|' and code[pos + 2] == '=': + finish(ttAssign, 3) + elif pos + 1 < code.len and code[pos + 1] == '|': + finish(ttLogicalOR, 2) + elif pos + 1 < code.len and code[pos + 1] == '>': + finish(ttPipeline, 2) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttAssign, 2) + else: + finish(ttBitwiseOR, 1) + of '&': + if pos + 2 < code.len and code[pos + 1] == '&' and code[pos + 2] == '=': + finish(ttAssign, 3) + elif pos + 1 < code.len and code[pos + 1] == '&': + finish(ttLogicalAND, 2) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttAssign, 2) + else: + finish(ttBitwiseAND, 1) + of '^': + if pos + 1 < code.len and code[pos + 1] == '=': finish(ttAssign, 2) + else: finish(ttBitwiseXOR, 1) + of '+': + if pos + 1 < code.len and code[pos + 1] == '+': + finish(if tokenCanEndExpression(prev) and not hadNewline: ttPostIncDec else: ttPreIncDec, 2) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttAssign, 2) + else: + finish(ttPlus, 1) + of '-': + if pos + 1 < code.len and code[pos + 1] == '-': + finish(if tokenCanEndExpression(prev) and not hadNewline: ttPostIncDec else: ttPreIncDec, 2) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttAssign, 2) + else: + finish(ttMinus, 1) + of '<': + if pos + 2 < code.len and code[pos + 1] == '<' and code[pos + 2] == '=': + finish(ttAssign, 3) + elif pos + 1 < code.len and code[pos + 1] == '<': + finish(ttBitShiftL, 2) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttRelationalOrEqual, 2) + else: + finish(ttLessThan, 1) + of '>': + if pos + 3 < code.len and code[pos + 1] == '>' and code[pos + 2] == '>' and code[pos + 3] == '=': + finish(ttAssign, 4) + elif pos + 2 < code.len and code[pos + 1] == '>' and code[pos + 2] == '=': + finish(ttAssign, 3) + elif pos + 1 < code.len and code[pos + 1] == '>': + finish(if pos + 2 < code.len and code[pos + 2] == '>': ttBitShiftR else: ttBitShiftR, if pos + 2 < code.len and code[pos + 2] == '>': 3 else: 2) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttRelationalOrEqual, 2) + else: + finish(ttGreaterThan, 1) + of '=': + if pos + 1 < code.len and code[pos + 1] == '>': + finish(ttArrow, 2) + elif pos + 1 < code.len and code[pos + 1] == '=': + finish(ttEquality, if pos + 2 < code.len and code[pos + 2] == '=': 3 else: 2) + else: + finish(ttEq, 1) + of '!': + if pos + 1 < code.len and code[pos + 1] == '=': + finish(ttEquality, if pos + 2 < code.len and code[pos + 2] == '=': 3 else: 2) + else: + finish(ttBang, 1) + of '~': + finish(ttTilde, 1) + else: + raise newException(ValueError, "Unexpected character '" & $code[pos] & "'") + +proc tokenizeJs*(code: string, options = defaultTokenizeOptions()): seq[JsToken] = + var pos = 0 + var prev = ttEof + var mode = modeNormal + var modeStack: seq[Mode] = @[] + var braceModeStack: seq[Mode] = @[] + var jsxDepth = 0 + var jsxTagClosing = false + var jsxSelfClosing = false + var tokens: seq[JsToken] = @[] + + proc pushToken(token: JsToken) = + tokens.add(token) + prev = token.typ + + while true: + if mode == modeTemplate: + let token = readTemplatePart(code, pos, prev) + pushToken(token) + if token.typ == ttDollarBraceL: + braceModeStack.add(modeTemplate) + mode = modeNormal + elif token.typ == ttBackQuote: + if modeStack.len > 0: + mode = modeStack.pop() + else: + mode = modeNormal + continue + + if mode == modeJsxText: + if pos >= code.len: + pushToken(makeToken(ttEof, pos, pos)) + break + if code[pos] == '<': + let start = pos + inc pos + pushToken(makeToken(ttJsxTagStart, start, pos)) + mode = modeJsxTag + var look = pos + while look < code.len and code[look] in {' ', '\t'}: + inc look + jsxTagClosing = look < code.len and code[look] == '/' + jsxSelfClosing = false + continue + if code[pos] == '{': + let start = pos + inc pos + pushToken(makeToken(ttBraceL, start, pos)) + braceModeStack.add(modeJsxText) + mode = modeNormal + continue + let token = readJsxText(code, pos) + pushToken(token) + continue + + let hadNewline = skipSpace(code, pos) + if pos >= code.len: + pushToken(makeToken(ttEof, pos, pos)) + break + + if mode == modeJsxTag: + let start = pos + case code[pos] + of '>': + inc pos + pushToken(makeToken(ttJsxTagEnd, start, pos)) + if jsxSelfClosing: + if jsxDepth == 0: + mode = modeNormal + else: + mode = modeJsxText + elif jsxTagClosing: + if jsxDepth > 0: + dec jsxDepth + mode = if jsxDepth == 0: modeNormal else: modeJsxText + else: + inc jsxDepth + mode = modeJsxText + continue + of '/': + inc pos + if not jsxTagClosing: + jsxSelfClosing = true + pushToken(makeToken(ttSlash, start, pos)) + continue + of '{': + inc pos + pushToken(makeToken(ttBraceL, start, pos)) + braceModeStack.add(modeJsxTag) + mode = modeNormal + continue + of '=', ':', '.', '-': + pushToken(punctToken(code, pos, prev, hadNewline)) + continue + of '\'', '"': + pushToken(readStringToken(code, pos)) + continue + else: + if isIdentStart(code[pos]): + pushToken(readWordToken(code, pos, jsxName = true)) + continue + pushToken(punctToken(code, pos, prev, hadNewline)) + continue + + if options.jsx and looksLikeJsxStart(code, pos, prev): + let start = pos + inc pos + pushToken(makeToken(ttJsxTagStart, start, pos)) + mode = modeJsxTag + var look = pos + while look < code.len and code[look] in {' ', '\t'}: + inc look + jsxTagClosing = look < code.len and code[look] == '/' + jsxSelfClosing = false + continue + + if code[pos] == '`': + let start = pos + inc pos + pushToken(makeToken(ttBackQuote, start, pos)) + modeStack.add(mode) + mode = modeTemplate + continue + + if code[pos] in {'\'', '"'}: + pushToken(readStringToken(code, pos)) + continue + + if code[pos] in {'0'..'9'}: + pushToken(readNumberToken(code, pos, startsWithDot = false)) + continue + + if isIdentStart(code[pos]) or code[pos] == '\\': + pushToken(readWordToken(code, pos)) + continue + + let token = punctToken(code, pos, prev, hadNewline) + pushToken(token) + if token.typ == ttBraceR and braceModeStack.len > 0: + mode = braceModeStack.pop() + + tokens + +proc formatToken*(code: string, token: JsToken): string = + let raw = if token.start >= 0 and token.`end` <= code.len and token.start <= token.`end`: code[token.start..<token.`end`] else: "" + var parts = @[formatTokenType(token.typ) & "(" & $token.start & "," & $token.`end` & ")"] + if token.contextualKeyword != ckNone: + parts.add("contextual=" & formatContextualKeyword(token.contextualKeyword)) + if raw.len > 0: + parts.add(raw.multiReplace(("\n", "\\n"), ("\r", "\\r"), ("\t", "\\t"))) + parts.join(" ") + +proc formatTokens*(code: string, tokens: seq[JsToken]): string = + for token in tokens: + if result.len > 0: + result.add("\n") + result.add(formatToken(code, token)) diff --git a/frameos/src/frameos/js_runtime/transpiler.nim b/frameos/src/frameos/js_runtime/transpiler.nim new file mode 100644 index 000000000..fc029d2fa --- /dev/null +++ b/frameos/src/frameos/js_runtime/transpiler.nim @@ -0,0 +1,1991 @@ +# Native TypeScript/JSX transpiler for FrameOS. +# +# This module is a Nim reimplementation track for the parts of Sucrase that +# FrameOS needs at runtime. The output is always evaluated by the bundled +# QuickJS runtime, so this intentionally erases TypeScript, lowers JSX to the +# FrameOS classic runtime, rewrites modules for the app wrapper, and preserves +# modern JavaScript syntax that QuickJS already supports. +# +# Sucrase is MIT licensed: +# +# Copyright (c) 2012-2018 various contributors (see AUTHORS) +# +# Sucrase itself includes a modified fork of Babylon, which was forked from +# Acorn. This file intentionally keeps public naming close to Sucrase concepts +# (`TransformOptions`, `TransformResult`, `transform`) so upstream changes can +# be tracked and ported incrementally. See `js_runtime/README.md`. + +import std/[strutils, sequtils] +from std/unicode import Rune, toUTF8 + +import ./parser +import ./source_map +import ./token_processor +import ./tokens + +type + TransformResult* = object + code*: string + sourceMap*: SourceLineMap + + TransformOptions* = object + filePath*: string + transforms*: seq[string] + + JsxParser = object + code: string + pos: int + +const + defaultTransforms = @["typescript", "jsx"] + moduleTransforms = @["typescript", "jsx", "imports"] + reservedWords = [ + "break", "case", "catch", "class", "const", "continue", "debugger", + "default", "delete", "do", "else", "export", "extends", "finally", + "for", "function", "if", "import", "in", "instanceof", "new", "return", + "super", "switch", "this", "throw", "try", "typeof", "var", "void", + "while", "with", "yield", "enum", "implements", "interface", "let", + "package", "private", "protected", "public", "static", "await", "false", + "null", "true" + ] + +proc hasTransform(options: TransformOptions, name: string): bool = + let transforms = if options.transforms.len == 0: defaultTransforms else: options.transforms + name in transforms + +proc isIdentStart(c: char): bool = + c in {'a'..'z', 'A'..'Z', '_', '$'} + +proc isIdentPart(c: char): bool = + isIdentStart(c) or c in {'0'..'9'} + +proc isIdentifierName(name: string): bool = + if name.len == 0 or not isIdentStart(name[0]): + return false + for ch in name: + if not isIdentPart(ch): + return false + name notin reservedWords + +proc skipSpaces(code: string, i: var int) = + while i < code.len and code[i] in {' ', '\t', '\n', '\r'}: + inc i + +proc jsonQuote(s: string): string = + result = "\"" + for ch in s: + case ch + of '\\': result.add("\\\\") + of '"': result.add("\\\"") + of '\n': result.add("\\n") + of '\r': result.add("\\r") + of '\t': result.add("\\t") + else: result.add(ch) + result.add('"') + +proc decodeJsxEntities(s: string): string = + var i = 0 + while i < s.len: + if s[i] != '&': + result.add(s[i]) + inc i + continue + let semi = s.find(';', i + 1) + if semi < 0: + result.add(s[i]) + inc i + continue + let entity = s[i + 1..<semi] + case entity + of "amp": result.add('&') + of "lt": result.add('<') + of "gt": result.add('>') + of "quot": result.add('"') + of "apos": result.add('\'') + of "nbsp": result.add(" ") + else: + if entity.startsWith("#x") or entity.startsWith("#X"): + try: + result.add(Rune(parseHexInt(entity[2..^1])).toUTF8()) + except CatchableError: + result.add("&" & entity & ";") + elif entity.startsWith("#"): + try: + result.add(Rune(parseInt(entity[1..^1])).toUTF8()) + except CatchableError: + result.add("&" & entity & ";") + else: + result.add("&" & entity & ";") + i = semi + 1 + +proc startsWordAt(code: string, i: int, word: string): bool = + if i < 0 or i + word.len > code.len: + return false + if code.substr(i, i + word.len - 1) != word: + return false + if i > 0 and isIdentPart(code[i - 1]): + return false + let after = i + word.len + after >= code.len or not isIdentPart(code[after]) + +proc readIdentifier(code: string, i: var int): string = + let start = i + if i < code.len and isIdentStart(code[i]): + inc i + while i < code.len and isIdentPart(code[i]): + inc i + code[start..<i] + +proc copyQuoted(code: string, i: var int, quote: char): string = + let start = i + inc i + while i < code.len: + if code[i] == '\\': + i += min(2, code.len - i) + elif code[i] == quote: + inc i + break + else: + inc i + code[start..<i] + +proc skipQuoted(code: string, i: var int, quote: char) = + discard copyQuoted(code, i, quote) + +proc copyLineComment(code: string, i: var int): string = + let start = i + i += 2 + while i < code.len and code[i] notin {'\n', '\r'}: + inc i + code[start..<i] + +proc skipLineComment(code: string, i: var int) = + discard copyLineComment(code, i) + +proc copyBlockComment(code: string, i: var int): string = + let start = i + i += 2 + while i + 1 < code.len: + if code[i] == '*' and code[i + 1] == '/': + i += 2 + break + inc i + code[start..<i] + +proc skipBlockComment(code: string, i: var int) = + discard copyBlockComment(code, i) + +proc copyTemplate(code: string, i: var int): string = + let start = i + inc i + while i < code.len: + if code[i] == '\\': + i += min(2, code.len - i) + elif code[i] == '`': + inc i + break + else: + inc i + code[start..<i] + +proc skipTemplate(code: string, i: var int) = + discard copyTemplate(code, i) + +proc findMatching(code: string, openIndex: int, openCh: char, closeCh: char): int = + var i = openIndex + var depth = 0 + while i < code.len: + case code[i] + of '\'', '"': + skipQuoted(code, i, code[i]) + continue + of '`': + skipTemplate(code, i) + continue + of '/': + if i + 1 < code.len and code[i + 1] == '/': + skipLineComment(code, i) + continue + if i + 1 < code.len and code[i + 1] == '*': + skipBlockComment(code, i) + continue + else: + discard + if code[i] == openCh: + inc depth + elif code[i] == closeCh: + dec depth + if depth == 0: + return i + inc i + -1 + +proc findMatchingReverse(code: string, closeIndex: int, openCh: char, closeCh: char): int = + var i = closeIndex + var depth = 0 + while i >= 0: + if code[i] == closeCh: + inc depth + elif code[i] == openCh: + dec depth + if depth == 0: + return i + dec i + -1 + +proc findMatchingAngle(code: string, openIndex: int): int = + var i = openIndex + var depth = 0 + while i < code.len: + case code[i] + of '\'', '"': + skipQuoted(code, i, code[i]) + continue + of '`': + skipTemplate(code, i) + continue + of '/': + if i + 1 < code.len and code[i + 1] == '/': + skipLineComment(code, i) + continue + if i + 1 < code.len and code[i + 1] == '*': + skipBlockComment(code, i) + continue + of '<': + inc depth + of '>': + dec depth + if depth == 0: + return i + else: + discard + inc i + -1 + +proc findStatementEnd(code: string, start: int): int = + var i = start + var parenDepth = 0 + var braceDepth = 0 + var bracketDepth = 0 + while i < code.len: + case code[i] + of '\'', '"': + skipQuoted(code, i, code[i]) + continue + of '`': + skipTemplate(code, i) + continue + of '/': + if i + 1 < code.len and code[i + 1] == '/': + skipLineComment(code, i) + continue + if i + 1 < code.len and code[i + 1] == '*': + skipBlockComment(code, i) + continue + of '(': + inc parenDepth + of ')': + if parenDepth > 0: dec parenDepth + of '[': + inc bracketDepth + of ']': + if bracketDepth > 0: dec bracketDepth + of '{': + inc braceDepth + of '}': + if braceDepth > 0: + dec braceDepth + elif parenDepth == 0 and bracketDepth == 0: + return i + of ';': + if parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + return i + 1 + of '\n': + if parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + return i + else: + discard + inc i + code.len + +proc findTopLevelCommaOrBrace(code: string, start: int, closeCh: char): int = + var i = start + var parenDepth = 0 + var braceDepth = 0 + var bracketDepth = 0 + while i < code.len: + case code[i] + of '\'', '"': + skipQuoted(code, i, code[i]) + continue + of '`': + skipTemplate(code, i) + continue + of '/': + if i + 1 < code.len and code[i + 1] == '/': + skipLineComment(code, i) + continue + if i + 1 < code.len and code[i + 1] == '*': + skipBlockComment(code, i) + continue + of '(': + inc parenDepth + of ')': + if parenDepth > 0: dec parenDepth + of '{': + inc braceDepth + of '}': + if closeCh == '}' and parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + return i + if braceDepth > 0: dec braceDepth + of '[': + inc bracketDepth + of ']': + if bracketDepth > 0: dec bracketDepth + of ',': + if parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + return i + else: + discard + inc i + code.len + +proc readPropertyName(code: string, i: var int): tuple[nameStringCode: string, variableName: string] = + skipSpaces(code, i) + if i < code.len and code[i] in {'\'', '"'}: + let raw = copyQuoted(code, i, code[i]) + let value = + if raw.len >= 2: raw[1..^2] + else: "" + return (raw, if isIdentifierName(value): value else: "") + let name = readIdentifier(code, i) + if name.len == 0: + raise newException(ValueError, "Expected name or string at beginning of enum element.") + (jsonQuote(name), if isIdentifierName(name): name else: "") + +proc isStringLiteralCode(code: string): bool = + let trimmed = code.strip() + trimmed.len >= 2 and trimmed[0] in {'\'', '"'} and trimmed[^1] == trimmed[0] + +proc lowerEnumMember(enumName: string, nameStringCode: string, variableName: string, valueCode: string, hasValue: bool, previousValueCode: string): tuple[code: string, previous: string] = + if hasValue and isStringLiteralCode(valueCode): + if variableName.len > 0: + result.code = "const " & variableName & " = " & valueCode.strip() & "; " & enumName & "[" & nameStringCode & "] = " & variableName & ";" + result.previous = variableName + else: + result.code = enumName & "[" & nameStringCode & "] = " & valueCode.strip() & ";" + result.previous = enumName & "[" & nameStringCode & "]" + return + + let resolvedValue = + if hasValue: + valueCode.strip() + elif previousValueCode.len > 0: + previousValueCode & " + 1" + else: + "0" + if variableName.len > 0: + result.code = "const " & variableName & " = " & resolvedValue & "; " & enumName & "[" & enumName & "[" & nameStringCode & "] = " & variableName & "] = " & nameStringCode & ";" + result.previous = variableName + else: + result.code = enumName & "[" & enumName & "[" & nameStringCode & "] = " & resolvedValue & "] = " & nameStringCode & ";" + result.previous = enumName & "[" & nameStringCode & "]" + +proc lowerEnumDeclaration(code: string, start: int): tuple[code: string, next: int] = + var i = start + var isExport = false + if startsWordAt(code, i, "export"): + isExport = true + i += "export".len + skipSpaces(code, i) + if startsWordAt(code, i, "const"): + i += "const".len + skipSpaces(code, i) + if not startsWordAt(code, i, "enum"): + raise newException(ValueError, "Expected enum declaration.") + i += "enum".len + skipSpaces(code, i) + let enumName = readIdentifier(code, i) + if enumName.len == 0: + raise newException(ValueError, "Expected enum name.") + skipSpaces(code, i) + if i >= code.len or code[i] != '{': + raise newException(ValueError, "Expected enum body.") + let close = findMatching(code, i, '{', '}') + if close < 0: + raise newException(ValueError, "Unterminated enum body.") + + var body = "" + var memberPos = i + 1 + var previousValueCode = "" + while memberPos < close: + skipSpaces(code, memberPos) + if memberPos >= close: + break + if code[memberPos] == ',': + inc memberPos + continue + let keyInfo = readPropertyName(code, memberPos) + skipSpaces(code, memberPos) + var valueCode = "" + var hasValue = false + if memberPos < close and code[memberPos] == '=': + hasValue = true + inc memberPos + let valueStart = memberPos + let valueEnd = findTopLevelCommaOrBrace(code, memberPos, '}') + valueCode = code[valueStart..<min(valueEnd, close)] + memberPos = min(valueEnd, close) + let lowered = lowerEnumMember(enumName, keyInfo.nameStringCode, keyInfo.variableName, valueCode, hasValue, previousValueCode) + body.add(lowered.code) + previousValueCode = lowered.previous + skipSpaces(code, memberPos) + if memberPos < close and code[memberPos] == ',': + inc memberPos + + result.code = (if isExport: "export " else: "") & "var " & enumName & "; (function (" & enumName & ") {" & body & "})(" & enumName & " || (" & enumName & " = {}));" + result.next = close + 1 + +proc lowerEnums(code: string): string = + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplate(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if startsWordAt(code, i, "enum") or + startsWordAt(code, i, "const") and (block: + var j = i + "const".len + skipSpaces(code, j) + startsWordAt(code, j, "enum") + ) or + startsWordAt(code, i, "export") and (block: + var j = i + "export".len + skipSpaces(code, j) + if startsWordAt(code, j, "const"): + j += "const".len + skipSpaces(code, j) + startsWordAt(code, j, "enum") + ): + let lowered = lowerEnumDeclaration(code, i) + result.add(lowered.code) + i = lowered.next + continue + + result.add(code[i]) + inc i + +proc removeTypeDeclaration(code: string, start: int): int = + var i = start + if startsWordAt(code, i, "export"): + i += "export".len + skipSpaces(code, i) + if startsWordAt(code, i, "interface"): + let brace = code.find('{', i) + if brace >= 0: + let close = findMatching(code, brace, '{', '}') + if close >= 0: + return close + 1 + findStatementEnd(code, i) + +proc skipType(code: string, start: int): int = + var i = start + var angleDepth = 0 + var parenDepth = 0 + var braceDepth = 0 + var bracketDepth = 0 + while i < code.len: + case code[i] + of '\'', '"': + skipQuoted(code, i, code[i]) + continue + of '`': + skipTemplate(code, i) + continue + of '<': + inc angleDepth + of '>': + if angleDepth > 0: + dec angleDepth + else: + break + of '(': + inc parenDepth + of ')': + if parenDepth == 0 and angleDepth == 0 and braceDepth == 0 and bracketDepth == 0: + break + if parenDepth > 0: dec parenDepth + of '{': + inc braceDepth + of '}': + if braceDepth == 0 and angleDepth == 0 and parenDepth == 0 and bracketDepth == 0: + break + if braceDepth > 0: dec braceDepth + of '[': + inc bracketDepth + of ']': + if bracketDepth == 0 and angleDepth == 0 and parenDepth == 0 and braceDepth == 0: + break + if bracketDepth > 0: dec bracketDepth + of ',', ';', '=': + if angleDepth == 0 and parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + break + else: + discard + if i + 1 < code.len and code[i] == '=' and code[i + 1] == '>': + break + inc i + i + +proc skipAssertionType(code: string, start: int): int = + var i = start + var angleDepth = 0 + var parenDepth = 0 + var braceDepth = 0 + var bracketDepth = 0 + while i < code.len: + case code[i] + of '\'', '"': + skipQuoted(code, i, code[i]) + continue + of '`': + skipTemplate(code, i) + continue + of '<': + inc angleDepth + of '>': + if angleDepth > 0: + dec angleDepth + else: + break + of '(': + inc parenDepth + of ')': + if parenDepth == 0 and angleDepth == 0 and braceDepth == 0 and bracketDepth == 0: + break + if parenDepth > 0: dec parenDepth + of '{': + inc braceDepth + of '}': + if braceDepth == 0 and angleDepth == 0 and parenDepth == 0 and bracketDepth == 0: + break + if braceDepth > 0: dec braceDepth + of '[': + inc bracketDepth + of ']': + if bracketDepth == 0 and angleDepth == 0 and parenDepth == 0 and braceDepth == 0: + break + if bracketDepth > 0: dec bracketDepth + of ',', ';', '\n', '\r': + if angleDepth == 0 and parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + break + else: + discard + if angleDepth == 0 and parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + if i + 1 < code.len and ((code[i] == '=' and code[i + 1] == '>') or + (code[i] == '|' and code[i + 1] == '|') or + (code[i] == '&' and code[i + 1] == '&') or + (code[i] == '?' and code[i + 1] == '?')): + break + inc i + i + +proc stripParamTypes(params: string): string + +proc stripReturnTypeAfterParen(code: string, i: var int) = + var j = i + skipSpaces(code, j) + if j < code.len and code[j] == ':': + inc j + skipSpaces(code, j) + if j < code.len and code[j] == '{': + let close = findMatching(code, j, '{', '}') + if close >= 0: + j = close + 1 + else: + j = skipType(code, j) + else: + while j < code.len: + if code[j] in {'{', ';'}: + break + if j + 1 < code.len and code[j] == '=' and code[j + 1] == '>': + break + if code[j] in {'\'', '"'}: + skipQuoted(code, j, code[j]) + continue + if code[j] == '`': + skipTemplate(code, j) + continue + inc j + i = j + +proc stripParamTypes(params: string): string = + var i = 0 + var braceDepth = 0 + var bracketDepth = 0 + while i < params.len: + case params[i] + of '\'', '"': + result.add(copyQuoted(params, i, params[i])) + continue + of '`': + result.add(copyTemplate(params, i)) + continue + of '/': + if i + 1 < params.len and params[i + 1] == '/': + result.add(copyLineComment(params, i)) + continue + if i + 1 < params.len and params[i + 1] == '*': + result.add(copyBlockComment(params, i)) + continue + of '{': + inc braceDepth + of '}': + if braceDepth > 0: dec braceDepth + of '[': + inc bracketDepth + of ']': + if bracketDepth > 0: dec bracketDepth + of '?': + var j = i + 1 + skipSpaces(params, j) + if j < params.len and params[j] == ':' and braceDepth == 0 and bracketDepth == 0: + i = j + continue + of ':': + if braceDepth == 0 and bracketDepth == 0: + inc i + i = skipType(params, i) + continue + else: + discard + result.add(params[i]) + inc i + +proc stripFunctionAndArrowTypes(code: string): string = + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplate(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if startsWordAt(code, i, "function"): + result.add("function") + i += "function".len + while i < code.len and code[i] != '(': + result.add(code[i]) + inc i + if i < code.len: + let close = findMatching(code, i, '(', ')') + if close >= 0: + result.add('(') + result.add(stripParamTypes(code[i + 1..<close])) + result.add(')') + i = close + 1 + stripReturnTypeAfterParen(code, i) + continue + + if code[i] == '(': + let close = findMatching(code, i, '(', ')') + if close >= 0: + var after = close + 1 + stripReturnTypeAfterParen(code, after) + var arrowCheck = after + skipSpaces(code, arrowCheck) + if arrowCheck + 1 < code.len and code[arrowCheck] == '=' and code[arrowCheck + 1] == '>': + result.add('(') + result.add(stripParamTypes(code[i + 1..<close])) + result.add(')') + i = after + continue + + result.add(code[i]) + inc i + +proc stripTypeParametersAndArguments(code: string): string = + proc isLikelyTypeList(raw: string): bool = + let content = raw.strip() + if content.len == 0: + return false + for ch in content: + if ch in {'{', '}', '"', '\'', '`'}: + return false + if "=" in content and not ("extends" in content): + return false + true + + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplate(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if code[i] == '<': + let close = findMatchingAngle(code, i) + if close >= 0 and isLikelyTypeList(code[i + 1..<close]): + var after = close + 1 + skipSpaces(code, after) + if after < code.len and code[after] in {'{', '('}: + i = close + 1 + continue + if startsWordAt(code, after, "extends") or startsWordAt(code, after, "implements"): + i = close + 1 + continue + if after < code.len and code[after] == '(': + let parenClose = findMatching(code, after, '(', ')') + var arrowCheck = if parenClose >= 0: parenClose + 1 else: after + skipSpaces(code, arrowCheck) + let prev = i - 1 + let isAfterIdentifier = prev >= 0 and (isIdentPart(code[prev]) or code[prev] == ')') + let isGenericArrow = arrowCheck + 1 < code.len and code[arrowCheck] == '=' and code[arrowCheck + 1] == '>' + if isAfterIdentifier or isGenericArrow: + i = close + 1 + continue + + result.add(code[i]) + inc i + +proc stripTypeScriptModifiers(code: string): string = + let modifiers = ["public", "private", "protected", "abstract", "readonly", "override", "declare"] + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplate(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + var removed = false + for modifier in modifiers: + if startsWordAt(code, i, modifier): + var after = i + modifier.len + skipSpaces(code, after) + if after < code.len and (isIdentStart(code[after]) or code[after] in {'{', '(', '[', '*'}): + i = after + removed = true + break + if removed: + continue + result.add(code[i]) + inc i + +proc stripMethodAndMemberTypes(code: string): string = + proc isLikelyMemberTypeColon(colonIndex: int): bool = + var lineStart = colonIndex - 1 + while lineStart >= 0 and code[lineStart] notin {'\n', '\r', '{', ';'}: + dec lineStart + let prefix = code[lineStart + 1..<colonIndex] + if "?" in prefix or "=" in prefix or "(" in prefix or ")" in prefix or "," in prefix: + return false + var prev = colonIndex - 1 + while prev >= 0 and code[prev] in {' ', '\t'}: + dec prev + if prev >= 0 and code[prev] == '!': + dec prev + while prev >= 0 and code[prev] in {' ', '\t'}: + dec prev + prev >= 0 and (isIdentPart(code[prev]) or code[prev] in {']', '?'}) + + proc isMethodContextBeforeName(nameStart: int): bool = + var before = nameStart - 1 + while before >= 0 and code[before] in {' ', '\t'}: + dec before + if before < 0: + return true + if code[before] in {'{', '}', ';', ',', '\n', '\r'}: + return true + if isIdentPart(code[before]): + var wordStart = before + while wordStart >= 0 and isIdentPart(code[wordStart]): + dec wordStart + let word = code[wordStart + 1..before] + if word in ["async", "static", "get", "set"]: + return isMethodContextBeforeName(wordStart + 1) + false + + proc isLikelyMethodParen(openIndex: int): bool = + var prev = openIndex - 1 + while prev >= 0 and code[prev] in {' ', '\t'}: + dec prev + if prev < 0: + return false + if code[prev] == ']': + let openBracket = findMatchingReverse(code, prev, '[', ']') + return openBracket >= 0 and isMethodContextBeforeName(openBracket) + if not isIdentPart(code[prev]): + return false + var nameStart = prev + while nameStart >= 0 and isIdentPart(code[nameStart]): + dec nameStart + inc nameStart + isMethodContextBeforeName(nameStart) + + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplate(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if code[i] == '(' and isLikelyMethodParen(i): + let close = findMatching(code, i, '(', ')') + if close >= 0: + var after = close + 1 + stripReturnTypeAfterParen(code, after) + var braceCheck = after + skipSpaces(code, braceCheck) + if braceCheck < code.len and code[braceCheck] == '{': + result.add('(') + result.add(stripParamTypes(code[i + 1..<close])) + result.add(')') + i = after + continue + + if code[i] == '?': + var j = i + 1 + skipSpaces(code, j) + if j < code.len and code[j] == ':': + i = j + continue + + if code[i] == ':': + if isLikelyMemberTypeColon(i): + let typeStart = i + 1 + let typeEnd = skipType(code, typeStart) + var after = typeEnd + skipSpaces(code, after) + if after < code.len and code[after] in {';', '='}: + if code[after] == '=': + result.add(' ') + i = typeEnd + continue + + result.add(code[i]) + inc i + +proc stripVarTypes(code: string): string = + var i = 0 + var inVarDecl = false + var inInitializer = false + var braceDepth = 0 + var bracketDepth = 0 + var parenDepth = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplate(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if startsWordAt(code, i, "const") or startsWordAt(code, i, "let") or startsWordAt(code, i, "var"): + let word = + if startsWordAt(code, i, "const"): "const" + elif startsWordAt(code, i, "let"): "let" + else: "var" + result.add(word) + i += word.len + inVarDecl = true + inInitializer = false + braceDepth = 0 + bracketDepth = 0 + parenDepth = 0 + continue + + if inVarDecl: + case code[i] + of '{': + inc braceDepth + of '}': + if braceDepth > 0: dec braceDepth + of '[': + inc bracketDepth + of ']': + if bracketDepth > 0: dec bracketDepth + of '(': + inc parenDepth + of ')': + if parenDepth > 0: dec parenDepth + else: + discard + + let atVarTopLevel = inVarDecl and braceDepth == 0 and bracketDepth == 0 and parenDepth == 0 + + if atVarTopLevel and not inInitializer and code[i] == ':': + inc i + i = skipType(code, i) + if i < code.len and code[i] == '=': + result.add(' ') + continue + + if atVarTopLevel: + if code[i] == '=': + inInitializer = true + elif code[i] == ',': + inInitializer = false + elif code[i] in {';', '\n'}: + inVarDecl = false + inInitializer = false + + result.add(code[i]) + inc i + +proc stripAsAssertions(code: string): string = + proc isLikelyAssertionTypeStart(code: string, i: int): bool = + i < code.len and (isIdentStart(code[i]) or code[i] in {'{', '[', '(', '\'', '"'}) + + proc isPropertyAccessName(code: string, i: int): bool = + var prev = i - 1 + while prev >= 0 and code[prev] in {' ', '\t'}: + dec prev + prev >= 0 and code[prev] == '.' + + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplate(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + if startsWordAt(code, i, "import"): + let endStmt = findStatementEnd(code, i) + result.add(code[i..<endStmt]) + i = endStmt + continue + if startsWordAt(code, i, "export"): + var j = i + "export".len + skipSpaces(code, j) + if j < code.len and code[j] in {'{', '*'}: + let endStmt = findStatementEnd(code, i) + result.add(code[i..<endStmt]) + i = endStmt + continue + if startsWordAt(code, i, "as") and not isPropertyAccessName(code, i): + var j = i + 2 + skipSpaces(code, j) + if not isLikelyAssertionTypeStart(code, j): + result.add(code[i]) + inc i + continue + let endType = skipAssertionType(code, j) + i = endType + continue + if startsWordAt(code, i, "satisfies") and not isPropertyAccessName(code, i): + var j = i + "satisfies".len + skipSpaces(code, j) + if not isLikelyAssertionTypeStart(code, j): + result.add(code[i]) + inc i + continue + i = skipAssertionType(code, j) + continue + if code[i] == '!' and i + 1 < code.len and code[i + 1] notin {'=', '!'}: + var j = i + 1 + skipSpaces(code, j) + if j >= code.len or code[j] in {'.', ',', ')', ']', '}', ';'}: + inc i + continue + result.add(code[i]) + inc i + +proc stripTypeScript(code: string): string + +proc copyTemplateWithTransformedExpressions(code: string, i: var int): string = + result.add('`') + inc i + while i < code.len: + if code[i] == '\\': + let count = min(2, code.len - i) + result.add(code[i..<i + count]) + i += count + continue + if code[i] == '`': + result.add('`') + inc i + break + if code[i] == '$' and i + 1 < code.len and code[i + 1] == '{': + let close = findMatching(code, i + 1, '{', '}') + if close < 0: + raise newException(ValueError, "Unterminated template literal expression.") + result.add("${") + result.add(stripTypeScript(code[i + 2..<close])) + result.add('}') + i = close + 1 + continue + result.add(code[i]) + inc i + +proc transformTemplateLiteralTypes(code: string): string = + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplateWithTransformedExpressions(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + result.add(code[i]) + inc i + +proc splitTopLevelCommaList(spec: string): seq[string] + +proc skipTypeParametersAt(code: string, i: var int) = + skipSpaces(code, i) + if i < code.len and code[i] == '<': + let close = findMatchingAngle(code, i) + if close >= 0: + i = close + 1 + +proc looksLikeTypeAliasAt(code: string, i: int): bool = + if not startsWordAt(code, i, "type"): + return false + var j = i + "type".len + skipSpaces(code, j) + if j >= code.len or not isIdentStart(code[j]): + return false + discard readIdentifier(code, j) + skipTypeParametersAt(code, j) + skipSpaces(code, j) + j < code.len and code[j] == '=' + +proc looksLikeInterfaceDeclarationAt(code: string, i: int): bool = + if not startsWordAt(code, i, "interface"): + return false + var j = i + "interface".len + skipSpaces(code, j) + if j >= code.len or not isIdentStart(code[j]): + return false + discard readIdentifier(code, j) + skipTypeParametersAt(code, j) + skipSpaces(code, j) + if startsWordAt(code, j, "extends"): + j += "extends".len + while j < code.len and code[j] != '{': + if code[j] in {';', '\n', '\r'}: + return false + if code[j] in {'\'', '"'}: + skipQuoted(code, j, code[j]) + continue + if code[j] == '`': + skipTemplate(code, j) + continue + inc j + skipSpaces(code, j) + j < code.len and code[j] == '{' + +proc stripTypeOnlyStatements(code: string): string = + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplate(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if looksLikeInterfaceDeclarationAt(code, i) or + looksLikeTypeAliasAt(code, i) or + (startsWordAt(code, i, "export") and (block: + var j = i + "export".len + skipSpaces(code, j) + looksLikeInterfaceDeclarationAt(code, j) or looksLikeTypeAliasAt(code, j) or + (startsWordAt(code, j, "type") and (block: + j += "type".len + skipSpaces(code, j) + j < code.len and code[j] == '{' + )) + )): + i = removeTypeDeclaration(code, i) + continue + + if startsWordAt(code, i, "import"): + var j = i + "import".len + skipSpaces(code, j) + if startsWordAt(code, j, "type"): + i = findStatementEnd(code, i) + continue + + result.add(code[i]) + inc i + +proc stripDeclareStatements(code: string): string = + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplateWithTransformedExpressions(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if startsWordAt(code, i, "declare"): + var j = i + "declare".len + skipSpaces(code, j) + if startsWordAt(code, j, "var") or startsWordAt(code, j, "let") or + startsWordAt(code, j, "const") or startsWordAt(code, j, "function") or + startsWordAt(code, j, "class") or startsWordAt(code, j, "enum") or + startsWordAt(code, j, "module") or startsWordAt(code, j, "global"): + i = findStatementEnd(code, i) + continue + + if startsWordAt(code, i, "export"): + var j = i + "export".len + skipSpaces(code, j) + if startsWordAt(code, j, "declare"): + var k = j + "declare".len + skipSpaces(code, k) + if startsWordAt(code, k, "var") or startsWordAt(code, k, "let") or + startsWordAt(code, k, "const") or startsWordAt(code, k, "function") or + startsWordAt(code, k, "class") or startsWordAt(code, k, "enum") or + startsWordAt(code, k, "module") or startsWordAt(code, k, "global"): + i = findStatementEnd(code, i) + continue + + result.add(code[i]) + inc i + +proc transformConstructorParameterProperties(code: string): string = + proc transformParams(params: string): tuple[code: string, assignments: seq[string]] = + let parts = splitTopLevelCommaList(params) + for index, part in parts: + if index > 0: + result.code.add(", ") + var i = 0 + var leading = "" + while i < part.len and part[i] in {' ', '\t', '\n', '\r'}: + leading.add(part[i]) + inc i + + var scan = i + var foundModifier = false + while true: + var beforeModifier = scan + skipSpaces(part, scan) + let modifier = + if startsWordAt(part, scan, "public"): "public" + elif startsWordAt(part, scan, "private"): "private" + elif startsWordAt(part, scan, "protected"): "protected" + elif startsWordAt(part, scan, "readonly"): "readonly" + else: "" + if modifier.len == 0: + scan = beforeModifier + break + foundModifier = true + scan += modifier.len + skipSpaces(part, scan) + + if foundModifier and scan < part.len and isIdentStart(part[scan]): + var namePos = scan + let name = readIdentifier(part, namePos) + if name.len > 0: + result.assignments.add("this." & name & " = " & name & ";") + result.code.add(leading & part[scan..^1]) + continue + + result.code.add(part) + + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplateWithTransformedExpressions(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if startsWordAt(code, i, "constructor"): + var open = i + "constructor".len + skipSpaces(code, open) + if open < code.len and code[open] == '(': + let close = findMatching(code, open, '(', ')') + if close >= 0: + var bodyOpen = close + 1 + skipSpaces(code, bodyOpen) + if bodyOpen < code.len and code[bodyOpen] == '{': + let transformed = transformParams(code[open + 1..<close]) + result.add(code[i..open]) + result.add(transformed.code) + result.add(code[close..<bodyOpen + 1]) + if transformed.assignments.len > 0: + result.add(transformed.assignments.join("")) + i = bodyOpen + 1 + continue + + result.add(code[i]) + inc i + +proc tokenRaw(code: string, token: JsToken): string = + if token.start >= 0 and token.`end` <= code.len and token.start <= token.`end`: + code[token.start..<token.`end`] + else: + "" + +proc tokenStatementEnd(tokens: seq[JsToken], start: int): int = + var parenDepth = 0 + var braceDepth = 0 + var bracketDepth = 0 + for index in start..<tokens.len: + case tokens[index].typ + of ttParenL: + inc parenDepth + of ttParenR: + if parenDepth > 0: dec parenDepth + of ttBraceL: + inc braceDepth + of ttBraceR: + if braceDepth == 0 and parenDepth == 0 and bracketDepth == 0: + return index + if braceDepth > 0: dec braceDepth + of ttBracketL: + inc bracketDepth + of ttBracketR: + if bracketDepth > 0: dec bracketDepth + of ttSemi: + if parenDepth == 0 and braceDepth == 0 and bracketDepth == 0: + return index + of ttEof: + return index + else: + discard + max(0, tokens.len - 1) + +proc tokenMatching(tokens: seq[JsToken], openIndex: int, openType, closeType: TokenType): int = + var depth = 0 + for index in openIndex..<tokens.len: + if tokens[index].typ == openType: + inc depth + elif tokens[index].typ == closeType: + dec depth + if depth == 0: + return index + -1 + +proc isTsModifierToken(token: JsToken): bool = + token.typ in {ttPublic, ttPrivate, ttProtected, ttReadonly, ttOverride, ttDeclare, ttAbstract} + +proc shouldRemoveDeclareStatement(tokens: seq[JsToken], index: int): bool = + if tokens[index].typ != ttDeclare: + return false + let next = index + 1 + next < tokens.len and tokens[next].typ in {ttVar, ttLet, ttConst, ttFunction, ttClass, ttEnum, ttName} + +proc shouldRemoveAbstractMember(tokens: seq[JsToken], index: int): bool = + if tokens[index].typ != ttAbstract: + return false + let next = index + 1 + if next < tokens.len and tokens[next].typ == ttClass: + return false + true + +proc tokenStripTypeScriptErasure(code: string): string = + let parsed = parseJs(code) + let tokens = parsed.tokens + var processor = initTokenProcessor(code, tokens) + var removeUntil = -1 + + while not processor.isAtEnd(): + let index = processor.currentIndex() + let token = processor.currentToken() + + if index <= removeUntil: + processor.removeToken() + continue + + if token.typ == ttEof: + processor.copyToken() + continue + + if shouldRemoveDeclareStatement(tokens, index): + removeUntil = tokenStatementEnd(tokens, index) + processor.removeToken() + continue + + if shouldRemoveAbstractMember(tokens, index): + removeUntil = tokenStatementEnd(tokens, index) + processor.removeToken() + continue + + if token.isType: + processor.removeToken() + continue + + if isTsModifierToken(token): + processor.removeToken() + continue + + if token.typ == ttBang: + let next = index + 1 + if next >= tokens.len or tokens[next].typ in {ttDot, ttComma, ttParenR, ttBracketR, ttBraceR, ttSemi, ttEof, ttColon}: + processor.removeToken() + continue + + if token.typ == ttQuestion: + let next = index + 1 + if next < tokens.len and tokens[next].typ == ttColon: + processor.removeToken() + continue + + processor.copyToken() + + processor.finish().code + +proc stripAbstractMembers(code: string): string = + var i = 0 + while i < code.len: + if code[i] in {'\'', '"'}: + result.add(copyQuoted(code, i, code[i])) + continue + if code[i] == '`': + result.add(copyTemplateWithTransformedExpressions(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '/': + result.add(copyLineComment(code, i)) + continue + if code[i] == '/' and i + 1 < code.len and code[i + 1] == '*': + result.add(copyBlockComment(code, i)) + continue + + if startsWordAt(code, i, "abstract"): + var j = i + "abstract".len + skipSpaces(code, j) + if startsWordAt(code, j, "class"): + result.add(code[i]) + inc i + continue + let endStmt = findStatementEnd(code, i) + var k = endStmt + while k < code.len and code[k] in {' ', '\t'}: + inc k + if k >= code.len or code[k] in {'\n', '\r', ';', '}'}: + i = endStmt + continue + + result.add(code[i]) + inc i + +proc stripTypeScript(code: string): string = + result = code.lowerEnums() + result = result.transformConstructorParameterProperties() + result = result.stripTypeOnlyStatements() + result = result.stripDeclareStatements() + result = result.stripAbstractMembers() + result = result.stripTypeScriptModifiers() + result = result.stripTypeParametersAndArguments() + result = result.stripFunctionAndArrowTypes() + result = result.stripMethodAndMemberTypes() + result = result.stripVarTypes() + result = result.stripAsAssertions() + result = result.transformTemplateLiteralTypes() + result = result.tokenStripTypeScriptErasure() + +proc shouldStartJsx(code: string, i: int): bool = + if i + 1 >= code.len or code[i] != '<': + return false + if not (code[i + 1] == '>' or isIdentStart(code[i + 1])): + return false + var prev = i - 1 + while prev >= 0 and code[prev] in {' ', '\t', '\n', '\r'}: + dec prev + if prev < 0: + return true + if code[prev] in {'(', '[', '{', '=', ':', ',', ';', '!', '?', '>'}: + return true + if isIdentPart(code[prev]): + var start = prev + while start >= 0 and isIdentPart(code[start]): + dec start + let word = code[start + 1..prev] + return word in ["return", "yield", "case", "throw"] + false + +proc readBalancedBrace(p: var JsxParser): string = + if p.pos >= p.code.len or p.code[p.pos] != '{': + return "" + let start = p.pos + 1 + let close = findMatching(p.code, p.pos, '{', '}') + if close < 0: + raise newException(ValueError, "Unterminated JSX expression.") + p.pos = close + 1 + p.code[start..<close] + +proc transformJSX(code: string): string + +proc transformExpression(code: string): string = + transformJSX(stripTypeScript(code)) + +proc readJsxName(p: var JsxParser): string = + let start = p.pos + while p.pos < p.code.len and (isIdentPart(p.code[p.pos]) or p.code[p.pos] in {'-', ':', '.'}): + inc p.pos + p.code[start..<p.pos] + +proc jsxTagCode(name: string): string = + if name.len == 0: + return "\"\"" + if name[0] in {'a'..'z'} or '-' in name or ':' in name: + jsonQuote(name) + else: + name + +proc normalizedJsxText(raw: string): string = + let collapsed = raw.replace("\r", "\n").splitLines().mapIt(it.strip()).filterIt(it.len > 0).join(" ") + decodeJsxEntities(collapsed) + +proc parseJsxElement(p: var JsxParser): string + +proc parseJsxChildren(p: var JsxParser, closingName: string): seq[string] = + while p.pos < p.code.len: + if p.code[p.pos] == '<' and p.pos + 1 < p.code.len and p.code[p.pos + 1] == '/': + p.pos += 2 + if closingName.len > 0: + let found = readJsxName(p) + if found != closingName: + raise newException(ValueError, "Mismatched JSX closing tag: " & found) + skipSpaces(p.code, p.pos) + if p.pos < p.code.len and p.code[p.pos] == '>': + inc p.pos + return + if shouldStartJsx(p.code, p.pos): + result.add(parseJsxElement(p)) + continue + if p.code[p.pos] == '{': + let expression = readBalancedBrace(p).strip() + if expression.len > 0 and expression != "...": + result.add(transformExpression(expression)) + continue + let start = p.pos + while p.pos < p.code.len and not (p.code[p.pos] == '{' or p.code[p.pos] == '<'): + inc p.pos + let text = normalizedJsxText(p.code[start..<p.pos]) + if text.len > 0: + result.add(jsonQuote(text)) + +proc parseJsxProps(p: var JsxParser): string = + var props: seq[string] = @[] + while p.pos < p.code.len: + skipSpaces(p.code, p.pos) + if p.pos >= p.code.len or p.code[p.pos] == '>' or + (p.code[p.pos] == '/' and p.pos + 1 < p.code.len and p.code[p.pos + 1] == '>'): + break + if p.code[p.pos] == '{': + let expression = readBalancedBrace(p).strip() + if expression.startsWith("..."): + props.add("..." & transformExpression(expression[3..^1].strip())) + continue + let name = readJsxName(p) + if name.len == 0: + inc p.pos + continue + skipSpaces(p.code, p.pos) + if p.pos >= p.code.len or p.code[p.pos] != '=': + props.add(jsonQuote(name) & ": true") + continue + inc p.pos + skipSpaces(p.code, p.pos) + var value = "true" + if p.pos < p.code.len and p.code[p.pos] in {'\'', '"'}: + let raw = copyQuoted(p.code, p.pos, p.code[p.pos]) + value = + if raw.len >= 2: + jsonQuote(decodeJsxEntities(raw[1..^2])) + else: + raw + elif p.pos < p.code.len and p.code[p.pos] == '{': + value = transformExpression(readBalancedBrace(p)) + elif shouldStartJsx(p.code, p.pos): + value = parseJsxElement(p) + props.add(jsonQuote(name) & ": " & value) + if props.len == 0: + "null" + else: + "{" & props.join(", ") & "}" + +proc parseJsxElement(p: var JsxParser): string = + if p.pos >= p.code.len or p.code[p.pos] != '<': + raise newException(ValueError, "Expected JSX tag.") + p.pos += 1 + if p.pos < p.code.len and p.code[p.pos] == '>': + inc p.pos + let children = parseJsxChildren(p, "") + var args = @["__frameosFragment", "null"] + args.add(children) + return "__frameosJsx(" & args.join(", ") & ")" + + let name = readJsxName(p) + let props = parseJsxProps(p) + if p.pos + 1 < p.code.len and p.code[p.pos] == '/' and p.code[p.pos + 1] == '>': + p.pos += 2 + return "__frameosJsx(" & jsxTagCode(name) & ", " & props & ")" + if p.pos < p.code.len and p.code[p.pos] == '>': + inc p.pos + let children = parseJsxChildren(p, name) + var args = @[jsxTagCode(name), props] + args.add(children) + "__frameosJsx(" & args.join(", ") & ")" + +proc transformJSX(code: string): string = + let parsed = parseJs(code) + let tokens = parsed.tokens + var processor = initTokenProcessor(code, tokens) + + while not processor.isAtEnd(): + let token = processor.currentToken() + if token.typ == ttEof: + processor.copyToken() + continue + + if token.typ == ttJsxTagStart: + let next = processor.currentIndex() + 1 + if next < tokens.len and tokens[next].typ == ttSlash: + processor.copyToken() + continue + + var parser = JsxParser(code: code, pos: token.start) + let lowered = parseJsxElement(parser) + processor.replaceToken(lowered) + while not processor.isAtEnd() and processor.currentToken().typ != ttEof and + processor.currentToken().start < parser.pos: + inc processor.tokenIndex + continue + + processor.copyToken() + + processor.finish().code + +proc parseExportNames(spec: string): seq[(string, string)] = + for rawPart in spec.split(','): + let part = rawPart.strip() + if part.len == 0: + continue + let pieces = part.splitWhitespace() + if pieces.len == 1: + result.add((pieces[0], pieces[0])) + elif pieces.len == 3 and pieces[1] == "as": + result.add((pieces[0], pieces[2])) + +proc sanitizeModuleIdentifier(path: string): string = + result = "_" + for ch in path: + if ch.isAlphaNumeric: + result.add(ch) + else: + result.add('_') + if result.len == 1: + result.add("module") + +proc uniqueModuleIdentifier(path: string, counter: var int): string = + inc counter + sanitizeModuleIdentifier(path) & "_" & $counter + +proc unquoteModulePath(raw: string): string = + let value = raw.strip() + if value.len >= 2 and value[0] in {'\'', '"'} and value[^1] == value[0]: + value[1..^2] + else: + value + +proc splitTopLevelCommaList(spec: string): seq[string] = + var i = 0 + var partStart = 0 + var braceDepth = 0 + var bracketDepth = 0 + var parenDepth = 0 + while i < spec.len: + case spec[i] + of '\'', '"': + skipQuoted(spec, i, spec[i]) + continue + of '`': + skipTemplate(spec, i) + continue + of '{': + inc braceDepth + of '}': + if braceDepth > 0: dec braceDepth + of '[': + inc bracketDepth + of ']': + if bracketDepth > 0: dec bracketDepth + of '(': + inc parenDepth + of ')': + if parenDepth > 0: dec parenDepth + of ',': + if braceDepth == 0 and bracketDepth == 0 and parenDepth == 0: + let part = spec[partStart..<i].strip() + if part.len > 0: + result.add(part) + partStart = i + 1 + else: + discard + inc i + let lastPart = spec[partStart..^1].strip() + if lastPart.len > 0: + result.add(lastPart) + +proc parseImportExportSpecifiers(spec: string): seq[(string, string)] = + for rawPart in splitTopLevelCommaList(spec): + var part = rawPart.strip() + if part.startsWith("type "): + continue + let pieces = part.splitWhitespace() + if pieces.len == 1: + result.add((pieces[0], pieces[0])) + elif pieces.len == 3 and pieces[1] == "as": + result.add((pieces[0], pieces[2])) + +proc collectVarDeclarationNames(declaration: string): seq[string] = + let trimmed = declaration.strip() + var i = 0 + if startsWordAt(trimmed, i, "const"): + i += "const".len + elif startsWordAt(trimmed, i, "let"): + i += "let".len + elif startsWordAt(trimmed, i, "var"): + i += "var".len + else: + return + let declarators = trimmed[i..^1].strip().strip(chars = {';'}) + for part in splitTopLevelCommaList(declarators): + var namePos = 0 + let name = readIdentifier(part, namePos) + if name.len > 0: + result.add(name) + +proc emitImportDeclaration(stmt: string, moduleCounter: var int): string = + let stripped = stmt.strip().strip(chars = {';'}) + if stripped.startsWith("import type"): + return "" + if stripped.startsWith("import("): + return stmt + if not stripped.startsWith("import"): + return stmt + + var rest = stripped["import".len..^1].strip() + if rest.len == 0: + return "" + + let requireEquals = rest.find("= require") + if requireEquals > 0: + let localName = rest[0..<requireEquals].strip() + let requireCall = rest[requireEquals + 1..^1].strip() + if localName.len > 0: + return "const " & localName & " = " & requireCall & ";" + + if rest[0] in {'\'', '"'}: + return "require(" & rest & ");" + + let fromIndex = rest.rfind(" from ") + if fromIndex < 0: + return "throw new Error(\"Unsupported import declaration\");" + let bindings = rest[0..<fromIndex].strip() + let pathCode = rest[fromIndex + " from ".len..^1].strip() + let path = unquoteModulePath(pathCode) + let moduleName = uniqueModuleIdentifier(path, moduleCounter) + result = "var " & moduleName & " = require(" & pathCode & ");" + + var remaining = bindings + if remaining.len == 0: + return + + if remaining.startsWith("*"): + let pieces = remaining.splitWhitespace() + if pieces.len >= 3 and pieces[1] == "as": + result.add(" var " & pieces[2] & " = " & moduleName & ";") + return + + if remaining.startsWith("{"): + let close = remaining.rfind("}") + if close >= 0: + for (importedName, localName) in parseImportExportSpecifiers(remaining[1..<close]): + result.add(" var " & localName & " = " & moduleName & "." & importedName & ";") + return + + let commaIndex = remaining.find(',') + if commaIndex >= 0: + let defaultName = remaining[0..<commaIndex].strip() + if defaultName.len > 0: + result.add(" var " & defaultName & " = " & moduleName & ".default;") + remaining = remaining[commaIndex + 1..^1].strip() + if remaining.startsWith("*"): + let pieces = remaining.splitWhitespace() + if pieces.len >= 3 and pieces[1] == "as": + result.add(" var " & pieces[2] & " = " & moduleName & ";") + elif remaining.startsWith("{"): + let close = remaining.rfind("}") + if close >= 0: + for (importedName, localName) in parseImportExportSpecifiers(remaining[1..<close]): + result.add(" var " & localName & " = " & moduleName & "." & importedName & ";") + else: + result.add(" var " & remaining & " = " & moduleName & ".default;") + +proc emitExportFromDeclaration(stmt: string, moduleCounter: var int): string = + let stripped = stmt.strip().strip(chars = {';'}) + if not stripped.startsWith("export"): + return stmt + var rest = stripped["export".len..^1].strip() + if rest.startsWith("type "): + return "" + if rest.startsWith("*"): + let fromIndex = rest.rfind(" from ") + if fromIndex < 0: + return "throw new Error(\"Unsupported export star declaration\");" + let pathCode = rest[fromIndex + " from ".len..^1].strip() + let path = unquoteModulePath(pathCode) + let moduleName = uniqueModuleIdentifier(path, moduleCounter) + if rest.startsWith("* as "): + let exportedName = rest["* as ".len..<fromIndex].strip() + return "exports." & exportedName & " = require(" & pathCode & ");" + return "var " & moduleName & " = require(" & pathCode & "); Object.keys(" & moduleName & ").forEach(function (key) { if (key !== \"default\" && key !== \"__esModule\") exports[key] = " & moduleName & "[key]; });" + if rest.startsWith("{"): + let close = rest.find('}') + if close < 0: + return "throw new Error(\"Unsupported export declaration\");" + var after = close + 1 + skipSpaces(rest, after) + if not startsWordAt(rest, after, "from"): + return "" + after += "from".len + skipSpaces(rest, after) + let pathCode = rest[after..^1].strip() + let path = unquoteModulePath(pathCode) + let moduleName = uniqueModuleIdentifier(path, moduleCounter) + result = "var " & moduleName & " = require(" & pathCode & ");" + for (importedName, exportedName) in parseImportExportSpecifiers(rest[1..<close]): + result.add(" exports." & exportedName & " = " & moduleName & "." & importedName & ";") + return + stmt + +proc tokenStatementSlice(code: string, tokens: seq[JsToken], startIndex, endIndex: int): string = + if startIndex < 0 or startIndex >= tokens.len: + return "" + let startPos = tokens[startIndex].start + let endPos = + if endIndex >= 0 and endIndex < tokens.len and tokens[endIndex].typ != ttEof: + tokens[endIndex].`end` + elif endIndex > startIndex and endIndex - 1 < tokens.len: + tokens[endIndex - 1].`end` + else: + tokens[startIndex].`end` + if startPos >= 0 and endPos >= startPos and endPos <= code.len: + code[startPos..<endPos] + else: + "" + +proc skipProcessorThrough(processor: var TokenProcessor, endIndex: int) = + while not processor.isAtEnd() and processor.currentIndex() <= endIndex and processor.currentToken().typ != ttEof: + processor.removeToken() + +proc exportDeclarationName(code: string, tokens: seq[JsToken], startIndex: int): string = + var i = startIndex + if i < tokens.len and tokens[i].typ == ttAsync: + inc i + if i < tokens.len and tokens[i].typ in {ttFunction, ttClass}: + inc i + if i < tokens.len and tokens[i].typ == ttStar: + inc i + if i < tokens.len and tokens[i].typ in {ttName, ttGet, ttSet}: + tokenRaw(code, tokens[i]) + else: + "" + +proc transformImportsTokenDriven(code: string): string = + let parsed = parseJs(code) + let tokens = parsed.tokens + var processor = initTokenProcessor(code, tokens) + var moduleCounter = 0 + var imports: seq[string] = @[] + var exports: seq[string] = @[] + + while not processor.isAtEnd(): + let index = processor.currentIndex() + let token = processor.currentToken() + + if token.typ == ttEof: + processor.copyToken() + continue + + if token.typ == ttImport: + let next = index + 1 + if next < tokens.len and tokens[next].typ notin {ttParenL, ttDot}: + let endIndex = tokenStatementEnd(tokens, index) + let stmt = tokenStatementSlice(code, tokens, index, endIndex).strip() + if not stmt.startsWith("import("): + let emitted = emitImportDeclaration(stmt, moduleCounter) + if emitted.len > 0: + imports.add(emitted) + processor.skipProcessorThrough(endIndex) + continue + + if token.typ == ttExport: + let endIndex = tokenStatementEnd(tokens, index) + var j = index + 1 + if j < tokens.len and tokens[j].typ == ttType: + processor.skipProcessorThrough(endIndex) + continue + + if j < tokens.len and tokens[j].typ == ttStar: + let emitted = emitExportFromDeclaration(tokenStatementSlice(code, tokens, index, endIndex), moduleCounter) + if emitted.len > 0: + imports.add(emitted) + processor.skipProcessorThrough(endIndex) + continue + + if j < tokens.len and tokens[j].typ == ttBraceL: + let close = tokenMatching(tokens, j, ttBraceL, ttBraceR) + var after = close + 1 + if close >= 0 and after < tokens.len and tokens[after].typ == ttName and tokenRaw(code, tokens[after]) == "from": + let emitted = emitExportFromDeclaration(tokenStatementSlice(code, tokens, index, endIndex), moduleCounter) + if emitted.len > 0: + imports.add(emitted) + processor.skipProcessorThrough(endIndex) + continue + if close >= 0: + for (localName, exportedName) in parseExportNames(code[tokens[j].`end`..<tokens[close].start]): + exports.add("exports." & exportedName & " = " & localName & ";") + processor.skipProcessorThrough(endIndex) + continue + + if j < tokens.len and tokens[j].typ in {ttConst, ttLet, ttVar}: + let declaration = tokenStatementSlice(code, tokens, j, endIndex) + for name in collectVarDeclarationNames(declaration): + exports.add("exports." & name & " = " & name & ";") + processor.removeToken() + continue + + if j < tokens.len and tokens[j].typ in {ttFunction, ttClass, ttAsync}: + let name = exportDeclarationName(code, tokens, j) + if name.len > 0: + exports.add("exports." & name & " = " & name & ";") + processor.removeToken() + continue + + if j < tokens.len and tokens[j].typ == ttDefault: + var declarationStart = j + 1 + if declarationStart < tokens.len and tokens[declarationStart].typ in {ttFunction, ttClass, ttAsync}: + let name = exportDeclarationName(code, tokens, declarationStart) + if name.len > 0: + exports.add("exports.default = " & name & ";") + processor.removeToken() + if not processor.isAtEnd() and processor.currentIndex() == j: + processor.removeToken() + continue + processor.replaceToken("exports.default =") + if not processor.isAtEnd() and processor.currentIndex() == j: + processor.removeToken() + continue + + processor.copyToken() + + let body = processor.finish().code + result = "\"use strict\";Object.defineProperty(exports, \"__esModule\", {value: true});" + if imports.len > 0: + result.add(imports.join("")) + result.add(body) + if exports.len > 0: + result.add("\n") + result.add(exports.join("\n")) + +proc transformImports(code: string): string = + return transformImportsTokenDriven(code) + +proc transform*(code: string, options: TransformOptions): TransformResult = + let originalCode = code + let path = if options.filePath.len == 0: "<frameos>" else: options.filePath + try: + result.code = code + if options.hasTransform("typescript"): + result.code = stripTypeScript(result.code) + if options.hasTransform("jsx"): + result.code = transformJSX(result.code) + if options.hasTransform("typescript"): + result.code = stripTypeScript(result.code) + if options.hasTransform("imports"): + result.code = transformImports(result.code) + result.sourceMap = lineBasedSourceLineMap(originalCode, result.code, path, path) + except CatchableError as error: + raise newException(ValueError, "Error transforming " & path & ": " & error.msg) + +proc transformFrameosScript*(code: string, filePath: string = "<frameos>"): string = + transform(code, TransformOptions(filePath: filePath, transforms: defaultTransforms)).code + +proc transformFrameosModule*(code: string, filePath: string = "<frameos>"): string = + transform(code, TransformOptions(filePath: filePath, transforms: moduleTransforms)).code diff --git a/frameos/src/frameos/runner.nim b/frameos/src/frameos/runner.nim index e1b3ce00c..1ca6a7f48 100644 --- a/frameos/src/frameos/runner.nim +++ b/frameos/src/frameos/runner.nim @@ -29,6 +29,14 @@ const RENDER_SLEEP_SLICE_MS = 100.0 var thread: Thread[(FrameConfig, Logger, Option[SceneId])] +proc logSignal(self: RunnerThread, payload: JsonNode) = + let wasEnabled = self.logger.enabled + if not wasEnabled: + self.logger.enable() + self.logger.log(payload) + if not wasEnabled: + self.logger.disable() + proc renderSceneInitError(scene: FrameScene, context: ExecutionContext): Image = let message = scene.state{SCENE_INIT_ERROR_STATE_KEY}.getStr("Scene failed to start.") context.image = renderError(context.image.width, context.image.height, message) @@ -133,12 +141,12 @@ proc renderSceneImage*(self: RunnerThread, exportedScene: ExportedScene, scene: let errorImage = renderError(requiredWidth, requiredHeight, &"Error: {$e.msg}\n{$e.getStackTrace()}") setLastImage(errorImage) result = (errorImage, context.nextSleep) - self.logger.log(%*{"event": "render:error", "error": $e.msg, "stacktrace": e.getStackTrace()}) + self.logSignal(%*{"event": "render:error", "error": $e.msg, "stacktrace": e.getStackTrace()}) self.lastRenderAt = epochTime() let elapsedMs = durationToMilliseconds(getMonoTime() - sceneTimer) markRuntimeCheckpoint("scene:done", currentSceneId = scene.id.string, clearNode = true) - self.logger.log(%*{"event": "render:done", "sceneId": scene.id.string, "ms": round(elapsedMs, 3)}) + self.logSignal(%*{"event": "render:done", "sceneId": scene.id.string, "ms": round(elapsedMs, 3)}) proc startRenderLoop*(self: RunnerThread, maxCycles = -1): Future[void] {.async.} = self.logger.log(%*{"event": "render:startLoop"}) @@ -166,7 +174,7 @@ proc startRenderLoop*(self: RunnerThread, maxCycles = -1): Future[void] {.async. sceneId = getFirstSceneId() exportedScene = findExportedScene(sceneId) if exportedScene.isNone: - self.logger.log(%*{"event": "render:error:scene:missing", "sceneId": sceneId.string}) + self.logSignal(%*{"event": "render:error:scene:missing", "sceneId": sceneId.string}) self.isRendering = false await sleepAsync(RENDER_SLEEP_SLICE_MS) continue @@ -174,7 +182,7 @@ proc startRenderLoop*(self: RunnerThread, maxCycles = -1): Future[void] {.async. self.currentSceneId = sceneId if lastSceneId != sceneId: var sceneInitialized = true - self.logger.log(%*{"event": "render:sceneChange", "sceneId": sceneId.string}) + self.logSignal(%*{"event": "render:sceneChange", "sceneId": sceneId.string}) # Persist the active scene context early in boot, then stop writing it # after a few successful renders to reduce SD card writes. if shouldPersistBootGuardContextForScene(sceneId.string, successfulSceneRenders): @@ -190,7 +198,7 @@ proc startRenderLoop*(self: RunnerThread, maxCycles = -1): Future[void] {.async. sceneInitialized = false currentScene = initSceneInitErrorScene(sceneId, self.frameConfig, self.logger, $e.msg) exportedScene = some(sceneInitErrorExport()) - self.logger.log(%*{"event": "render:error:scene:init", "error": $e.msg, "stacktrace": e.getStackTrace()}) + self.logSignal(%*{"event": "render:error:scene:init", "error": $e.msg, "stacktrace": e.getStackTrace()}) if sceneInitialized: lastSceneId = sceneId @@ -293,12 +301,12 @@ proc triggerRender*(self: RunnerThread): void = proc dispatchSceneEvent*(self: RunnerThread, sceneId: Option[SceneId], event: string, payload: JsonNode) = let targetSceneId: SceneId = if sceneId.isSome: sceneId.get() else: self.currentSceneId if not self.scenes.hasKey(targetSceneId): - self.logger.log(%*{"event": "dispatchEvent:error", "error": "Scene not initialized", + self.logSignal(%*{"event": "dispatchEvent:error", "error": "Scene not initialized", "sceneId": targetSceneId.string, "event": event, "payload": payload}) return let exportedScene = findExportedScene(targetSceneId) if exportedScene.isNone: - self.logger.log(%*{"event": "dispatchEvent:error", "error": "Scene not exported", + self.logSignal(%*{"event": "dispatchEvent:error", "error": "Scene not exported", "sceneId": targetSceneId.string, "event": event, "payload": payload}) return let scene = self.scenes[targetSceneId] @@ -332,7 +340,7 @@ proc startMessageLoop*(self: RunnerThread, maxIterations = -1): Future[void] {.a if success: waitTime = 1 if not event.startsWith("mouse"): - self.logger.log(%*{"event": "event:" & event, "payload": payload}) + self.logSignal(%*{"event": "event:" & event, "payload": payload}) try: case event: of "render": @@ -353,7 +361,7 @@ proc startMessageLoop*(self: RunnerThread, maxIterations = -1): Future[void] {.a let sceneId = SceneId(payload["sceneId"].getStr()) let exportedScene = findExportedScene(sceneId) if exportedScene.isNone: - self.logger.log(%*{"event": "dispatchEvent:error", "error": "Scene not found", "sceneId": sceneId.string, + self.logSignal(%*{"event": "dispatchEvent:error", "error": "Scene not found", "sceneId": sceneId.string, "event": event, "payload": payload}) continue if sceneId != self.currentSceneId: @@ -401,7 +409,7 @@ proc startMessageLoop*(self: RunnerThread, maxIterations = -1): Future[void] {.a else: discard self.dispatchSceneEvent(sceneId, event, payload) except Exception as e: - self.logger.log(%*{"event": "event:error", "error": $e.msg, "stacktrace": e.getStackTrace()}) + self.logSignal(%*{"event": "event:error", "error": $e.msg, "stacktrace": e.getStackTrace()}) # after we have processed all queued messages if not success: diff --git a/frameos/src/frameos/scenes.nim b/frameos/src/frameos/scenes.nim index 7c639c112..b043125bf 100644 --- a/frameos/src/frameos/scenes.nim +++ b/frameos/src/frameos/scenes.nim @@ -4,7 +4,7 @@ import scenes/scenes import system/scenes as systemScenesRegistry import frameos/types import frameos/interpreter -import frameos/js_runtime +import frameos/js_runtime/runtime # Where to store the persisted states const SCENE_STATE_JSON_FOLDER = "./state" diff --git a/frameos/src/frameos/tests/test_js_app_runtime.nim b/frameos/src/frameos/tests/test_js_app_runtime.nim deleted file mode 100644 index bc3f08abf..000000000 --- a/frameos/src/frameos/tests/test_js_app_runtime.nim +++ /dev/null @@ -1,139 +0,0 @@ -import std/[json, tables, unittest] -import pixie - -import ../js_app_runtime -import ../types -import ../values - -proc testConfig(): FrameConfig = - FrameConfig( - width: 6, - height: 4, - rotate: 0, - scalingMode: "cover", - debug: true, - saveAssets: %*false, - assetsPath: "/tmp" - ) - -proc testLogger(config: FrameConfig): Logger = - var logger = Logger(frameConfig: config, enabled: true) - logger.log = proc(payload: JsonNode) = - discard payload - logger.enable = proc() = - logger.enabled = true - logger.disable = proc() = - logger.enabled = false - logger - -suite "js app runtime": - test "returns string, node, and image values": - let config = testConfig() - let logger = testLogger(config) - let scene = FrameScene(id: "tests/js-app".SceneId, frameConfig: config, state: %*{}, logger: logger) - let owner = AppRoot(nodeId: 7.NodeId, nodeName: "jsText", scene: scene, frameConfig: config) - let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) - - let runtime = newJsAppRuntime( - category = "data", - outputType = "text", - source = """export const get = (app: { config: { mode: string; message?: string; targetNode?: number } }, context: { event: string }) => { - if (app.config.mode === "image") { - return <image width={3} height={2} color="#336699" /> - } - if (app.config.mode === "node") { - return frameos.node(app.config.targetNode) - } - return `${app.config.message}:${context.event}` - }""" - ) - - let textValue = runtime.get(owner, %*{"message": "hello", "mode": "text"}, context) - check textValue.kind == fkString - check textValue.asString() == "hello:render" - - let nodeValue = runtime.get(owner, %*{"mode": "node", "targetNode": 9}, context) - check nodeValue.kind == fkNode - check nodeValue.asNode() == 9.NodeId - - let imageValue = runtime.get(owner, %*{"mode": "image"}, context) - check imageValue.kind == fkImage - check imageValue.asImage().width == 3 - check imageValue.asImage().height == 2 - - test "run can set next sleep, state, and draw a render image": - let config = testConfig() - let logger = testLogger(config) - let scene = FrameScene(id: "tests/js-app-run".SceneId, frameConfig: config, state: %*{}, logger: logger) - let owner = AppRoot(nodeId: 8.NodeId, nodeName: "jsLogic", scene: scene, frameConfig: config) - var image = newImage(4, 3) - let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: true, image: image, loopIndex: 0, loopKey: ".", nextSleep: -1) - - let runtime = newJsAppRuntime( - category = "render", - outputType = "image", - source = """export function run(app: { config: { duration: number } }) { - frameos.setNextSleep(app.config.duration) - frameos.setState("lastDuration", app.config.duration) - return <image width={4} height={3} color="#ff0000" /> - }""" - ) - - runtime.run(owner, %*{"duration": 12.5}, context) - check abs(context.nextSleep - 12.5) < 0.0001 - check scene.state["lastDuration"].getFloat() == 12.5 - let pixel = context.image.data[context.image.dataIndex(0, 0)] - check pixel.r > 0 - check runtime.images.len == 0 - - test "clears transient context image refs after JS calls": - let config = testConfig() - let logger = testLogger(config) - let scene = FrameScene(id: "tests/js-app-image-refs".SceneId, frameConfig: config, state: %*{}, logger: logger) - let owner = AppRoot(nodeId: 9.NodeId, nodeName: "jsImageRefs", scene: scene, frameConfig: config) - - let runtime = newJsAppRuntime( - category = "data", - outputType = "image", - source = """export function get(app, context) { - return context.image - }""" - ) - - for i in 0..<3: - let image = newImage(4 + i, 3) - let context = ExecutionContext(scene: scene, event: "render", payload: %*{}, hasImage: true, image: image, loopIndex: i, loopKey: ".", nextSleep: -1) - let value = runtime.get(owner, %*{}, context) - check value.kind == fkImage - check value.asImage().width == 4 + i - check value.asImage().height == 3 - check runtime.images.len == 0 - - test "releases overwritten dynamic field image refs": - let config = testConfig() - let logger = testLogger(config) - let scene = FrameScene(id: "tests/js-app-field-refs".SceneId, frameConfig: config, state: %*{}, logger: logger) - let runtime = newJsAppRuntime(category = "data", outputType = "image", source = "export const get = () => null") - let app = DynamicJsApp( - nodeId: 10.NodeId, - nodeName: "jsFieldRefs", - scene: scene, - frameConfig: config, - configJson: %*{}, - runtime: runtime - ) - - setDynamicJsAppField(app, "inputImage", VImage(newImage(4, 3))) - check runtime.images.len == 1 - let firstId = app.configJson["inputImage"]["id"].getInt() - check runtime.images.hasKey(firstId) - - setDynamicJsAppField(app, "inputImage", VImage(newImage(5, 3))) - check runtime.images.len == 1 - check not runtime.images.hasKey(firstId) - let secondId = app.configJson["inputImage"]["id"].getInt() - check secondId != firstId - check runtime.images.hasKey(secondId) - - setDynamicJsAppField(app, "inputImage", VString("not an image")) - check runtime.images.len == 0 diff --git a/frameos/src/frameos/tests/test_runner_loop.nim b/frameos/src/frameos/tests/test_runner_loop.nim index ef943d8ae..c14f1fd53 100644 --- a/frameos/src/frameos/tests/test_runner_loop.nim +++ b/frameos/src/frameos/tests/test_runner_loop.nim @@ -37,7 +37,8 @@ proc resetBootGuardState() = proc testLogger(config: FrameConfig, store: LogStore): Logger = var logger = Logger(frameConfig: config, enabled: true) logger.log = proc(payload: JsonNode) = - store.entries.add(payload) + if logger.enabled: + store.entries.add(payload) logger.enable = proc() = logger.enabled = true logger.disable = proc() = @@ -61,12 +62,32 @@ proc hasEvent(store: LogStore, eventName: string): bool = store.entries.anyIt(it.kind == JObject and it.hasKey("event") and it["event"].kind == JString and it["event"].getStr() == eventName) +proc countEvent(store: LogStore, eventName: string): int = + for entry in store.entries: + if entry.kind == JObject and entry.hasKey("event") and entry["event"].kind == JString and + entry["event"].getStr() == eventName: + result += 1 + proc failingInit(sceneId: SceneId, frameConfig: FrameConfig, logger: Logger, persistedState: JsonNode): FrameScene = raise newException(IOError, "network path unavailable") +proc fastInit(sceneId: SceneId, frameConfig: FrameConfig, logger: Logger, persistedState: JsonNode): FrameScene = + FrameScene( + id: sceneId, + frameConfig: frameConfig, + logger: logger, + state: %*{}, + refreshInterval: 0.05, + backgroundColor: parseHtmlColor("#ffffff") + ) + proc unusedRender(scene: FrameScene, context: ExecutionContext): Image = context.image +proc fastRender(scene: FrameScene, context: ExecutionContext): Image = + context.image.fill(scene.backgroundColor) + context.image + proc failingRender(scene: FrameScene, context: ExecutionContext): Image = raise newException(IOError, "network request failed") @@ -168,6 +189,129 @@ suite "runner loop safety": updateUploadedScenes(initTable[SceneId, ExportedInterpretedScene]()) restoreBootGuardState(savedBootGuardState) + test "render signals are logged while fast render logging is paused": + let sceneId = "tests/runner/fast-render".SceneId + try: + var uploaded = initTable[SceneId, ExportedInterpretedScene]() + uploaded[sceneId] = ExportedInterpretedScene( + name: "Fast render scene", + publicStateFields: @[], + persistedStateKeys: @[], + init: fastInit, + render: fastRender, + runEvent: proc (self: FrameScene, context: ExecutionContext): void = discard + ) + updateUploadedScenes(uploaded) + + var config = loadConfig() + config.controlCode = ControlCode( + enabled: false, + position: "center", + size: 0, + padding: 0, + offsetX: 0, + offsetY: 0, + qrCodeColor: parseHtmlColor("#000000"), + backgroundColor: parseHtmlColor("#ffffff") + ) + + let store = LogStore(entries: @[]) + let logger = testLogger(config, store) + var runnerThread = RunnerThread( + frameConfig: config, + scenes: initTable[SceneId, FrameScene](), + currentSceneId: sceneId, + lastRenderAt: 0.0, + sleepFuture: none(Future[void]), + isRendering: false, + triggerRenderNext: false, + logger: logger + ) + + waitFor runnerThread.startRenderLoop(maxCycles = 3) + + check countEvent(store, "render:pause") == 1 + check countEvent(store, "render:done") == 3 + check not logger.enabled + finally: + updateUploadedScenes(initTable[SceneId, ExportedInterpretedScene]()) + + test "activation control events are logged while render logging is paused": + clearEventChannel() + + var config = loadConfig() + let store = LogStore(entries: @[]) + let logger = testLogger(config, store) + logger.disable() + var runnerThread = RunnerThread( + frameConfig: config, + scenes: initTable[SceneId, FrameScene](), + currentSceneId: getFirstSceneId(), + lastRenderAt: 0.0, + sleepFuture: none(Future[void]), + isRendering: false, + triggerRenderNext: false, + logger: logger + ) + + let messageLoop = runnerThread.startMessageLoop(maxIterations = 2) + sendEvent("setCurrentScene", %*{"sceneId": "tests/runner/missing-scene"}) + + let finished = waitUntil(proc(): bool = messageLoop.finished, steps = 200, stepMs = 5) + check finished + if finished: + waitFor messageLoop + check hasEvent(store, "event:setCurrentScene") + check not logger.enabled + + test "scene changes are logged while render logging is paused": + let sceneId = "tests/runner/paused-scene-change".SceneId + try: + var uploaded = initTable[SceneId, ExportedInterpretedScene]() + uploaded[sceneId] = ExportedInterpretedScene( + name: "Paused scene change", + publicStateFields: @[], + persistedStateKeys: @[], + init: fastInit, + render: fastRender, + runEvent: proc (self: FrameScene, context: ExecutionContext): void = discard + ) + updateUploadedScenes(uploaded) + + var config = loadConfig() + config.controlCode = ControlCode( + enabled: false, + position: "center", + size: 0, + padding: 0, + offsetX: 0, + offsetY: 0, + qrCodeColor: parseHtmlColor("#000000"), + backgroundColor: parseHtmlColor("#ffffff") + ) + + let store = LogStore(entries: @[]) + let logger = testLogger(config, store) + logger.disable() + var runnerThread = RunnerThread( + frameConfig: config, + scenes: initTable[SceneId, FrameScene](), + currentSceneId: sceneId, + lastRenderAt: 0.0, + sleepFuture: none(Future[void]), + isRendering: false, + triggerRenderNext: false, + logger: logger + ) + + waitFor runnerThread.startRenderLoop(maxCycles = 1) + + check hasEvent(store, "render:sceneChange") + check hasEvent(store, "render:done") + check not logger.enabled + finally: + updateUploadedScenes(initTable[SceneId, ExportedInterpretedScene]()) + test "scene render errors do not update boot guard failure details": let savedBootGuardState = saveBootGuardState() try: diff --git a/frameos/src/frameos/types.nim b/frameos/src/frameos/types.nim index 8408340d4..78d3c5b06 100644 --- a/frameos/src/frameos/types.nim +++ b/frameos/src/frameos/types.nim @@ -1,7 +1,7 @@ import json, pixie, locks, tables, options, asyncdispatch, mummy import frameos/ids export ids -import lib/burrito +import frameos/js_runtime/burrito const DefaultMaxHttpResponseBytes* = 64 * 1024 * 1024 diff --git a/frameos/src/frameos/values.nim b/frameos/src/frameos/values.nim index 7907f7362..94c87fbef 100644 --- a/frameos/src/frameos/values.nim +++ b/frameos/src/frameos/values.nim @@ -91,6 +91,9 @@ proc parseBoolish*(s: string): bool = result = (t in ["true", "1", "yes", "y"]) proc valueFromJsonByType*(j: JsonNode; fieldType: string): Value = + if j.isNil: + return valueFromJsonByType(newJNull(), fieldType) + case fieldType of "integer": var v = 0 diff --git a/frameos/tools/install_prebuilt_quickjs.py b/frameos/tools/install_prebuilt_quickjs.py index 835427414..0b66c43e4 100644 --- a/frameos/tools/install_prebuilt_quickjs.py +++ b/frameos/tools/install_prebuilt_quickjs.py @@ -16,7 +16,7 @@ import urllib.request from pathlib import Path -QUICKJS_VERSION = os.environ.get("QUICKJS_VERSION", "2025-04-26") +QUICKJS_VERSION = os.environ.get("QUICKJS_VERSION", "2026-06-04") ARCHIVE_BASE_URL = os.environ.get("FRAMEOS_ARCHIVE_BASE_URL", "https://archive.frameos.net/") MANIFEST_PATH = "prebuilt-deps/manifest.json" TIMEOUT = float(os.environ.get("FRAMEOS_PREBUILT_TIMEOUT", "20")) diff --git a/frameos/tools/native_js_transpile.nim b/frameos/tools/native_js_transpile.nim new file mode 100644 index 000000000..4e583eda9 --- /dev/null +++ b/frameos/tools/native_js_transpile.nim @@ -0,0 +1,70 @@ +import std/[json, os] + +import frameos/js_runtime/parser +import frameos/js_runtime/source_map +import frameos/js_runtime/tokens +import frameos/js_runtime/transpiler + +if paramCount() < 2: + stderr.writeLine("Usage: native_js_transpile <script|module|script-json|module-json|tokens|parse> <source-file>") + quit(2) + +let mode = paramStr(1) +let path = paramStr(2) +let source = readFile(path) + +proc sourceMapToJson(sourceMap: SourceLineMap): JsonNode = + let segments = newJArray() + for segment in sourceMap.segments: + segments.add(%*{ + "generatedLine": segment.generatedLine, + "generatedColumn": segment.generatedColumn, + "sourceLine": segment.sourceLine, + "sourceColumn": segment.sourceColumn, + }) + + %*{ + "generatedName": sourceMap.generatedName, + "sourceName": sourceMap.sourceName, + "generatedToSourceLine": sourceMap.generatedToSourceLine, + "segments": segments, + } + +proc writeTransformJson(transformed: TransformResult) = + stdout.write($(%*{ + "ok": true, + "code": transformed.code, + "sourceMap": transformed.sourceMap.sourceMapToJson(), + })) + +try: + case mode + of "script": + stdout.write(transformFrameosScript(source, path)) + of "module": + stdout.write(transformFrameosModule(source, path)) + of "script-json": + writeTransformJson(transform(source, TransformOptions(filePath: path, transforms: @["typescript", "jsx"]))) + of "module-json": + writeTransformJson(transform(source, TransformOptions(filePath: path, transforms: @["typescript", "jsx", "imports"]))) + of "tokens": + stdout.write(formatTokens(source, tokenizeJs(source))) + of "parse": + stdout.write(formatAnnotatedTokens(source, parseJs(source))) + else: + stderr.writeLine("Unknown mode: " & mode) + quit(2) +except CatchableError as error: + if mode in ["script-json", "module-json"]: + stdout.write($(%*{ + "ok": false, + "errors": [ + { + "text": error.msg, + "location": {"line": 1, "column": 1}, + } + ], + })) + else: + stderr.writeLine(error.msg) + quit(1) diff --git a/frameos/tools/prepare_assets.py b/frameos/tools/prepare_assets.py index 6755d949e..d22c11306 100644 --- a/frameos/tools/prepare_assets.py +++ b/frameos/tools/prepare_assets.py @@ -20,10 +20,6 @@ Path("assets/compiled/frame_web/static/main.css"), ) -OPTIONAL_FRONTEND_OUTPUTS = ( - Path("assets/compiled/vendor/sucrase.js"), -) - MODULE_OUTPUTS = ( Path("src/assets/apps.nim"), Path("src/assets/web.nim"), @@ -31,10 +27,6 @@ Path("src/assets/fonts.nim"), ) -OPTIONAL_MODULE_OUTPUTS = ( - (Path("assets/compiled/vendor"), Path("src/assets/vendor.nim")), -) - MODULE_SOURCE_FILES = ( Path("assets/compiled/web/control.html"), Path("assets/compiled/fonts/Ubuntu-Regular.ttf"), @@ -195,30 +187,17 @@ def hash_module_inputs(project_root: Path) -> str: entries = [ *MODULE_SOURCE_FILES, Path("assets/compiled/frame_web"), - Path("assets/compiled/vendor"), *iter_app_config_entries(project_root), ] return hash_inputs(project_root, entries) def frontend_outputs_exist(project_root: Path) -> bool: - if not all((project_root / output).is_file() for output in FRONTEND_OUTPUTS): - return False - for output in OPTIONAL_FRONTEND_OUTPUTS: - parent = project_root / output.parent - if parent.exists() and any(parent.rglob("*")) and not (project_root / output).is_file(): - return False - return True + return all((project_root / output).is_file() for output in FRONTEND_OUTPUTS) def module_outputs_exist(project_root: Path) -> bool: - if not all((project_root / output).is_file() for output in MODULE_OUTPUTS): - return False - for source_dir, output in OPTIONAL_MODULE_OUTPUTS: - source_root = project_root / source_dir - if source_root.exists() and any(source_root.rglob("*")) and not (project_root / output).is_file(): - return False - return True + return all((project_root / output).is_file() for output in MODULE_OUTPUTS) def load_manifest(project_root: Path) -> AssetsManifest | None: @@ -373,16 +352,6 @@ def generate_asset_modules(project_root: Path) -> None: "--output", "src/assets/fonts.nim", ], - [ - python, - "tools/generate_compressed_asset_nim.py", - "--source-dir", - ".", - "--dir", - "assets/compiled/vendor", - "--output", - "src/assets/vendor.nim", - ], ) for command in commands: diff --git a/frameos/tools/run_test_shard.sh b/frameos/tools/run_test_shard.sh index de24a2914..9f1afece5 100644 --- a/frameos/tools/run_test_shard.sh +++ b/frameos/tools/run_test_shard.sh @@ -83,7 +83,7 @@ declare -a shard_1_tests=( "src/apps/data/weather/tests/test_app.nim" "src/apps/data/rotateImage/tests/test_app.nim" "src/apps/data/prettyJson/tests/test_app.nim" - "src/frameos/tests/test_scene_runtime_cleanup.nim" + "src/frameos/js_runtime/tests/test_scene_runtime_cleanup.nim" "src/frameos/tests/test_scenes_persistence.nim" "src/apps/render/svg/tests/test_app.nim" "src/apps/data/icalJson/tests/test_ical.nim" @@ -147,7 +147,7 @@ declare -a shard_4_tests=( "src/frameos/server/tests/test_web_routes_behavior.nim" "src/apps/data/unsplash/tests/test_app.nim" "src/frameos/tests/test_logger.nim" - "src/frameos/tests/test_js_runtime_helpers.nim" + "src/frameos/js_runtime/tests/test_js_runtime_helpers.nim" "src/frameos/utils/tests/test_url.nim" "src/frameos/utils/tests/test_dither.nim" ) @@ -157,7 +157,7 @@ declare -a shard_5_tests=( "src/frameos/server/tests/test_auth.nim" "src/frameos/tests/test_apps_helpers.nim" "src/apps/data/clock/tests/test_app.nim" - "src/frameos/tests/test_js_app_runtime.nim" + "src/frameos/js_runtime/tests/test_js_app_runtime.nim" "src/frameos/tests/test_scenes_registry_state_cleanup.nim" "src/apps/data/frameOSGallery/tests/test_app.nim" "src/apps/data/beRecycle/tests/test_app.nim" diff --git a/frameos/tools/tests/test_native_js_transpiler_parity.py b/frameos/tools/tests/test_native_js_transpiler_parity.py new file mode 100644 index 000000000..7443d7aef --- /dev/null +++ b/frameos/tools/tests/test_native_js_transpiler_parity.py @@ -0,0 +1,561 @@ +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +FRAMEOS_ROOT = ROOT / "frameos" +FRAME_FRONTEND_ROOT = FRAMEOS_ROOT / "frontend" +NATIVE_CLI = FRAMEOS_ROOT / "tools" / "native_js_transpile.nim" + + +JSX_PRELUDE = r""" +const __frameosFragment = Symbol.for("frameos.fragment"); +const __frameosNormalizeChildren = (children) => { + if (children.length === 0) return undefined; + if (children.length === 1) return children[0]; + return children; +}; +const __frameosJsx = (type, props, ...children) => { + const nextProps = props ? { ...props } : {}; + const explicitChildren = __frameosNormalizeChildren(children); + const propChildren = Object.prototype.hasOwnProperty.call(nextProps, "children") + ? nextProps.children + : undefined; + if (Object.prototype.hasOwnProperty.call(nextProps, "children")) { + delete nextProps.children; + } + const normalizedChildren = explicitChildren ?? propChildren; + if (type === __frameosFragment) { + return normalizedChildren ?? null; + } + if (normalizedChildren !== undefined) { + nextProps.children = normalizedChildren; + } + return { type, props: nextProps }; +}; +""" + + +@dataclass(frozen=True) +class Fixture: + name: str + source: str + app: dict + context: dict + xfail_native: str | None = None + + +FIXTURES = [ + Fixture( + name="typed_jsx_and_modern_es", + source=r''' + export function get(app: { config?: { nested?: { count?: number }, label?: string } }, context: { event: string }) { + class Counter { + value = 1_000 + increment = () => ++this.value + } + const metadata = { type: "image", as: "alias", satisfies: true } + const count = app.config?.nested?.count ?? new Counter().increment() + const label = (app.config?.label ?? "FrameOS") as string + const ok = /frame\s*os/i.test("Frame OS") + return <image width={count} label={`${label}:${context.event}`} metadata={metadata} ok={ok} /> + } + ''', + app={"config": {"label": "Native", "nested": {"count": 42}}}, + context={"event": "render"}, + ), + Fixture( + name="quickjs_native_es_passthrough", + source=r''' + export function get(app: { config?: { nested?: { count?: number } } }) { + class Counter { + static label = "counter" + #step = 1n + value = 1_000 + increment = () => { + this.value += Number(this.#step) + return this.value + } + } + const counter = new Counter() + let configured = app.config?.nested?.count ?? 0 + configured ||= counter.increment() + return Counter.label === "counter" ? configured : 0 + } + ''', + app={"config": {}}, + context={}, + ), + Fixture( + name="enum_runtime_values", + source=r''' + export enum Mode { + First, + Second = First + 2, + Label = "label", + } + export function get() { + return [Mode.First, Mode[0], Mode.Second, Mode.Label] + } + ''', + app={}, + context={}, + ), + Fixture( + name="complex_type_alias_and_predicate", + source=r''' + type Wrapped<T> = T extends string ? { [K in keyof T]: T[K] } : never + interface Input<T> extends Record<string, unknown> { + value?: T + } + function isString(value: unknown): value is string { + return typeof value === "string" + } + export function get(app: { config: Input<string> }) { + return isString(app.config.value) ? app.config.value : "missing" + } + ''', + app={"config": {"value": "ok"}}, + context={}, + ), + Fixture( + name="constructor_parameter_property", + source=r''' + class Box { + constructor(public value: string) {} + } + export function get() { + return new Box("ok").value + } + ''', + app={}, + context={}, + ), + Fixture( + name="function_overload_declarations", + source=r''' + function pick(value: string): string + function pick(value: number): number + function pick(value: string | number): string | number { + return value + } + export function get() { + return [pick("ok"), pick(3)] + } + ''', + app={}, + context={}, + ), + Fixture( + name="const_enum_runtime_values", + source=r''' + const enum Mode { + A, + B = A + 2, + } + export function get() { + return [Mode.A, Mode.B] + } + ''', + app={}, + context={}, + ), + Fixture( + name="type_only_export_elision", + source=r''' + type Foo = string + export type { Foo } + export function get() { + return "ok" + } + ''', + app={}, + context={}, + ), + Fixture( + name="declare_const_elision", + source=r''' + declare const injected: any + export function get() { + return typeof injected + } + ''', + app={}, + context={}, + ), + Fixture( + name="abstract_class_members", + source=r''' + abstract class Base { + abstract value(): string + concrete(): string { + return "ok" + } + } + class Child extends Base { + value(): string { + return "child" + } + } + export function get() { + return new Child().concrete() + } + ''', + app={}, + context={}, + ), +] + +TOKEN_FIXTURES = [ + pytest.param("5/3/1", id="division_sequence"), + pytest.param("5 + /3/", id="regex_after_operator"), + pytest.param("x<Hello>2", id="relational_not_jsx"), + pytest.param("x + < Hello / >", id="jsx_after_operator"), + pytest.param( + ''' + <div className="foo"> + Hello, world! + <span className={bar} /> + </div> + ''', + id="nested_jsx", + ), + pytest.param("`Hello, ${name} ${surname}`", id="template_expressions"), + pytest.param( + ''' + a = b + ++c + d++ + e = f++ + g = ++h + ''', + id="pre_post_increment", + ), + pytest.param( + ''' + import foo from "./foo.json" with {type: "json"}; + export {val} from './foo.js' with {type: "javascript"}; + ''', + id="import_attributes", + ), + pytest.param( + ''' + class { + #x = 3 + } + this.#x = 3 + delete this?.#x + if (#x in obj) { } + ''', + id="private_properties", + ), +] + +ANNOTATION_FIXTURES = [ + pytest.param( + ''' + function outer(a: number) { + const x: string = "x"; + var y = 1; + class Inner {} + } + ''', + id="declaration_roles_and_types", + ), + pytest.param( + ''' + import DefaultThing, { value as renamed, other } from "pkg"; + export { renamed as publicName }; + export const answer = 42; + ''', + id="import_export_roles", + ), + pytest.param( + ''' + const empty = <div />; + const one = <div>{child}</div>; + const many = <div><span />{child}</div>; + const keyed = <div {...props} key={1} />; + ''', + id="jsx_roles", + ), +] + + +def require_tool(name: str) -> str: + path = shutil.which(name) + if not path: + pytest.skip(f"{name} is required for native/Sucrase parity tests") + return path + + +@pytest.fixture(scope="session") +def native_cli(tmp_path_factory: pytest.TempPathFactory) -> Path: + require_tool("nim") + out = tmp_path_factory.mktemp("native-js-transpile") / "native_js_transpile" + proc = subprocess.run( + [ + "nim", + "c", + "--hints:off", + "--verbosity:0", + f"--nimcache:{out.parent / 'nimcache'}", + f"--out:{out}", + str(NATIVE_CLI), + ], + cwd=FRAMEOS_ROOT, + text=True, + capture_output=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + return out + + +def transform_native(native_cli: Path, source: str) -> str: + with tempfile.NamedTemporaryFile("w", suffix=".tsx", encoding="utf-8", delete=False) as tmp: + tmp.write(source) + tmp_path = tmp.name + try: + proc = subprocess.run( + [str(native_cli), "module", tmp_path], + cwd=FRAMEOS_ROOT, + text=True, + capture_output=True, + check=False, + ) + finally: + Path(tmp_path).unlink(missing_ok=True) + assert proc.returncode == 0, proc.stderr + proc.stdout + return proc.stdout + + +def tokenize_native(native_cli: Path, source: str) -> list[str]: + with tempfile.NamedTemporaryFile("w", suffix=".tsx", encoding="utf-8", delete=False) as tmp: + tmp.write(source) + tmp_path = tmp.name + try: + proc = subprocess.run( + [str(native_cli), "tokens", tmp_path], + cwd=FRAMEOS_ROOT, + text=True, + capture_output=True, + check=False, + ) + finally: + Path(tmp_path).unlink(missing_ok=True) + assert proc.returncode == 0, proc.stderr + proc.stdout + labels = [] + for line in proc.stdout.splitlines(): + if not line: + continue + match = re.match(r"^(.*?)\(\d+,\d+\)", line) + assert match, line + labels.append(match.group(1)) + return labels + + +def annotations_from_native(native_cli: Path, source: str) -> list[dict]: + with tempfile.NamedTemporaryFile("w", suffix=".tsx", encoding="utf-8", delete=False) as tmp: + tmp.write(source) + tmp_path = tmp.name + try: + proc = subprocess.run( + [str(native_cli), "parse", tmp_path], + cwd=FRAMEOS_ROOT, + text=True, + capture_output=True, + check=False, + ) + finally: + Path(tmp_path).unlink(missing_ok=True) + assert proc.returncode == 0, proc.stderr + proc.stdout + result = [] + for line in proc.stdout.splitlines(): + if not line: + continue + match = re.match(r"^(.*?)\((\d+),(\d+)\)(?:.*?\[(.*)\])?", line) + assert match, line + start = int(match.group(2)) + end = int(match.group(3)) + fields = {} + if match.group(4): + for field in match.group(4).split(","): + if "=" in field: + key, value = field.split("=", 1) + fields[key] = value + else: + fields[field] = True + result.append( + { + "label": match.group(1), + "text": source[start:end], + "type": bool(fields.get("type")), + "role": fields.get("role"), + "jsx": fields.get("jsx"), + } + ) + return result + + +def transform_sucrase(source: str) -> str: + require_tool("node") + script = r''' + import { transform } from "sucrase"; + const chunks = []; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) chunks.push(chunk); + const source = chunks.join(""); + const result = transform(source, { + transforms: ["typescript", "jsx", "imports"], + jsxRuntime: "classic", + jsxPragma: "__frameosJsx", + jsxFragmentPragma: "__frameosFragment", + production: true, + }); + process.stdout.write(result.code); + ''' + proc = subprocess.run( + ["node", "--input-type=module", "-e", script], + cwd=FRAME_FRONTEND_ROOT, + input=source, + text=True, + capture_output=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + return proc.stdout + + +def tokenize_sucrase(source: str) -> list[str]: + require_tool("node") + script = r''' + const {parse} = require("sucrase/dist/parser"); + const {formatTokenType} = require("sucrase/dist/parser/tokenizer/types"); + const chunks = []; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => { + const file = parse(chunks.join(""), true, true, false); + process.stdout.write(JSON.stringify(file.tokens.map((token) => formatTokenType(token.type)))); + }); + ''' + proc = subprocess.run( + ["node", "-e", script], + cwd=FRAME_FRONTEND_ROOT, + input=source, + text=True, + capture_output=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + return json.loads(proc.stdout) + + +def annotations_from_sucrase(source: str) -> list[dict]: + require_tool("node") + script = r''' + const {parse} = require("sucrase/dist/parser"); + const {formatTokenType} = require("sucrase/dist/parser/tokenizer/types"); + const {IdentifierRole, JSXRole} = require("sucrase/dist/parser/tokenizer"); + const chunks = []; + const lowerFirst = (value) => value ? value[0].toLowerCase() + value.slice(1) : null; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => { + const source = chunks.join(""); + const file = parse(source, true, true, false); + process.stdout.write(JSON.stringify(file.tokens.map((token) => ({ + label: formatTokenType(token.type), + text: source.slice(token.start, token.end), + type: Boolean(token.isType), + role: token.identifierRole == null ? null : lowerFirst(IdentifierRole[token.identifierRole]), + jsx: token.jsxRole == null ? null : lowerFirst(JSXRole[token.jsxRole]), + })))); + }); + ''' + proc = subprocess.run( + ["node", "-e", script], + cwd=FRAME_FRONTEND_ROOT, + input=source, + text=True, + capture_output=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + return json.loads(proc.stdout) + + +def interesting_annotations(tokens: list[dict]) -> list[dict]: + return [ + token + for token in tokens + if token["type"] or token["role"] is not None or token["jsx"] is not None + ] + + +def run_transformed(code: str, app: dict, context: dict): + require_tool("node") + runner = ( + JSX_PRELUDE + + "\n" + + "const exports = {};\n" + + code + + "\n" + + "const value = exports.get(" + + json.dumps(app) + + ", " + + json.dumps(context) + + ");\n" + + "process.stdout.write(JSON.stringify(value));\n" + ) + proc = subprocess.run( + ["node", "--input-type=module", "-e", runner], + cwd=FRAME_FRONTEND_ROOT, + text=True, + capture_output=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + proc.stdout + "\nCode:\n" + code + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("fixture", FIXTURES, ids=lambda fixture: fixture.name) +def test_native_transpiler_matches_sucrase_runtime(native_cli: Path, fixture: Fixture): + sucrase_code = transform_sucrase(fixture.source) + sucrase_output = run_transformed(sucrase_code, fixture.app, fixture.context) + + try: + native_code = transform_native(native_cli, fixture.source) + native_output = run_transformed(native_code, fixture.app, fixture.context) + except AssertionError as error: + if fixture.xfail_native: + pytest.xfail(fixture.xfail_native + f": {error}") + raise + + if fixture.xfail_native and native_output == sucrase_output: + pytest.fail(f"Fixture marked xfail now matches Sucrase: {fixture.xfail_native}") + if fixture.xfail_native: + pytest.xfail(fixture.xfail_native) + assert native_output == sucrase_output + + +@pytest.mark.parametrize("source", TOKEN_FIXTURES) +def test_native_tokenizer_matches_sucrase_tokens(native_cli: Path, source: str): + assert tokenize_native(native_cli, source) == tokenize_sucrase(source) + + +@pytest.mark.parametrize("source", ANNOTATION_FIXTURES) +def test_native_parser_annotations_match_sucrase_subset(native_cli: Path, source: str): + assert interesting_annotations(annotations_from_native(native_cli, source)) == interesting_annotations( + annotations_from_sucrase(source) + ) diff --git a/frontend/src/scenes/frame/frameDeployUtils.ts b/frontend/src/scenes/frame/frameDeployUtils.ts index d0bb71828..671b35dde 100644 --- a/frontend/src/scenes/frame/frameDeployUtils.ts +++ b/frontend/src/scenes/frame/frameDeployUtils.ts @@ -1,5 +1,10 @@ import type { FrameType } from '../../types' import versions from '../../../../versions.json' +import { + type FrameCompilationMode, + normalizeFrameCompilationMode, + normalizeFrameCrossCompilation, +} from '../../utils/frameBuildOptions' export interface ChangeDetail { label: string @@ -27,8 +32,8 @@ export interface FullDeployPlanResponse { low_memory: boolean drivers: string[] binary: { - requested_compilation_mode?: 'static' | 'shared' | 'shared-scenes' | 'precompiled' - compilation_mode?: 'static' | 'shared' | 'shared-scenes' | 'precompiled' + requested_compilation_mode?: FrameCompilationMode + compilation_mode?: FrameCompilationMode will_attempt_cross_compile?: boolean will_attempt_precompiled?: boolean cross_compile_supported?: boolean @@ -166,22 +171,12 @@ function pluralize(count: number, singular: string): string { return `${count} ${singular}${count === 1 ? '' : 's'}` } -function normalizeCompilationMode(value: unknown): 'static' | 'shared' | 'shared-scenes' | 'precompiled' { - return value === 'static' || value === 'shared' || value === 'shared-scenes' || value === 'precompiled' - ? value - : 'precompiled' -} - -function frameCompilationMode(frame?: Partial<FrameType> | null): 'static' | 'shared' | 'shared-scenes' | 'precompiled' { - return normalizeCompilationMode( +function frameCompilationMode(frame?: Partial<FrameType> | null): FrameCompilationMode { + return normalizeFrameCompilationMode( frame?.mode === 'buildroot' ? frame?.buildroot?.compilationMode : frame?.rpios?.compilationMode ) } -function normalizeCrossCompilation(value: unknown): 'auto' | 'always' | 'never' { - return value === 'always' || value === 'never' ? value : 'auto' -} - function frameCompiledSceneCount(frame?: Partial<FrameType> | null): number { return (frame?.scenes ?? []).filter((scene) => (scene.settings?.execution ?? 'compiled') !== 'interpreted').length } @@ -258,7 +253,7 @@ function canUsePrecompiledFrameos(frame?: Partial<FrameType> | null, plan?: Depl } const compilationMode = frameCompilationMode(frame) - const crossCompilation = normalizeCrossCompilation(frame?.rpios?.crossCompilation) + const crossCompilation = normalizeFrameCrossCompilation(frame?.rpios?.crossCompilation) return compilationMode === 'precompiled' && crossCompilation !== 'always' && !precompiledSkipReason(frame) } @@ -267,8 +262,8 @@ function inferBuildStrategy(frame?: Partial<FrameType> | null): string { return 'Build the configured Buildroot target' } - const compilationMode = normalizeCompilationMode(frame?.rpios?.compilationMode) - const crossCompilation = normalizeCrossCompilation(frame?.rpios?.crossCompilation) + const compilationMode = normalizeFrameCompilationMode(frame?.rpios?.compilationMode) + const crossCompilation = normalizeFrameCrossCompilation(frame?.rpios?.crossCompilation) const skipReason = precompiledSkipReason(frame) const crossCompileText = crossCompilation === 'never' @@ -295,7 +290,7 @@ function inferBuildStrategy(frame?: Partial<FrameType> | null): string { function inferCompilationSummary(frame?: Partial<FrameType> | null): string { const compilationMode = frameCompilationMode(frame) - const crossCompilation = normalizeCrossCompilation(frame?.rpios?.crossCompilation) + const crossCompilation = normalizeFrameCrossCompilation(frame?.rpios?.crossCompilation) if (compilationMode === 'shared') { return 'Shared libraries deployed next to the FrameOS binary' } @@ -363,6 +358,28 @@ export function normalizeFrameosVersion(version: unknown): string | null { return typeof version === 'string' && version.trim() ? version.split('+')[0] : null } +function parseFrameosVersion(version: string | null): [number, number, number] | null { + const match = version?.match(/^(\d+)\.(\d+)\.(\d+)$/) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null +} + +export function isFrameosVersionBefore(version: string | null, minimumVersion: string): boolean { + const current = parseFrameosVersion(normalizeFrameosVersion(version)) + const minimum = parseFrameosVersion(normalizeFrameosVersion(minimumVersion)) + + if (!current || !minimum) { + return false + } + + for (let index = 0; index < current.length; index++) { + if (current[index] !== minimum[index]) { + return current[index] < minimum[index] + } + } + + return false +} + export function deployedFrameosVersion(deploy?: Partial<FrameType> | Record<string, unknown> | null): string | null { return normalizeFrameosVersion((deploy as Record<string, unknown> | null | undefined)?.frameos_version) } @@ -406,6 +423,19 @@ export function buildFullDeployPlanSummary( const packagesToInstall = fullPlan.packages.filter((pkg) => pkg.needs_install).map((pkg) => pkg.name) const previousVersion = previousFrameosVersion(plan) + const buildStrategyItem: SummaryItem = { + label: 'Build strategy', + value: fullPlan.binary.will_attempt_precompiled + ? 'Download and install the precompiled FrameOS release' + : fullPlan.binary.will_attempt_cross_compile + ? fullPlan.binary.build_host_configured + ? 'Cross-compile on the configured build host' + : 'Cross-compile locally on this server' + : fullPlan.binary.cross_compile_supported + ? 'Build on device because cross-compilation is disabled' + : 'Build on device because cross-compilation is unavailable for this target', + } + let compilationItem: SummaryItem | null = null const items: SummaryItem[] = [ { label: 'FrameOS version', @@ -419,18 +449,6 @@ export function buildFullDeployPlanSummary( label: 'Target', value: `${fullPlan.target.distro} ${fullPlan.target.version} · ${fullPlan.target.arch} · ${fullPlan.target.total_memory_mb} MiB`, }, - { - label: 'Build strategy', - value: fullPlan.binary.will_attempt_precompiled - ? 'Download and install the precompiled FrameOS release' - : fullPlan.binary.will_attempt_cross_compile - ? fullPlan.binary.build_host_configured - ? 'Cross-compile on the configured build host' - : 'Cross-compile locally on this server' - : fullPlan.binary.cross_compile_supported - ? 'Build on device because cross-compilation is disabled' - : 'Build on device because cross-compilation is unavailable for this target', - }, ] if (fullPlan.drivers.length > 0) { @@ -438,26 +456,29 @@ export function buildFullDeployPlanSummary( } const requestedCompilationMode = fullPlan.binary.requested_compilation_mode ?? fullPlan.binary.compilation_mode if (fullPlan.binary.compilation_mode === 'shared' && requestedCompilationMode !== 'precompiled') { - items.push({ label: 'Compilation', value: 'Shared libraries deployed next to the FrameOS binary' }) + compilationItem = { label: 'Compilation', value: 'Shared libraries deployed next to the FrameOS binary' } } if (fullPlan.binary.compilation_mode === 'shared-scenes' && requestedCompilationMode === 'precompiled') { - items.push({ label: 'Compilation', value: 'Compiled scenes bundled into scenes.so next to the FrameOS binary' }) + compilationItem = { + label: 'Compilation', + value: 'Compiled scenes bundled into scenes.so next to the FrameOS binary', + } } if (requestedCompilationMode === 'precompiled') { const fallbackMode = fullPlan.binary.compilation_mode === 'static' ? 'Single executable' : fullPlan.binary.compilation_mode === 'shared-scenes' - ? 'Bundled scenes library' - : 'Shared libraries' - items.push({ + ? 'Bundled scenes library' + : 'Shared libraries' + compilationItem = { label: 'Compilation', value: fullPlan.binary.will_attempt_precompiled ? 'Precompiled FrameOS binary and shared driver libraries' : `${fallbackMode}; precompiled release skipped${ fullPlan.binary.precompiled_skip_reason ? ` (${fullPlan.binary.precompiled_skip_reason})` : '' }`, - }) + } } if (packagesToInstall.length > 0) { items.push({ label: 'Packages to install', value: stringifyList(packagesToInstall) }) @@ -519,6 +540,11 @@ export function buildFullDeployPlanSummary( items.push({ label: 'After deploy', value: 'Device reboot required' }) } + items.push(buildStrategyItem) + if (compilationItem) { + items.push(compilationItem) + } + return items } @@ -536,16 +562,11 @@ export function buildInferredFullDeployPlanSummary( : `${previousVersion ?? 'Not deployed'} -> ${CURRENT_FRAMEOS_VERSION}`, }, ...(frame?.device ? [{ label: 'Device', value: String(frame.device) }] : []), - { - label: 'Build strategy', - value: inferBuildStrategy(frame), - }, ] const drivers = inferFrameDriverNames(frame) if (drivers.length > 0) { items.push({ label: 'Drivers', value: stringifyList(drivers) }) } - items.push({ label: 'Compilation', value: inferCompilationSummary(frame) }) const rebootSchedule = rebootScheduleSummary(frame?.reboot) if (rebootSchedule) { items.push({ label: 'Reboot schedule', value: `Automatic ${rebootSchedule}` }) @@ -554,6 +575,8 @@ export function buildInferredFullDeployPlanSummary( if (mounts) { items.push({ label: 'Mountpoints', value: mounts }) } + items.push({ label: 'Build strategy', value: inferBuildStrategy(frame) }) + items.push({ label: 'Compilation', value: inferCompilationSummary(frame) }) return items } diff --git a/frontend/src/scenes/frame/frameLogic.ts b/frontend/src/scenes/frame/frameLogic.ts index 5c90c56d9..de27a02be 100644 --- a/frontend/src/scenes/frame/frameLogic.ts +++ b/frontend/src/scenes/frame/frameLogic.ts @@ -37,9 +37,11 @@ import { buildInferredFullDeployPlanSummary, deployedFrameosVersion, deployPlanPreviousFrameosVersion, + isFrameosVersionBefore, } from './frameDeployUtils' import { getDeployPlanErrorMessage } from './frameDeployErrors' import { urls } from '../../urls' +import { normalizeFrameCompilationMode, normalizeFrameCrossCompilation } from '../../utils/frameBuildOptions' export type { ChangeDetail, DeployPlanResponse, DeployRecommendation, SummaryItem } from './frameDeployUtils' @@ -113,6 +115,19 @@ const FRAME_KEYS: (keyof FrameType)[] = [ 'rpios', ] +// When adding a runtime-consumed field to FRAME_KEYS, add its introduced version here. +// During active development, use the next patch after versions.json's frameos base version +// (for example, 2026.6.9 while versions.json says 2026.6.8). +const FRAME_KEY_INTRODUCED_FRAMEOS_VERSION: Partial<Record<keyof FrameType, string>> = { + mountpoints: '2026.6.0', + error_behavior: '2026.6.1', + buildroot: '2026.6.2', + image_engine: '2026.6.3', + max_http_response_bytes: '2026.6.4', + rpios: '2026.6.7', + timezone_updater: '2026.6.7', +} + const FRAME_KEYS_REQUIRE_RECOMPILE_RPIOS: (keyof FrameType)[] = ['device', 'scenes', 'reboot', 'rpios'] const FRAME_KEYS_REQUIRE_RECOMPILE_BUILDROOT: (keyof FrameType)[] = [ 'device', @@ -309,6 +324,11 @@ function getRecompileFields(mode: FrameType['mode']): (keyof FrameType)[] { return mode === 'buildroot' ? FRAME_KEYS_REQUIRE_RECOMPILE_BUILDROOT : FRAME_KEYS_REQUIRE_RECOMPILE_RPIOS } +function frameKeyRequiresVersionUpgrade(key: keyof FrameType, previousFrameosVersion: string | null): boolean { + const introducedVersion = FRAME_KEY_INTRODUCED_FRAMEOS_VERSION[key] + return introducedVersion ? isFrameosVersionBefore(previousFrameosVersion, introducedVersion) : false +} + function frameSubmitKeys(frame: Partial<FrameType>): (keyof FrameType)[] { return FRAME_KEYS } @@ -385,20 +405,21 @@ function computeChangeDetails( ): ChangeDetail[] { const recompileFields = new Set(getRecompileFields(mode).filter((key) => key !== 'scenes')) const details: ChangeDetail[] = [] + const previousFrameosVersion = includeFrameosVersion ? deployedFrameosVersion(previous) : null for (const key of FRAME_KEYS.filter((k) => k !== 'scenes')) { if (!frameKeyEqual(key, previous?.[key], next?.[key])) { details.push({ label: keyLabel(key), - requiresFullDeploy: recompileFields.has(key), + requiresFullDeploy: + recompileFields.has(key) || + (includeFrameosVersion && frameKeyRequiresVersionUpgrade(key, previousFrameosVersion)), }) } } const sceneDetails = sceneChangeDetails(next?.scenes ?? [], previous?.scenes ?? []) - const previousFrameosVersion = deployedFrameosVersion(previous) - if (includeFrameosVersion && (!previousFrameosVersion || previousFrameosVersion !== CURRENT_FRAMEOS_VERSION)) { details.push({ label: `FrameOS upgrade ${previousFrameosVersion ?? ''} -> ${CURRENT_FRAMEOS_VERSION}`, @@ -474,24 +495,14 @@ function sortDeployChangeDetails(changes: ChangeDetail[]): ChangeDetail[] { .map(({ change }) => change) } -function normalizeRpiosCompilationMode(value: unknown): 'static' | 'shared' | 'shared-scenes' | 'precompiled' { - return value === 'static' || value === 'shared' || value === 'shared-scenes' || value === 'precompiled' - ? value - : 'precompiled' -} - -function normalizeRpiosCrossCompilation(value: unknown): 'auto' | 'always' | 'never' { - return value === 'always' || value === 'never' ? value : 'auto' -} - function normalizeRpiosForComparison(value: unknown): Record<string, unknown> { const source = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {} const { platform: _platform, compilationMode, crossCompilation, ...rest } = source return { ...rest, - compilationMode: normalizeRpiosCompilationMode(compilationMode), - crossCompilation: normalizeRpiosCrossCompilation(crossCompilation), + compilationMode: normalizeFrameCompilationMode(compilationMode), + crossCompilation: normalizeFrameCrossCompilation(crossCompilation), } } @@ -518,11 +529,19 @@ function normalizeMountpointsForComparison(value: unknown): Record<string, any> } } +function normalizeTimezoneForComparison(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + function normalizeFrameKeyValueForComparison(key: keyof FrameType, value: unknown): unknown { if (key === 'image_engine') { return value ?? '' } + if (key === 'timezone') { + return normalizeTimezoneForComparison(value) + } + if (key === 'rpios') { return normalizeRpiosForComparison(value) } @@ -1306,7 +1325,8 @@ export const frameLogic = kea<frameLogicType>([ }, showDeployPlanModal: () => { const isBuildroot = (values.frameForm?.mode || values.frame?.mode || 'rpios') === 'buildroot' - const buildrootFirstInstall = isBuildroot && !values.frame?.last_successful_deploy && !values.frame?.last_successful_deploy_at + const buildrootFirstInstall = + isBuildroot && !values.frame?.last_successful_deploy && !values.frame?.last_successful_deploy_at if (buildrootFirstInstall) { return } @@ -1498,7 +1518,10 @@ export const frameLogic = kea<frameLogicType>([ })), subscriptions(({ actions, values }) => ({ frame: (frame?: FrameType, oldFrame?: FrameType) => { - const frameFormMatchesPrevious = equal(oldFrame, values.frameForm) + const previousMode = values.frameForm?.mode || oldFrame?.mode || 'rpios' + const frameFormMatchesPrevious = oldFrame + ? computeChangeDetails(oldFrame, values.frameForm, previousMode, false).length === 0 + : false if (frame && (!oldFrame || frameFormMatchesPrevious)) { actions.resetFrameForm(sanitizeFrame(frame) as FrameType) } diff --git a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx index 4c4ac57a4..e0ae7b529 100644 --- a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx +++ b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx @@ -14,6 +14,7 @@ import { frameLogic, normalizeFrameErrorBehavior, } from '../../frameLogic' +import { frameCompilationModeOptions, frameCrossCompilationOptions } from '../../../../utils/frameBuildOptions' import { downloadJson } from '../../../../utils/downloadJson' import { Field } from '../../../../components/Field' import { devices, spectraPalettes, withCustomPalette, buildrootPlatforms, modes } from '../../../../devices' @@ -207,7 +208,9 @@ export function FrameSettings({ const selectedTimezone = frameForm.timezone ?? frame.timezone ?? '' const timezoneUpdater = frameForm.timezone_updater ?? {} const timezoneUpdateHourValue = - typeof timezoneUpdater.hour === 'number' && Number.isInteger(timezoneUpdater.hour) ? String(timezoneUpdater.hour) : '' + typeof timezoneUpdater.hour === 'number' && Number.isInteger(timezoneUpdater.hour) + ? String(timezoneUpdater.hour) + : '' const timezoneUpdateUrlValue = timezoneUpdater.url ?? '' const setTimezoneUpdaterValue = (patch: Partial<NonNullable<FrameType['timezone_updater']>>) => { const next = { @@ -627,19 +630,7 @@ export function FrameSettings({ </div> } > - <Select - name="buildroot.compilationMode" - options={[ - { value: '', label: 'Default (Precompiled)' }, - { value: 'precompiled', label: 'Use precompiled binaries if possible' }, - { value: 'static', label: 'Build as a single executable' }, - { value: 'shared', label: 'Scenes and drivers as shared libraries' }, - { - value: 'shared-scenes', - label: 'Scenes bundled in one shared library (scenes.so)', - }, - ]} - /> + <Select name="buildroot.compilationMode" options={frameCompilationModeOptions} /> </Field> </Group> ) : null} @@ -699,14 +690,7 @@ export function FrameSettings({ </div> } > - <Select - name="rpios.crossCompilation" - options={[ - { value: 'auto', label: 'Auto (try to cross-compile, fallback if needed)' }, - { value: 'always', label: 'Always cross-compile (fail if unavailable)' }, - { value: 'never', label: 'Never cross-compile (build on device)' }, - ]} - /> + <Select name="rpios.crossCompilation" options={frameCrossCompilationOptions} /> </Field> <Field name="compilationMode" @@ -725,19 +709,7 @@ export function FrameSettings({ </div> } > - <Select - name="rpios.compilationMode" - options={[ - { value: '', label: 'Default (Precompiled)' }, - { value: 'precompiled', label: 'Use precompiled binaries if possible' }, - { value: 'static', label: 'Build as a single executable' }, - { value: 'shared', label: 'Scenes and drivers as shared libraries' }, - { - value: 'shared-scenes', - label: 'Scenes bundled in one shared library (scenes.so)', - }, - ]} - /> + <Select name="rpios.compilationMode" options={frameCompilationModeOptions} /> </Field> </Group> ) : null} diff --git a/frontend/src/scenes/workspace/FrameDashboardSurface.tsx b/frontend/src/scenes/workspace/FrameDashboardSurface.tsx index 78eaa0b3e..3426bdf0d 100644 --- a/frontend/src/scenes/workspace/FrameDashboardSurface.tsx +++ b/frontend/src/scenes/workspace/FrameDashboardSurface.tsx @@ -289,32 +289,30 @@ function FrameDashboardHeader({ frame, archived }: { frame: FrameType; archived? <div className="group flex min-w-[14rem] flex-1 items-center gap-3"> <FrameChangeStatusIcon frameId={frame.id} variant="dashboard" /> <div className="min-w-0"> - <A - href={urls.frame(frame.id, 'overview')} - className="rounded-xl transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400" - > - <div className="flex min-w-0 items-center gap-2"> + <div className="flex min-w-0 items-center gap-2"> + <A + href={urls.frame(frame.id, 'overview')} + className="min-w-0 rounded-xl transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400" + > <h2 data-workspace-frame-title={frame.id} className="frameos-strong truncate text-2xl font-bold tracking-normal text-slate-950" > {frame.name || frameHost(frame)} </h2> - <FrameMetricAlertIndicator frame={frame} className="h-5 w-5" /> - {archived ? ( - <span className="rounded-full bg-slate-200 px-2 py-0.5 text-xs font-semibold text-slate-500"> - Archived - </span> - ) : null} - {connected ? ( - <FrameConnectionDot - title={healthy ? 'Frame is healthy and agent connected' : 'FrameOS agent connected'} - /> - ) : healthy ? ( - <span title="Frame is healthy" className="h-2.5 w-2.5 rounded-full bg-emerald-400" /> - ) : null} - </div> - </A> + </A> + <FrameMetricAlertIndicator frame={frame} className="h-5 w-5" /> + {archived ? ( + <span className="rounded-full bg-slate-200 px-2 py-0.5 text-xs font-semibold text-slate-500"> + Archived + </span> + ) : null} + {connected ? ( + <FrameConnectionDot title={healthy ? 'Frame is healthy and agent connected' : 'FrameOS agent connected'} /> + ) : healthy ? ( + <span title="Frame is healthy" className="h-2.5 w-2.5 rounded-full bg-emerald-400" /> + ) : null} + </div> <FrameDashboardStatusLine frame={frame} /> </div> </div> diff --git a/frontend/src/scenes/workspace/FrameDeployPlanDrawer.tsx b/frontend/src/scenes/workspace/FrameDeployPlanDrawer.tsx index 4da8bd0e0..9639b8d74 100644 --- a/frontend/src/scenes/workspace/FrameDeployPlanDrawer.tsx +++ b/frontend/src/scenes/workspace/FrameDeployPlanDrawer.tsx @@ -32,6 +32,11 @@ import { type DeployRecommendation, type SummaryItem, } from '../frame/frameLogic' +import { + frameCompilationModeOptions, + frameCrossCompilationOptions, + normalizeFrameCrossCompilation, +} from '../../utils/frameBuildOptions' import { logsLogic } from '../frame/panels/Logs/logsLogic' import { settingsLogic } from '../settings/settingsLogic' import { frameBootstrapLogic } from './frameBootstrapLogic' @@ -270,6 +275,82 @@ function SummaryRows({ items }: { items: SummaryItem[] }): JSX.Element | null { ) } +function DeployBuildOptionsSection({ + frame, + frameForm, +}: { + frame: FrameType + frameForm: Partial<FrameType> +}): JSX.Element { + const { setFrameFormValues, touchFrameFormField } = useActions(frameLogic({ frameId: frame.id })) + const mode = frameForm.mode ?? frame.mode ?? 'rpios' + const isBuildroot = mode === 'buildroot' + const rpios = { + ...(frame.rpios ?? {}), + ...(frameForm.rpios ?? {}), + } + const buildroot = { + ...(frame.buildroot ?? {}), + ...(frameForm.buildroot ?? {}), + } + const crossCompilation = normalizeFrameCrossCompilation(rpios.crossCompilation) + const compilationMode = String((isBuildroot ? buildroot.compilationMode : rpios.compilationMode) ?? '') + const selectClassName = + 'frameos-form-control h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-400/30' + + const updateRpios = (field: keyof NonNullable<FrameType['rpios']>, value: string): void => { + setFrameFormValues({ rpios: { ...rpios, [field]: value } }) + touchFrameFormField(`rpios.${field}`) + } + + const updateBuildroot = (field: keyof NonNullable<FrameType['buildroot']>, value: string): void => { + setFrameFormValues({ buildroot: { ...buildroot, [field]: value } }) + touchFrameFormField(`buildroot.${field}`) + } + + return ( + <section className="space-y-2"> + <DrawerHeading action={<FrameSettingsLink frameId={frame.id} />}>FrameOS compilation</DrawerHeading> + <label className="block space-y-1"> + <select + className={selectClassName} + value={compilationMode} + onChange={(event) => + isBuildroot + ? updateBuildroot('compilationMode', event.target.value) + : updateRpios('compilationMode', event.target.value) + } + > + {frameCompilationModeOptions.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </label> + <label className="block space-y-1"> + <span className="frame-tool-heading text-sm font-semibold">Build strategy</span> + <select + className={selectClassName} + value={isBuildroot ? 'buildroot' : crossCompilation} + disabled={isBuildroot} + onChange={(event) => updateRpios('crossCompilation', event.target.value)} + > + {isBuildroot ? ( + <option value="buildroot">Build the configured Buildroot target</option> + ) : ( + frameCrossCompilationOptions.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + )) + )} + </select> + </label> + </section> + ) +} + function ChangeRows({ changes }: { changes: ChangeDetail[] }): JSX.Element | null { if (changes.length === 0) { return null @@ -783,9 +864,9 @@ function ScriptInstallSection({ frame, onBack }: { frame: FrameType; onBack: () </DrawerHeading> <div className="frame-tool-card space-y-4 rounded-[22px] p-4"> <div className="frame-tool-muted text-sm leading-5"> - Run this command on the device as a user with sudo access. It installs FrameOS, starts the remote management agent, and connects - back to this backend. The installer supports most major Debian and Ubuntu releases, including Raspberry Pi OS - releases based on Debian. + Run this command on the device as a user with sudo access. It installs FrameOS, starts the remote management + agent, and connects back to this backend. The installer supports most major Debian and Ubuntu releases, + including Raspberry Pi OS releases based on Debian. </div> {loading ? ( <div className="flex items-center gap-2 text-sm font-semibold text-[color:var(--tool-strong)]"> @@ -875,6 +956,9 @@ export function FrameDeployPlanDrawer({ frame }: { frame: FrameType }): JSX.Elem closeFrameChangeDrawer() } const showMainDeployView = (): void => setDeployDrawerView('main') + const deploySummaryWithoutBuildOptions = fullDeployPlanSummary.filter( + (item) => item.label !== 'Build strategy' && item.label !== 'Compilation' + ) const saveSdCardSettingsAndDownload = async (): Promise<void> => { const response = await apiFetch(`/api/frames/${frame.id}`, { @@ -1001,11 +1085,12 @@ export function FrameDeployPlanDrawer({ frame }: { frame: FrameType }): JSX.Elem </div> </section> ) : null} - {fullDeployPlanSummary.length > 0 ? ( + {deploySummaryWithoutBuildOptions.length > 0 ? ( <section> - <SummaryRows items={fullDeployPlanSummary} /> + <SummaryRows items={deploySummaryWithoutBuildOptions} /> </section> ) : null} + <DeployBuildOptionsSection frame={frame} frameForm={frameForm} /> </div> )} </> diff --git a/frontend/src/scenes/workspace/FrameMetricAlertIndicator.tsx b/frontend/src/scenes/workspace/FrameMetricAlertIndicator.tsx index 310e94a75..4b5f2e3f5 100644 --- a/frontend/src/scenes/workspace/FrameMetricAlertIndicator.tsx +++ b/frontend/src/scenes/workspace/FrameMetricAlertIndicator.tsx @@ -3,15 +3,20 @@ import clsx from 'clsx' import { ExclamationTriangleIcon } from '@heroicons/react/24/solid' import type { FrameType } from '../../types' +import type { FrameMetricAlert } from '../../utils/frameMetricAlerts' import { getFrameMetricAlerts, frameMetricAlertTitle } from '../../utils/frameMetricAlerts' +import { urls } from '../../urls' import { frameMetricsPreviewLogic } from './frameMetricsPreviewLogic' +import { A } from 'kea-router' export function FrameMetricAlertIndicator({ frame, className, + containerClassName, }: { frame: FrameType className?: string + containerClassName?: string }): JSX.Element | null { const { sortedRecentMetrics } = useValues(frameMetricsPreviewLogic({ frameId: frame.id })) const alerts = getFrameMetricAlerts(frame, sortedRecentMetrics) @@ -20,12 +25,42 @@ export function FrameMetricAlertIndicator({ return null } + const title = frameMetricAlertTitle(alerts) + + return ( + <A + href={urls.frame(frame.id, 'metrics')} + className={clsx( + 'group/metric-alert relative inline-flex shrink-0 align-middle focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400', + containerClassName + )} + aria-label={`${title}. Open metrics.`} + > + <ExclamationTriangleIcon + role="img" + aria-label={title} + className={clsx('h-4 w-4 shrink-0 text-amber-400 drop-shadow-sm', className)} + /> + <FrameMetricAlertPopup alerts={alerts} /> + </A> + ) +} + +function FrameMetricAlertPopup({ alerts }: { alerts: FrameMetricAlert[] }): JSX.Element { return ( - <ExclamationTriangleIcon - role="img" - aria-label={frameMetricAlertTitle(alerts)} - title={frameMetricAlertTitle(alerts)} - className={clsx('h-4 w-4 shrink-0 text-amber-400 drop-shadow-sm', className)} - /> + <span className="frameos-tooltip-panel pointer-events-none invisible absolute left-1/2 top-full z-50 mt-2 w-64 -translate-x-1/2 rounded-md p-3 text-left text-xs opacity-0 transition group-hover/metric-alert:visible group-hover/metric-alert:opacity-100 group-focus-visible/metric-alert:visible group-focus-visible/metric-alert:opacity-100"> + <span className="mb-2 flex items-center gap-2 text-sm font-semibold text-amber-600"> + <ExclamationTriangleIcon className="h-4 w-4 shrink-0" /> + <span>Metrics issues</span> + </span> + <span className="flex flex-col gap-1.5"> + {alerts.map((alert) => ( + <span key={alert.key} className="flex items-start gap-2"> + <span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" /> + <span>{alert.label}</span> + </span> + ))} + </span> + </span> ) } diff --git a/frontend/src/scenes/workspace/FrameWorkspace.tsx b/frontend/src/scenes/workspace/FrameWorkspace.tsx index 09f362c67..2b860df83 100644 --- a/frontend/src/scenes/workspace/FrameWorkspace.tsx +++ b/frontend/src/scenes/workspace/FrameWorkspace.tsx @@ -370,7 +370,7 @@ function FrameSelector({ </select> <FrameMetricAlertIndicator frame={frame} - className="pointer-events-none absolute right-7 top-1/2 -translate-y-1/2" + containerClassName="absolute right-7 top-1/2 -translate-y-1/2" /> </div> <FrameActionsMenu diff --git a/frontend/src/scenes/workspace/FramesHome.tsx b/frontend/src/scenes/workspace/FramesHome.tsx index 0287e2c4f..5377c95c1 100644 --- a/frontend/src/scenes/workspace/FramesHome.tsx +++ b/frontend/src/scenes/workspace/FramesHome.tsx @@ -253,22 +253,22 @@ function FrameTreeRow({ )} > <FrameChangeStatusIcon frameId={frame.id} /> - <button - type="button" - title={`Scroll to ${frameName}. Double-click to open overview.`} - onClick={(event: MouseEvent<HTMLButtonElement>) => onSelect(event, frame.id)} - onDoubleClick={(event: MouseEvent<HTMLButtonElement>) => onOpen(event, frame.id)} - className="flex min-w-0 flex-1 items-center gap-3 rounded-lg text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400" - > - <span className="min-w-0 flex-1"> + <div className="flex min-w-0 flex-1 items-center gap-3"> + <button + type="button" + title={`Scroll to ${frameName}. Double-click to open overview.`} + onClick={(event: MouseEvent<HTMLButtonElement>) => onSelect(event, frame.id)} + onDoubleClick={(event: MouseEvent<HTMLButtonElement>) => onOpen(event, frame.id)} + className="min-w-0 flex-1 rounded-lg text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400" + > <span className="flex min-w-0 items-center gap-1.5"> <span className={clsx('block min-w-0 truncate', !archived && 'text-base font-medium')}>{frameName}</span> - <FrameMetricAlertIndicator frame={frame} /> </span> <span className="block truncate text-xs text-slate-400">{sidebarFrameActivityDescription(frame)}</span> - </span> + </button> + <FrameMetricAlertIndicator frame={frame} /> <SidebarStatusDots frame={frame} inactive={inactive} /> - </button> + </div> </div> ) } diff --git a/frontend/src/scenes/workspace/SceneWorkspace.tsx b/frontend/src/scenes/workspace/SceneWorkspace.tsx index 325d5902f..5a56bdbc0 100644 --- a/frontend/src/scenes/workspace/SceneWorkspace.tsx +++ b/frontend/src/scenes/workspace/SceneWorkspace.tsx @@ -173,7 +173,7 @@ function SceneSelector({ </select> <FrameMetricAlertIndicator frame={frame} - className="pointer-events-none absolute right-7 top-1/2 -translate-y-1/2" + containerClassName="absolute right-7 top-1/2 -translate-y-1/2" /> </div> <FrameActionsMenu diff --git a/frontend/src/scenes/workspace/workspaceLogic.tsx b/frontend/src/scenes/workspace/workspaceLogic.tsx index aacfb8963..34656ae28 100644 --- a/frontend/src/scenes/workspace/workspaceLogic.tsx +++ b/frontend/src/scenes/workspace/workspaceLogic.tsx @@ -1194,6 +1194,7 @@ export const workspaceLogic = kea<workspaceLogicType>([ applyWorkspaceScrollGuard(values.secondarySidebarOpen) }, openSceneControl: ({ frameId, sceneId }) => { + frameLogic({ frameId }).actions.hideDeployPlanModal() newFrameForm.actions.hideForm() if (isMobileWorkspaceViewport()) { actions.closeSecondarySidebar() @@ -1201,7 +1202,8 @@ export const workspaceLogic = kea<workspaceLogicType>([ preserveFramesScroll() ensureSceneTileVisibleAfterLayoutChange(frameId, sceneId, cache) }, - openLiveSceneControl: () => { + openLiveSceneControl: ({ frameId }) => { + frameLogic({ frameId }).actions.hideDeployPlanModal() newFrameForm.actions.hideForm() if (isMobileWorkspaceViewport()) { actions.closeSecondarySidebar() diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx index e21800d89..ba6770d7e 100644 --- a/frontend/src/types.tsx +++ b/frontend/src/types.tsx @@ -1,4 +1,5 @@ import { Edge, Node } from 'reactflow' +import type { FrameCompilationModeOptionValue, FrameCrossCompilationOptionValue } from './utils/frameBuildOptions' export type FrameErrorBehaviorMode = 'safe_mode' | 'show_error_retry' | 'silent_retry' @@ -721,7 +722,7 @@ export interface Palette { export interface FrameBuildrootConfig { platform?: string - compilationMode?: '' | 'static' | 'shared' | 'shared-scenes' | 'precompiled' + compilationMode?: FrameCompilationModeOptionValue sdImage?: { status?: 'idle' | 'queued' | 'building' | 'ready' | 'error' | 'missing' | 'stale' buildId?: string @@ -749,6 +750,6 @@ export interface FrameBuildrootConfig { export interface FrameRpiOSConfig { platform?: string - crossCompilation?: '' | 'auto' | 'always' | 'never' - compilationMode?: '' | 'static' | 'shared' | 'shared-scenes' | 'precompiled' + crossCompilation?: FrameCrossCompilationOptionValue + compilationMode?: FrameCompilationModeOptionValue } diff --git a/frontend/src/utils/frameBuildOptions.ts b/frontend/src/utils/frameBuildOptions.ts new file mode 100644 index 000000000..4a265a3da --- /dev/null +++ b/frontend/src/utils/frameBuildOptions.ts @@ -0,0 +1,33 @@ +export type FrameCompilationMode = 'static' | 'shared' | 'shared-scenes' | 'precompiled' +export type FrameCompilationModeOptionValue = '' | FrameCompilationMode +export type FrameCrossCompilation = 'auto' | 'always' | 'never' +export type FrameCrossCompilationOptionValue = '' | FrameCrossCompilation + +export interface FrameBuildOption<T extends string = string> { + value: T + label: string +} + +export const frameCompilationModeOptions: FrameBuildOption<FrameCompilationModeOptionValue>[] = [ + { value: '', label: 'Use binaries if possible, compile from source if not' }, + { value: 'precompiled', label: 'Use precompiled binaries if exist for target OS' }, + { value: 'static', label: 'Compile a single binary' }, + { value: 'shared', label: 'Compile drivers and scenes separately' }, + { value: 'shared-scenes', label: 'Compile drivers separately, scenes as one library' }, +] + +export const frameCrossCompilationOptions: FrameBuildOption<FrameCrossCompilation>[] = [ + { value: 'auto', label: 'Compile on server if possible, on device if not' }, + { value: 'always', label: 'Always compile on server' }, + { value: 'never', label: 'Always compile on device' }, +] + +export function normalizeFrameCompilationMode(value: unknown): FrameCompilationMode { + return value === 'static' || value === 'shared' || value === 'shared-scenes' || value === 'precompiled' + ? value + : 'precompiled' +} + +export function normalizeFrameCrossCompilation(value: unknown): FrameCrossCompilation { + return value === 'always' || value === 'never' ? value : 'auto' +} diff --git a/scripts/frameos-setup.sh b/scripts/frameos-setup.sh new file mode 100755 index 000000000..68fb609e4 --- /dev/null +++ b/scripts/frameos-setup.sh @@ -0,0 +1,1121 @@ +#!/bin/sh +set -eu + +FRAMEOS_RELEASE_VERSION="${FRAMEOS_RELEASE_VERSION:-2026.6.8}" +FRAMEOS_RELEASE_BASE_URL="${FRAMEOS_RELEASE_BASE_URL:-https://github.com/FrameOS/frameos/releases/download/}" +FRAMEOS_DIR="${FRAMEOS_DIR:-/srv/frameos}" +FRAMEOS_AGENT_DIR="${FRAMEOS_AGENT_DIR:-/srv/frameos/agent}" +FRAMEOS_ASSETS_DIR="${FRAMEOS_ASSETS_DIR:-/srv/assets}" +SUPPORTED_RELEASES="debian:buster debian:bullseye debian:bookworm debian:trixie ubuntu:22.04 ubuntu:24.04 ubuntu:26.04" +SUPPORTED_ARCHES="arm64 armhf amd64" +TTY="/dev/tty" +GENERATED_ADMIN_PASSWORD="" + +if [ ! -r "$TTY" ] || [ ! -w "$TTY" ] || ! ( : <"$TTY" ) 2>/dev/null; then + TTY="" +fi + +say() { + printf '%s\n' "$*" +} + +warn() { + printf '%s\n' "$*" >&2 +} + +die() { + warn "$*" + exit 1 +} + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + die "Missing required command: $1" + fi +} + +prompt_out() { + if [ -n "$TTY" ]; then + printf '%s' "$*" >"$TTY" + else + printf '%s' "$*" >&2 + fi +} + +prompt_line() { + if [ -n "$TTY" ]; then + printf '%s\n' "$*" >"$TTY" + else + printf '%s\n' "$*" >&2 + fi +} + +ask() { + prompt="$1" + default="${2:-}" + answer="" + if [ -n "$default" ]; then + prompt_out "$prompt [$default]: " + else + prompt_out "$prompt: " + fi + if [ -n "$TTY" ]; then + IFS= read -r answer <"$TTY" || answer="" + fi + if [ -z "$answer" ]; then + answer="$default" + fi + printf '%s\n' "$answer" +} + +ask_required() { + prompt="$1" + default="${2:-}" + while :; do + answer="$(ask "$prompt" "$default")" + if [ -n "$answer" ]; then + printf '%s\n' "$answer" + return + fi + if [ -z "$TTY" ]; then + die "No terminal is available to ask: $prompt" + fi + warn "A value is required." + done +} + +ask_yes_no() { + prompt="$1" + default="${2:-n}" + while :; do + case "$default" in + y|Y|yes|YES|true|1) suffix="Y/n" ;; + *) suffix="y/N" ;; + esac + answer="$(ask "$prompt ($suffix)" "")" + if [ -z "$answer" ]; then + answer="$default" + fi + case "$answer" in + y|Y|yes|YES|true|TRUE|1) printf '%s\n' "true"; return ;; + n|N|no|NO|false|FALSE|0) printf '%s\n' "false"; return ;; + *) warn "Please answer yes or no." ;; + esac + done +} + +ask_secret() { + prompt="$1" + default_marker="${2:-}" + answer="" + if [ -n "$default_marker" ]; then + prompt_out "$prompt [$default_marker]: " + else + prompt_out "$prompt: " + fi + if [ -n "$TTY" ]; then + old_stty="$(stty -g <"$TTY" 2>/dev/null || true)" + stty -echo <"$TTY" 2>/dev/null || true + IFS= read -r answer <"$TTY" || answer="" + if [ -n "$old_stty" ]; then + stty "$old_stty" <"$TTY" 2>/dev/null || true + else + stty echo <"$TTY" 2>/dev/null || true + fi + prompt_line "" + fi + printf '%s\n' "$answer" +} + +ask_int() { + prompt="$1" + default="$2" + while :; do + answer="$(ask "$prompt" "$default")" + case "$answer" in + ''|*[!0-9]*) warn "Please enter a whole number." ;; + *) printf '%s\n' "$answer"; return ;; + esac + done +} + +ask_float() { + prompt="$1" + default="$2" + while :; do + answer="$(ask "$prompt" "$default")" + if python3 - "$answer" <<'PY' +import sys +try: + value = float(sys.argv[1]) + raise SystemExit(0 if value > 0 else 1) +except Exception: + raise SystemExit(1) +PY + then + printf '%s\n' "$answer" + return + fi + warn "Please enter a positive number." + done +} + +install_packages() { + if ! command -v apt-get >/dev/null 2>&1; then + warn "apt-get not found; skipping package install: $*" + return 0 + fi + + missing="" + for package in "$@"; do + if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q '^install ok installed$'; then + continue + fi + missing="$missing $package" + done + if [ -z "$missing" ]; then + return 0 + fi + + if ! env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $missing; then + env DEBIAN_FRONTEND=noninteractive apt-get update + env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $missing + fi +} + +install_optional_packages() { + if ! command -v apt-get >/dev/null 2>&1; then + return 0 + fi + if ! install_packages "$@"; then + warn "Optional package install failed: $*" + fi +} + +download_file() { + url="$1" + destination="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$destination" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$destination" "$url" + else + die "Missing required command: curl or wget" + fi +} + +detect_arch() { + case "$(uname -m)" in + aarch64|arm64|armv8) echo arm64 ;; + armv8l|armv7l|armv6l|armhf) echo armhf ;; + x86_64|amd64) echo amd64 ;; + *) die "Unsupported CPU architecture: $(uname -m). Supported architectures: $SUPPORTED_ARCHES" ;; + esac +} + +release_supported() { + candidate="$1:$2" + for supported in $SUPPORTED_RELEASES; do + if [ "$candidate" = "$supported" ]; then + return 0 + fi + done + return 1 +} + +print_supported_targets() { + warn "Supported OS releases:" + warn " Debian: buster, bullseye, bookworm, trixie" + warn " Ubuntu: 22.04, 24.04, 26.04" + warn "Supported architectures: $SUPPORTED_ARCHES" +} + +detect_os_target() { + arch="$(detect_arch)" + if [ -n "${FRAMEOS_TARGET:-}" ]; then + echo "$FRAMEOS_TARGET" + return + fi + + if [ ! -r /etc/os-release ]; then + die "Cannot read /etc/os-release." + fi + + # shellcheck disable=SC1091 + . /etc/os-release + distro="${FRAMEOS_DISTRO_OVERRIDE:-${ID:-}}" + release="${FRAMEOS_OS_RELEASE_OVERRIDE:-}" + + if [ -z "$release" ]; then + release="${VERSION_CODENAME:-}" + fi + if [ -z "$release" ]; then + release="${UBUNTU_CODENAME:-}" + fi + if [ -z "$release" ]; then + release="${VERSION_ID:-}" + fi + + case "$distro" in + raspbian|raspios) distro=debian ;; + debian|ubuntu) ;; + *) + case "${ID_LIKE:-}" in + *debian*) distro=debian ;; + esac + ;; + esac + + if [ "$distro" = "ubuntu" ]; then + case "$release" in + jammy|22.04*) release=22.04 ;; + noble|24.04*) release=24.04 ;; + resolute|26.04*) release=26.04 ;; + esac + fi + + if release_supported "$distro" "$release"; then + echo "$distro-$release-$arch" + return + fi + + warn "Unsupported OS detected: ${PRETTY_NAME:-${ID:-unknown}} (${distro:-unknown} ${release:-unknown})" + print_supported_targets + if [ "${FRAMEOS_ALLOW_UNSUPPORTED:-}" = "1" ]; then + override_release="${FRAMEOS_OS_RELEASE_OVERRIDE:-}" + override_distro="${FRAMEOS_DISTRO_OVERRIDE:-$distro}" + else + if [ -z "$TTY" ]; then + die "No terminal is available for an OS override. Set FRAMEOS_TARGET, or set FRAMEOS_DISTRO_OVERRIDE and FRAMEOS_OS_RELEASE_OVERRIDE." + fi + override_distro="$(ask "Use release builds for distro" "${distro:-debian}")" + override_release="$(ask "Use release build version/codename" "bookworm")" + fi + if ! release_supported "$override_distro" "$override_release"; then + die "Unsupported override: $override_distro $override_release" + fi + echo "$override_distro-$override_release-$arch" +} + +json_get() { + file="$1" + path="$2" + default="$3" + if [ ! -f "$file" ]; then + printf '%s\n' "$default" + return + fi + python3 - "$file" "$path" "$default" <<'PY' +import json +import sys + +filename, path, default = sys.argv[1], sys.argv[2], sys.argv[3] +try: + with open(filename, "r", encoding="utf-8") as fh: + data = json.load(fh) +except Exception: + print(default) + raise SystemExit(0) + +node = data +for key in path.split("."): + if isinstance(node, dict) and key in node: + node = node[key] + else: + print(default) + raise SystemExit(0) + +if node is None: + print(default) +elif isinstance(node, bool): + print("true" if node else "false") +elif isinstance(node, (int, float, str)): + print(node) +else: + print(default) +PY +} + +random_secret() { + python3 - <<'PY' +import secrets +print(secrets.token_urlsafe(32)) +PY +} + +detect_timezone() { + if command -v timedatectl >/dev/null 2>&1; then + zone="$(timedatectl show -p Timezone --value 2>/dev/null || true)" + if [ -n "$zone" ]; then + echo "$zone" + return + fi + fi + if [ -r /etc/timezone ]; then + zone="$(sed -n '1p' /etc/timezone 2>/dev/null || true)" + if [ -n "$zone" ]; then + echo "$zone" + return + fi + fi + echo "UTC" +} + +device_dimensions() { + device="$1" + python3 - "$device" <<'PY' +import sys + +device = sys.argv[1] +raw = """ +pimoroni.hyperpixel2r:480x480 +pimoroni.hyperpixel2r_native:480x480 +pimoroni.inky_impression_13:1600x1200 +pimoroni.inky_impression_13_2025:1600x1200 +pimoroni.inky_impression_4:600x400 +pimoroni.inky_impression_4_2025:600x400 +pimoroni.inky_impression_4_7_color:640x400 +pimoroni.inky_impression_4_spectra6:600x400 +pimoroni.inky_impression_5_7:600x448 +pimoroni.inky_impression_5_7_color:600x448 +pimoroni.inky_impression_7:800x480 +pimoroni.inky_impression_7_2025:800x480 +pimoroni.inky_impression_7_3:800x480 +pimoroni.inky_impression_7_color:800x480 +pimoroni.inky_phat_4:250x122 +pimoroni.inky_phat_4_color:250x122 +pimoroni.inky_phat_black:212x104 +pimoroni.inky_phat_jd79661:250x122 +pimoroni.inky_phat_red:212x104 +pimoroni.inky_phat_red_ht:212x104 +pimoroni.inky_phat_ssd1608:250x122 +pimoroni.inky_phat_ssd1608_black:250x122 +pimoroni.inky_phat_ssd1608_red:250x122 +pimoroni.inky_phat_ssd1608_yellow:250x122 +pimoroni.inky_phat_yellow:212x104 +pimoroni.inky_what_4:400x300 +pimoroni.inky_what_4_color:400x300 +pimoroni.inky_what_black:400x300 +pimoroni.inky_what_jd79668:400x300 +pimoroni.inky_what_legacy_yellow:400x300 +pimoroni.inky_what_red:400x300 +pimoroni.inky_what_red_ht:400x300 +pimoroni.inky_what_ssd1683:400x300 +pimoroni.inky_what_ssd1683_black:400x300 +pimoroni.inky_what_ssd1683_red:400x300 +pimoroni.inky_what_ssd1683_yellow:400x300 +pimoroni.inky_what_yellow:400x300 +waveshare.EPD_10in2b:960x640 +waveshare.EPD_10in3:1872x1404 +waveshare.EPD_12in48:1304x984 +waveshare.EPD_12in48b:1304x984 +waveshare.EPD_12in48b_V2:1304x984 +waveshare.EPD_13in3b:960x680 +waveshare.EPD_13in3e:1200x1600 +waveshare.EPD_13in3k:960x680 +waveshare.EPD_1in02d:80x128 +waveshare.EPD_1in54:200x200 +waveshare.EPD_1in54_DES:152x152 +waveshare.EPD_1in54_V2:200x200 +waveshare.EPD_1in54b:200x200 +waveshare.EPD_1in54b_V2:200x200 +waveshare.EPD_1in54c:152x152 +waveshare.EPD_1in64g:168x168 +waveshare.EPD_2in13:122x250 +waveshare.EPD_2in13_DES:104x212 +waveshare.EPD_2in13_V2:122x250 +waveshare.EPD_2in13_V3:122x250 +waveshare.EPD_2in13_V4:122x250 +waveshare.EPD_2in13b:104x212 +waveshare.EPD_2in13b_V3:104x212 +waveshare.EPD_2in13b_V4:122x250 +waveshare.EPD_2in13bc:104x212 +waveshare.EPD_2in13c:104x212 +waveshare.EPD_2in13d:104x212 +waveshare.EPD_2in13g:122x250 +waveshare.EPD_2in13g_V2:122x250 +waveshare.EPD_2in15b:160x296 +waveshare.EPD_2in15g:160x296 +waveshare.EPD_2in36g:168x296 +waveshare.EPD_2in66:152x296 +waveshare.EPD_2in66b:152x296 +waveshare.EPD_2in66g:184x360 +waveshare.EPD_2in7:176x264 +waveshare.EPD_2in7_V2:176x264 +waveshare.EPD_2in7b:176x264 +waveshare.EPD_2in7b_V2:176x264 +waveshare.EPD_2in9:128x296 +waveshare.EPD_2in9_DES:128x296 +waveshare.EPD_2in9_V2:128x296 +waveshare.EPD_2in9b:128x296 +waveshare.EPD_2in9b_V3:128x296 +waveshare.EPD_2in9b_V4:128x296 +waveshare.EPD_2in9bc:128x296 +waveshare.EPD_2in9c:128x296 +waveshare.EPD_2in9d:128x296 +waveshare.EPD_3in0g:168x400 +waveshare.EPD_3in52:240x360 +waveshare.EPD_3in52b:240x360 +waveshare.EPD_3in7:280x480 +waveshare.EPD_4in01f:640x400 +waveshare.EPD_4in0e:400x600 +waveshare.EPD_4in2:400x300 +waveshare.EPD_4in26:800x480 +waveshare.EPD_4in2_V2:400x300 +waveshare.EPD_4in2b:400x300 +waveshare.EPD_4in2b_V2:400x300 +waveshare.EPD_4in2b_V2_old:400x300 +waveshare.EPD_4in2bc:400x300 +waveshare.EPD_4in2c:400x300 +waveshare.EPD_4in37b:176x480 +waveshare.EPD_4in37g:512x368 +waveshare.EPD_5in65f:600x448 +waveshare.EPD_5in79:792x272 +waveshare.EPD_5in79b:792x272 +waveshare.EPD_5in79g:792x272 +waveshare.EPD_5in83:600x448 +waveshare.EPD_5in83_V2:648x480 +waveshare.EPD_5in83b:600x448 +waveshare.EPD_5in83b_V2:648x480 +waveshare.EPD_5in83bc:600x448 +waveshare.EPD_5in83c:600x448 +waveshare.EPD_5in84:768x256 +waveshare.EPD_7in3e:800x480 +waveshare.EPD_7in3f:800x480 +waveshare.EPD_7in3g:800x480 +waveshare.EPD_7in5:640x384 +waveshare.EPD_7in5_HD:880x528 +waveshare.EPD_7in5_V2:800x480 +waveshare.EPD_7in5_V2_gray:800x480 +waveshare.EPD_7in5b:640x384 +waveshare.EPD_7in5b_HD:880x528 +waveshare.EPD_7in5b_V2:800x480 +waveshare.EPD_7in5b_V2_old:800x480 +waveshare.EPD_7in5bc:640x384 +waveshare.EPD_7in5c:640x384 +web_only:800x480 +framebuffer:800x480 +http.upload:800x480 +""" +for line in raw.splitlines(): + if not line or ":" not in line: + continue + key, dims = line.split(":", 1) + if key == device: + print(dims.replace("x", " ")) + raise SystemExit(0) +raise SystemExit(1) +PY +} + +print_pimoroni_devices() { + cat <<'EOF' + pimoroni.inky_impression_13 + pimoroni.inky_impression_13_2025 + pimoroni.inky_impression_7_3 + pimoroni.inky_impression_7_color + pimoroni.inky_impression_7 + pimoroni.inky_impression_7_2025 + pimoroni.inky_impression_5_7 + pimoroni.inky_impression_5_7_color + pimoroni.inky_impression_4 + pimoroni.inky_impression_4_2025 + pimoroni.inky_impression_4_7_color + pimoroni.inky_impression_4_spectra6 + pimoroni.inky_phat_black + pimoroni.inky_phat_red + pimoroni.inky_phat_yellow + pimoroni.inky_phat_4 + pimoroni.inky_what_black + pimoroni.inky_what_red + pimoroni.inky_what_yellow + pimoroni.inky_what_4 + pimoroni.hyperpixel2r + pimoroni.hyperpixel2r_native +EOF +} + +print_waveshare_devices() { + cat <<'EOF' + waveshare.EPD_1in02d + waveshare.EPD_1in54 + waveshare.EPD_2in13 + waveshare.EPD_2in13_V3 + waveshare.EPD_2in13_V4 + waveshare.EPD_2in66 + waveshare.EPD_2in7 + waveshare.EPD_2in9 + waveshare.EPD_2in9_V2 + waveshare.EPD_3in7 + waveshare.EPD_4in2 + waveshare.EPD_4in2_V2 + waveshare.EPD_4in26 + waveshare.EPD_5in65f + waveshare.EPD_5in83 + waveshare.EPD_5in83_V2 + waveshare.EPD_7in3e + waveshare.EPD_7in3f + waveshare.EPD_7in3g + waveshare.EPD_7in5 + waveshare.EPD_7in5_HD + waveshare.EPD_7in5_V2 + waveshare.EPD_7in5b + waveshare.EPD_7in5b_V2 + waveshare.EPD_10in3 + waveshare.EPD_12in48 + waveshare.EPD_13in3e +EOF +} + +choose_device() { + default="$1" + while :; do + prompt_line "" + prompt_line "Device choices:" + prompt_line " 1) web_only (browser/admin preview only)" + prompt_line " 2) framebuffer (HDMI or Linux framebuffer)" + prompt_line " 3) http.upload (POST rendered PNG to an HTTP endpoint)" + prompt_line " 4) pimoroni.inky_impression_7_2025" + prompt_line " 5) pimoroni.inky_impression_13_2025" + prompt_line " 6) waveshare.EPD_7in3e" + prompt_line " 7) waveshare.EPD_13in3e" + prompt_line " 8) waveshare.EPD_7in5_V2" + prompt_line " p) list Pimoroni devices" + prompt_line " w) list Waveshare examples" + prompt_line " c) custom device key" + answer="$(ask "Device" "$default")" + case "$answer" in + 1) echo "web_only"; return ;; + 2) echo "framebuffer"; return ;; + 3) echo "http.upload"; return ;; + 4) echo "pimoroni.inky_impression_7_2025"; return ;; + 5) echo "pimoroni.inky_impression_13_2025"; return ;; + 6) echo "waveshare.EPD_7in3e"; return ;; + 7) echo "waveshare.EPD_13in3e"; return ;; + 8) echo "waveshare.EPD_7in5_V2"; return ;; + p|P) + print_pimoroni_devices >&2 + ;; + w|W) + print_waveshare_devices >&2 + ;; + c|C|custom) + custom="$(ask_required "Custom device key" "$default")" + echo "$custom" + return + ;; + EPD_*) + echo "waveshare.$answer" + return + ;; + *) + if [ -n "$answer" ]; then + echo "$answer" + return + fi + ;; + esac + done +} + +copy_scene_payloads() { + release_dir="$1" + old_dir="$2" + mkdir -p "$release_dir" + + if [ -n "$old_dir" ] && [ -f "$old_dir/all_scenes.json.gz" ]; then + cp "$old_dir/all_scenes.json.gz" "$release_dir/all_scenes.json.gz" + elif [ -n "$old_dir" ] && [ -f "$old_dir/all_scenes.json" ]; then + gzip -c "$old_dir/all_scenes.json" > "$release_dir/all_scenes.json.gz" + else + printf '[]\n' | gzip -c > "$release_dir/all_scenes.json.gz" + fi + + if [ -n "$old_dir" ] && [ -f "$old_dir/scenes.json.gz" ]; then + cp "$old_dir/scenes.json.gz" "$release_dir/scenes.json.gz" + elif [ -n "$old_dir" ] && [ -f "$old_dir/scenes.json" ]; then + gzip -c "$old_dir/scenes.json" > "$release_dir/scenes.json.gz" + else + printf '[]\n' | gzip -c > "$release_dir/scenes.json.gz" + fi +} + +write_frame_config() { + existing_config="$1" + destination="$2" + FRAMEOS_EXISTING_CONFIG="$existing_config" FRAMEOS_CONFIG_DESTINATION="$destination" python3 - <<'PY' +import json +import os +from pathlib import Path + +def env(name, default=""): + return os.environ.get(name, default) + +def env_bool(name): + return env(name).lower() in {"1", "true", "yes", "y", "on"} + +def env_int(name, default): + try: + return int(env(name, str(default))) + except Exception: + return default + +def env_float(name, default): + try: + return float(env(name, str(default))) + except Exception: + return default + +source = Path(env("FRAMEOS_EXISTING_CONFIG")) +if source.is_file(): + try: + data = json.loads(source.read_text(encoding="utf-8")) + if not isinstance(data, dict): + data = {} + except Exception: + data = {} +else: + data = {} + +device_config = dict(data.get("deviceConfig") or {}) +if env("FRAMEOS_DEVICE") == "http.upload": + device_config["uploadUrl"] = env("FRAMEOS_HTTP_UPLOAD_URL") +else: + device_config.pop("uploadUrl", None) +if env("FRAMEOS_DEVICE_VCOM"): + device_config["vcom"] = env_float("FRAMEOS_DEVICE_VCOM", 0) + +https_proxy = dict(data.get("httpsProxy") or {}) +https_proxy.setdefault("enable", False) +https_proxy.setdefault("port", 8443) +https_proxy.setdefault("exposeOnlyPort", False) + +network = dict(data.get("network") or {}) +network.update({ + "networkCheck": env_bool("FRAMEOS_NETWORK_CHECK"), + "networkCheckTimeoutSeconds": env_int("FRAMEOS_NETWORK_CHECK_TIMEOUT_SECONDS", 30), + "networkCheckUrl": env("FRAMEOS_NETWORK_CHECK_URL", "https://networkcheck.frameos.net/"), + "wifiHotspot": env("FRAMEOS_WIFI_HOTSPOT", "disabled"), + "wifiHotspotSsid": env("FRAMEOS_WIFI_HOTSPOT_SSID", "FrameOS-Setup"), + "wifiHotspotPassword": env("FRAMEOS_WIFI_HOTSPOT_PASSWORD", "frame1234"), + "wifiHotspotTimeoutSeconds": env_int("FRAMEOS_WIFI_HOTSPOT_TIMEOUT_SECONDS", 300), +}) + +agent_enabled = env_bool("FRAMEOS_BACKEND_ENABLED") +agent = dict(data.get("agent") or {}) +agent.update({ + "agentEnabled": agent_enabled, + "agentRunCommands": env_bool("FRAMEOS_AGENT_RUN_COMMANDS") if agent_enabled else False, + "agentSharedSecret": env("FRAMEOS_AGENT_SHARED_SECRET") if agent_enabled else agent.get("agentSharedSecret", ""), +}) + +frame_admin_auth = dict(data.get("frameAdminAuth") or {}) +frame_admin_auth.update({ + "enabled": env_bool("FRAMEOS_ADMIN_AUTH_ENABLED"), + "user": env("FRAMEOS_ADMIN_USER"), + "pass": env("FRAMEOS_ADMIN_PASSWORD"), +}) + +data.update({ + "frameosVersion": env("FRAMEOS_RELEASE_VERSION"), + "name": env("FRAMEOS_NAME"), + "mode": "rpios", + "frameHost": env("FRAMEOS_FRAME_HOST", "localhost"), + "framePort": env_int("FRAMEOS_FRAME_PORT", 8787), + "frameAccessKey": env("FRAMEOS_FRAME_ACCESS_KEY"), + "frameAccess": env("FRAMEOS_FRAME_ACCESS", "private"), + "httpsProxy": https_proxy, + "serverHost": env("FRAMEOS_SERVER_HOST"), + "serverPort": env_int("FRAMEOS_SERVER_PORT", 8989), + "serverApiKey": env("FRAMEOS_SERVER_API_KEY"), + "serverSendLogs": env_bool("FRAMEOS_SERVER_SEND_LOGS") if agent_enabled else False, + "width": env_int("FRAMEOS_WIDTH", 800), + "height": env_int("FRAMEOS_HEIGHT", 480), + "device": env("FRAMEOS_DEVICE"), + "deviceConfig": device_config, + "interval": env_float("FRAMEOS_INTERVAL", 300), + "metricsInterval": env_float("FRAMEOS_METRICS_INTERVAL", 60), + "maxHttpResponseBytes": env_int("FRAMEOS_MAX_HTTP_RESPONSE_BYTES", 67108864), + "debug": env_bool("FRAMEOS_DEBUG"), + "scalingMode": env("FRAMEOS_SCALING_MODE", "contain"), + "imageEngine": env("FRAMEOS_IMAGE_ENGINE", ""), + "rotate": env_int("FRAMEOS_ROTATE", 0), + "flip": env("FRAMEOS_FLIP", ""), + "logToFile": env("FRAMEOS_LOG_TO_FILE"), + "assetsPath": env("FRAMEOS_ASSETS_PATH", "/srv/assets"), + "saveAssets": env_bool("FRAMEOS_SAVE_ASSETS"), + "schedule": data.get("schedule") if isinstance(data.get("schedule"), dict) else {"events": []}, + "gpioButtons": data.get("gpioButtons") if isinstance(data.get("gpioButtons"), list) else [], + "palette": data.get("palette") if isinstance(data.get("palette"), dict) else {}, + "controlCode": data.get("controlCode") if isinstance(data.get("controlCode"), dict) else {"enabled": False}, + "network": network, + "agent": agent, + "mountpoints": data.get("mountpoints") if isinstance(data.get("mountpoints"), dict) else {"enabled": False, "items": []}, + "errorBehavior": data.get("errorBehavior") if isinstance(data.get("errorBehavior"), dict) else { + "mode": "show_error_retry", + "retrySeconds": 60, + "silentRetrySeconds": 60, + "silentRetryForever": False, + "silentWindowMinutes": 10, + "showErrorRetrySeconds": 60, + }, + "timeZone": env("FRAMEOS_TIME_ZONE"), + "timeZoneUpdates": data.get("timeZoneUpdates") if isinstance(data.get("timeZoneUpdates"), dict) else { + "enabled": True, + "hour": 3, + "url": "https://tz.frameos.net/tzdata.json.gz", + }, + "frameAdminAuth": frame_admin_auth, + "settings": data.get("settings") if isinstance(data.get("settings"), dict) else {}, +}) + +Path(env("FRAMEOS_CONFIG_DESTINATION")).write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY +} + +if [ "$(id -u)" -ne 0 ]; then + die "Run this setup script as root, for example: curl -fsSL https://frameos.net/setup.sh | sudo sh" +fi + +need_cmd uname +need_cmd tar +need_cmd find +need_cmd gzip +need_cmd install +need_cmd mktemp +need_cmd systemctl + +target="$(detect_os_target)" +if ! command -v python3 >/dev/null 2>&1; then + install_packages python3 +fi +need_cmd python3 +install_packages ca-certificates hostapd imagemagick +install_optional_packages caddy +systemctl disable --now caddy.service >/dev/null 2>&1 || true + +existing_config="" +existing_release_dir="" +if [ -f "$FRAMEOS_DIR/current/frame.json" ]; then + existing_config="$FRAMEOS_DIR/current/frame.json" + existing_release_dir="$(readlink -f "$FRAMEOS_DIR/current" 2>/dev/null || printf '%s' "$FRAMEOS_DIR/current")" +elif [ -f "$FRAMEOS_DIR/frame.json" ]; then + existing_config="$FRAMEOS_DIR/frame.json" +fi + +say "FrameOS standalone setup" +say "Target release: FrameOS $FRAMEOS_RELEASE_VERSION for $target" +if [ -n "$existing_config" ]; then + say "Using existing defaults from $existing_config" +fi + +default_name="$(json_get "$existing_config" name "$(hostname)")" +default_device="$(json_get "$existing_config" device "web_only")" +default_width="$(json_get "$existing_config" width "800")" +default_height="$(json_get "$existing_config" height "480")" +default_frame_port="$(json_get "$existing_config" framePort "8787")" +default_interval="$(json_get "$existing_config" interval "300")" +default_metrics_interval="$(json_get "$existing_config" metricsInterval "60")" +default_rotate="$(json_get "$existing_config" rotate "0")" +default_timezone="$(json_get "$existing_config" timeZone "$(detect_timezone)")" +default_admin_enabled="$(json_get "$existing_config" frameAdminAuth.enabled "true")" +default_admin_user="$(json_get "$existing_config" frameAdminAuth.user "admin")" +existing_admin_password="$(json_get "$existing_config" frameAdminAuth.pass "")" +default_frame_access_key="$(json_get "$existing_config" frameAccessKey "$(random_secret)")" +default_server_host="$(json_get "$existing_config" serverHost "")" +default_server_port="$(json_get "$existing_config" serverPort "8989")" +default_server_api_key="$(json_get "$existing_config" serverApiKey "")" +default_agent_secret="$(json_get "$existing_config" agent.agentSharedSecret "")" +default_agent_enabled="$(json_get "$existing_config" agent.agentEnabled "false")" +default_agent_run_commands="$(json_get "$existing_config" agent.agentRunCommands "true")" +default_server_send_logs="$(json_get "$existing_config" serverSendLogs "true")" +default_network_check="$(json_get "$existing_config" network.networkCheck "true")" +default_wifi_hotspot="$(json_get "$existing_config" network.wifiHotspot "disabled")" +default_wifi_ssid="$(json_get "$existing_config" network.wifiHotspotSsid "FrameOS-Setup")" +default_wifi_password="$(json_get "$existing_config" network.wifiHotspotPassword "frame1234")" +default_log_to_file="$(json_get "$existing_config" logToFile "")" +default_save_assets="$(json_get "$existing_config" saveAssets "true")" + +FRAMEOS_NAME="${FRAMEOS_NAME:-$(ask_required "Frame name" "$default_name")}" +FRAMEOS_DEVICE="${FRAMEOS_DEVICE:-$(choose_device "$default_device")}" + +dims="$(device_dimensions "$FRAMEOS_DEVICE" 2>/dev/null || true)" +if [ -n "$dims" ]; then + detected_width="$(printf '%s' "$dims" | awk '{print $1}')" + detected_height="$(printf '%s' "$dims" | awk '{print $2}')" +else + detected_width="$default_width" + detected_height="$default_height" +fi + +FRAMEOS_WIDTH="${FRAMEOS_WIDTH:-$(ask_int "Display width" "$detected_width")}" +FRAMEOS_HEIGHT="${FRAMEOS_HEIGHT:-$(ask_int "Display height" "$detected_height")}" +FRAMEOS_ROTATE="${FRAMEOS_ROTATE:-$(ask "Rotation (0, 90, 180, 270)" "$default_rotate")}" +case "$FRAMEOS_ROTATE" in + 0|90|180|270) ;; + *) die "Unsupported rotation: $FRAMEOS_ROTATE" ;; +esac +FRAMEOS_FRAME_PORT="${FRAMEOS_FRAME_PORT:-$(ask_int "Local admin panel port" "$default_frame_port")}" +FRAMEOS_TIME_ZONE="${FRAMEOS_TIME_ZONE:-$(ask "Timezone" "$default_timezone")}" +FRAMEOS_INTERVAL="${FRAMEOS_INTERVAL:-$default_interval}" +FRAMEOS_METRICS_INTERVAL="${FRAMEOS_METRICS_INTERVAL:-$default_metrics_interval}" +FRAMEOS_ADMIN_AUTH_ENABLED="${FRAMEOS_ADMIN_AUTH_ENABLED:-$(ask_yes_no "Require admin login for the local panel" "$default_admin_enabled")}" +FRAMEOS_ADMIN_USER="${FRAMEOS_ADMIN_USER:-$(ask_required "Admin username" "$default_admin_user")}" + +if [ -n "${FRAMEOS_ADMIN_PASSWORD:-}" ]; then + : # provided through environment +elif [ -n "$existing_admin_password" ]; then + entered_password="$(ask_secret "Admin password" "keep existing")" + if [ -n "$entered_password" ]; then + FRAMEOS_ADMIN_PASSWORD="$entered_password" + else + FRAMEOS_ADMIN_PASSWORD="$existing_admin_password" + fi +else + entered_password="$(ask_secret "Admin password (blank generates one)" "")" + if [ -n "$entered_password" ]; then + confirm_password="$(ask_secret "Confirm admin password" "")" + if [ "$entered_password" != "$confirm_password" ]; then + die "Admin passwords did not match." + fi + FRAMEOS_ADMIN_PASSWORD="$entered_password" + else + FRAMEOS_ADMIN_PASSWORD="$(random_secret)" + GENERATED_ADMIN_PASSWORD="$FRAMEOS_ADMIN_PASSWORD" + fi +fi + +if [ "$FRAMEOS_DEVICE" = "http.upload" ]; then + default_upload_url="$(json_get "$existing_config" deviceConfig.uploadUrl "")" + FRAMEOS_HTTP_UPLOAD_URL="${FRAMEOS_HTTP_UPLOAD_URL:-$(ask_required "HTTP upload URL" "$default_upload_url")}" +else + FRAMEOS_HTTP_UPLOAD_URL="${FRAMEOS_HTTP_UPLOAD_URL:-}" +fi + +case "$FRAMEOS_DEVICE" in + waveshare.*) + default_vcom="$(json_get "$existing_config" deviceConfig.vcom "")" + FRAMEOS_DEVICE_VCOM="${FRAMEOS_DEVICE_VCOM:-$(ask "Waveshare VCOM override, if needed" "$default_vcom")}" + ;; + *) + FRAMEOS_DEVICE_VCOM="${FRAMEOS_DEVICE_VCOM:-}" + ;; +esac + +backend_default="n" +if [ "$default_agent_enabled" = "true" ] || { [ -n "$default_server_host" ] && [ "$default_server_host" != "localhost" ]; }; then + backend_default="y" +fi +FRAMEOS_BACKEND_ENABLED="${FRAMEOS_BACKEND_ENABLED:-$(ask_yes_no "Connect this frame to a FrameOS backend" "$backend_default")}" + +if [ "$FRAMEOS_BACKEND_ENABLED" = "true" ]; then + FRAMEOS_SERVER_HOST="${FRAMEOS_SERVER_HOST:-$(ask_required "Backend host" "$default_server_host")}" + FRAMEOS_SERVER_PORT="${FRAMEOS_SERVER_PORT:-$(ask_int "Backend port" "$default_server_port")}" + FRAMEOS_SERVER_API_KEY="${FRAMEOS_SERVER_API_KEY:-$(ask_required "Backend server API key" "$default_server_api_key")}" + FRAMEOS_AGENT_SHARED_SECRET="${FRAMEOS_AGENT_SHARED_SECRET:-$(ask_required "FrameOS agent shared secret" "$default_agent_secret")}" + FRAMEOS_AGENT_RUN_COMMANDS="${FRAMEOS_AGENT_RUN_COMMANDS:-$(ask_yes_no "Allow backend terminal/deploy commands through the agent" "$default_agent_run_commands")}" + FRAMEOS_SERVER_SEND_LOGS="${FRAMEOS_SERVER_SEND_LOGS:-$(ask_yes_no "Send logs to the backend" "$default_server_send_logs")}" +else + FRAMEOS_SERVER_HOST="${FRAMEOS_SERVER_HOST:-}" + FRAMEOS_SERVER_PORT="${FRAMEOS_SERVER_PORT:-8989}" + FRAMEOS_SERVER_API_KEY="${FRAMEOS_SERVER_API_KEY:-}" + FRAMEOS_AGENT_SHARED_SECRET="${FRAMEOS_AGENT_SHARED_SECRET:-$default_agent_secret}" + FRAMEOS_AGENT_RUN_COMMANDS="${FRAMEOS_AGENT_RUN_COMMANDS:-false}" + FRAMEOS_SERVER_SEND_LOGS="${FRAMEOS_SERVER_SEND_LOGS:-false}" +fi + +FRAMEOS_FRAME_HOST="${FRAMEOS_FRAME_HOST:-$(json_get "$existing_config" frameHost "localhost")}" +FRAMEOS_FRAME_ACCESS="${FRAMEOS_FRAME_ACCESS:-$(json_get "$existing_config" frameAccess "private")}" +FRAMEOS_FRAME_ACCESS_KEY="${FRAMEOS_FRAME_ACCESS_KEY:-$default_frame_access_key}" +FRAMEOS_NETWORK_CHECK="${FRAMEOS_NETWORK_CHECK:-$(ask_yes_no "Enable network check before rendering" "$default_network_check")}" +FRAMEOS_NETWORK_CHECK_TIMEOUT_SECONDS="${FRAMEOS_NETWORK_CHECK_TIMEOUT_SECONDS:-30}" +FRAMEOS_NETWORK_CHECK_URL="${FRAMEOS_NETWORK_CHECK_URL:-https://networkcheck.frameos.net/}" +FRAMEOS_WIFI_HOTSPOT="${FRAMEOS_WIFI_HOTSPOT:-$(ask "WiFi setup hotspot mode (disabled/bootOnly)" "$default_wifi_hotspot")}" +case "$FRAMEOS_WIFI_HOTSPOT" in + disabled|bootOnly) ;; + *) die "Unsupported WiFi setup hotspot mode: $FRAMEOS_WIFI_HOTSPOT" ;; +esac +if [ "$FRAMEOS_WIFI_HOTSPOT" = "bootOnly" ]; then + FRAMEOS_WIFI_HOTSPOT_SSID="${FRAMEOS_WIFI_HOTSPOT_SSID:-$(ask "WiFi setup hotspot SSID" "$default_wifi_ssid")}" + FRAMEOS_WIFI_HOTSPOT_PASSWORD="${FRAMEOS_WIFI_HOTSPOT_PASSWORD:-$(ask "WiFi setup hotspot password" "$default_wifi_password")}" +else + FRAMEOS_WIFI_HOTSPOT_SSID="${FRAMEOS_WIFI_HOTSPOT_SSID:-$default_wifi_ssid}" + FRAMEOS_WIFI_HOTSPOT_PASSWORD="${FRAMEOS_WIFI_HOTSPOT_PASSWORD:-$default_wifi_password}" +fi +FRAMEOS_WIFI_HOTSPOT_TIMEOUT_SECONDS="${FRAMEOS_WIFI_HOTSPOT_TIMEOUT_SECONDS:-300}" +FRAMEOS_LOG_TO_FILE="${FRAMEOS_LOG_TO_FILE:-$default_log_to_file}" +FRAMEOS_ASSETS_PATH="${FRAMEOS_ASSETS_PATH:-$FRAMEOS_ASSETS_DIR}" +FRAMEOS_SAVE_ASSETS="${FRAMEOS_SAVE_ASSETS:-$default_save_assets}" +FRAMEOS_MAX_HTTP_RESPONSE_BYTES="${FRAMEOS_MAX_HTTP_RESPONSE_BYTES:-67108864}" +FRAMEOS_DEBUG="${FRAMEOS_DEBUG:-false}" +FRAMEOS_SCALING_MODE="${FRAMEOS_SCALING_MODE:-$(json_get "$existing_config" scalingMode "contain")}" +FRAMEOS_IMAGE_ENGINE="${FRAMEOS_IMAGE_ENGINE:-$(json_get "$existing_config" imageEngine "")}" +FRAMEOS_FLIP="${FRAMEOS_FLIP:-$(json_get "$existing_config" flip "")}" + +export FRAMEOS_RELEASE_VERSION +export FRAMEOS_NAME FRAMEOS_DEVICE FRAMEOS_WIDTH FRAMEOS_HEIGHT FRAMEOS_ROTATE +export FRAMEOS_FRAME_HOST FRAMEOS_FRAME_PORT FRAMEOS_FRAME_ACCESS FRAMEOS_FRAME_ACCESS_KEY +export FRAMEOS_SERVER_HOST FRAMEOS_SERVER_PORT FRAMEOS_SERVER_API_KEY FRAMEOS_SERVER_SEND_LOGS +export FRAMEOS_BACKEND_ENABLED FRAMEOS_AGENT_SHARED_SECRET FRAMEOS_AGENT_RUN_COMMANDS +export FRAMEOS_ADMIN_AUTH_ENABLED FRAMEOS_ADMIN_USER FRAMEOS_ADMIN_PASSWORD +export FRAMEOS_HTTP_UPLOAD_URL FRAMEOS_DEVICE_VCOM +export FRAMEOS_NETWORK_CHECK FRAMEOS_NETWORK_CHECK_TIMEOUT_SECONDS FRAMEOS_NETWORK_CHECK_URL +export FRAMEOS_WIFI_HOTSPOT FRAMEOS_WIFI_HOTSPOT_SSID FRAMEOS_WIFI_HOTSPOT_PASSWORD FRAMEOS_WIFI_HOTSPOT_TIMEOUT_SECONDS +export FRAMEOS_LOG_TO_FILE FRAMEOS_ASSETS_PATH FRAMEOS_SAVE_ASSETS +export FRAMEOS_MAX_HTTP_RESPONSE_BYTES FRAMEOS_DEBUG FRAMEOS_SCALING_MODE FRAMEOS_IMAGE_ENGINE FRAMEOS_FLIP +export FRAMEOS_INTERVAL FRAMEOS_METRICS_INTERVAL FRAMEOS_TIME_ZONE + +say "" +say "Installing FrameOS..." +base_url="${FRAMEOS_RELEASE_BASE_URL%/}" +archive_url="$base_url/v$FRAMEOS_RELEASE_VERSION/frameos-$FRAMEOS_RELEASE_VERSION-$target.tar.gz" +work_dir="$(mktemp -d)" +release_name="release_setup_$(date +%Y%m%d%H%M%S)" +frameos_release_dir="$FRAMEOS_DIR/releases/$release_name" +agent_release_dir="$FRAMEOS_AGENT_DIR/releases/$release_name" +trap 'rm -rf "$work_dir"' EXIT + +download_file "$archive_url" "$work_dir/frameos.tar.gz" +mkdir -p "$work_dir/extract" "$frameos_release_dir" "$agent_release_dir" "$FRAMEOS_AGENT_DIR/logs" "$FRAMEOS_DIR/logs" "$FRAMEOS_DIR/state" "$FRAMEOS_ASSETS_PATH" +tar -xzf "$work_dir/frameos.tar.gz" -C "$work_dir/extract" + +frameos_binary="$(find "$work_dir/extract" -type f -name frameos | head -n 1)" +agent_binary="$(find "$work_dir/extract" -type f -name frameos_agent | head -n 1)" +if [ -z "$frameos_binary" ]; then + die "The precompiled FrameOS release did not contain a frameos binary for $target." +fi +if [ -z "$agent_binary" ]; then + die "The precompiled FrameOS release did not contain a frameos_agent binary for $target." +fi +artifact_root="${frameos_binary%/*}" + +install -m 0755 "$frameos_binary" "$frameos_release_dir/frameos" +install -m 0755 "$agent_binary" "$agent_release_dir/frameos_agent" + +if [ -d "$artifact_root/drivers" ]; then + cp -R "$artifact_root/drivers" "$frameos_release_dir/drivers" +fi +if [ -d "$artifact_root/scenes" ]; then + cp -R "$artifact_root/scenes" "$frameos_release_dir/scenes" +fi +if [ -d "$artifact_root/vendor" ]; then + mkdir -p "$FRAMEOS_DIR/vendor" + cp -R "$artifact_root/vendor/." "$FRAMEOS_DIR/vendor/" +fi + +write_frame_config "$existing_config" "$frameos_release_dir/frame.json" +cp "$frameos_release_dir/frame.json" "$agent_release_dir/frame.json" +copy_scene_payloads "$frameos_release_dir" "$existing_release_dir" + +agent_user="${SUDO_USER:-}" +if [ -z "$agent_user" ] || [ "$agent_user" = "root" ]; then + if id pi >/dev/null 2>&1; then + agent_user=pi + else + agent_user="$(id -un)" + fi +fi +if ! id "$agent_user" >/dev/null 2>&1; then + agent_user=root +fi + +cat > "$frameos_release_dir/frameos.service" <<EOF +[Unit] +Description=FrameOS Service +After=network.target + +[Service] +User=$agent_user +WorkingDirectory=$FRAMEOS_DIR/current +ExecStart=$FRAMEOS_DIR/current/frameos +Restart=always + +[Install] +WantedBy=multi-user.target +EOF + +cat > "$agent_release_dir/frameos_agent.service" <<EOF +[Unit] +Description=FrameOS Agent (auto-reconnect, hardened) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=$agent_user +WorkingDirectory=$FRAMEOS_AGENT_DIR/current +ExecStart=$FRAMEOS_AGENT_DIR/current/frameos_agent +Restart=always +RestartSec=5 +LimitNOFILE=65536 +PrivateTmp=yes +ProtectSystem=full +ReadWritePaths=/etc/systemd/system /etc/cron.d /boot + +[Install] +WantedBy=multi-user.target +EOF + +rm -rf "$FRAMEOS_DIR/current" "$FRAMEOS_AGENT_DIR/current" +ln -s "$frameos_release_dir" "$FRAMEOS_DIR/current" +ln -s "$agent_release_dir" "$FRAMEOS_AGENT_DIR/current" +chown -R "$agent_user" "$FRAMEOS_DIR" "$FRAMEOS_ASSETS_PATH" + +set +e +cd "$frameos_release_dir" && ./frameos setup +setup_status=$? +set -e + +if [ "$setup_status" -ne 0 ] && [ "$setup_status" -ne 2 ]; then + die "FrameOS setup failed with exit code $setup_status." +fi + +install -d -m 0755 /etc/systemd/system +install -m 0644 "$frameos_release_dir/frameos.service" /etc/systemd/system/frameos.service +if [ "$FRAMEOS_BACKEND_ENABLED" = "true" ]; then + install -m 0644 "$agent_release_dir/frameos_agent.service" /etc/systemd/system/frameos_agent.service +else + systemctl disable --now frameos_agent.service >/dev/null 2>&1 || true +fi +systemctl daemon-reload +systemctl enable frameos.service >/dev/null +if [ "$FRAMEOS_BACKEND_ENABLED" = "true" ]; then + systemctl enable frameos_agent.service >/dev/null +fi + +if [ "$setup_status" -eq 2 ]; then + say "" + say "FrameOS is installed, but hardware setup requested a reboot before the service starts." + say "Reboot this device, then open the local admin panel at http://<frame-ip>:$FRAMEOS_FRAME_PORT/" +else + if [ "$FRAMEOS_BACKEND_ENABLED" = "true" ]; then + systemctl restart frameos_agent.service + fi + systemctl restart frameos.service + say "" + say "FrameOS is installed and started." + say "Open the local admin panel at http://<frame-ip>:$FRAMEOS_FRAME_PORT/" +fi + +say "" +say "Summary:" +say " Release: $FRAMEOS_RELEASE_VERSION ($target)" +say " Config: $frameos_release_dir/frame.json" +say " Current release: $FRAMEOS_DIR/current" +say " Device: $FRAMEOS_DEVICE ($FRAMEOS_WIDTH x $FRAMEOS_HEIGHT, rotate $FRAMEOS_ROTATE)" +say " Admin user: $FRAMEOS_ADMIN_USER" +if [ -n "$GENERATED_ADMIN_PASSWORD" ]; then + say " Generated admin password: $GENERATED_ADMIN_PASSWORD" +fi +if [ "$FRAMEOS_BACKEND_ENABLED" = "true" ]; then + say " Backend: $FRAMEOS_SERVER_HOST:$FRAMEOS_SERVER_PORT" +else + say " Backend: not configured; FrameOS will run standalone." +fi +say "" +say "You should see FrameOS render soon. If the display stays blank or distorted, run this setup again and try a different device driver." +say "Logs:" +say " journalctl -u frameos -f" +if [ "$FRAMEOS_BACKEND_ENABLED" = "true" ]; then + say " journalctl -u frameos_agent -f" +fi diff --git a/scripts/tests/test_frameos_setup.py b/scripts/tests/test_frameos_setup.py new file mode 100644 index 000000000..d6399c2ff --- /dev/null +++ b/scripts/tests/test_frameos_setup.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tarfile +import tempfile +import textwrap +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SETUP_SCRIPT = ROOT / "scripts" / "frameos-setup.sh" +IMAGE = os.environ.get("FRAMEOS_SETUP_TEST_IMAGE", "python:3.12-slim-bookworm") +VERSION = "2026.6.8" +TARGET = "debian-bookworm-amd64" + + +class FrameOSSetupScriptTest(unittest.TestCase): + def setUp(self) -> None: + if not shutil.which("docker"): + self.skipTest("docker is required for setup script container tests") + docker_info = subprocess.run( + ["docker", "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if docker_info.returncode != 0: + self.skipTest("docker daemon is required for setup script container tests") + self.tmp = tempfile.TemporaryDirectory(prefix="frameos-setup-test-") + self.tmp_path = Path(self.tmp.name) + self.releases_dir = self.tmp_path / "releases" + self.out_dir = self.tmp_path / "out" + self._write_fake_release_archive() + self._write_stubs() + + def tearDown(self) -> None: + self._restore_tmp_permissions() + self.tmp.cleanup() + + def test_standalone_install_writes_frame_json_and_starts_frameos_only(self) -> None: + result = self._run_setup( + { + "FRAMEOS_NAME": "Standalone Frame", + "FRAMEOS_DEVICE": "waveshare.EPD_7in3e", + "FRAMEOS_WIDTH": "800", + "FRAMEOS_HEIGHT": "480", + "FRAMEOS_ROTATE": "90", + "FRAMEOS_FRAME_PORT": "8787", + "FRAMEOS_TIME_ZONE": "Europe/Brussels", + "FRAMEOS_ADMIN_AUTH_ENABLED": "true", + "FRAMEOS_ADMIN_USER": "admin", + "FRAMEOS_ADMIN_PASSWORD": "admin-secret", + "FRAMEOS_BACKEND_ENABLED": "false", + "FRAMEOS_FRAME_ACCESS_KEY": "local-access-key", + "FRAMEOS_NETWORK_CHECK": "false", + "FRAMEOS_WIFI_HOTSPOT": "disabled", + "FRAMEOS_SAVE_ASSETS": "true", + } + ) + + self.assertIn("Backend: not configured; FrameOS will run standalone.", result.stdout) + frame_json = self._installed_frame_json() + self.assertEqual(frame_json["name"], "Standalone Frame") + self.assertEqual(frame_json["device"], "waveshare.EPD_7in3e") + self.assertEqual(frame_json["width"], 800) + self.assertEqual(frame_json["height"], 480) + self.assertEqual(frame_json["rotate"], 90) + self.assertEqual(frame_json["interval"], 300.0) + self.assertEqual(frame_json["metricsInterval"], 60.0) + self.assertEqual(frame_json["timeZone"], "Europe/Brussels") + self.assertEqual(frame_json["frameAdminAuth"], { + "enabled": True, + "user": "admin", + "pass": "admin-secret", + }) + self.assertEqual(frame_json["agent"]["agentEnabled"], False) + self.assertEqual(frame_json["agent"]["agentRunCommands"], False) + self.assertEqual(frame_json["serverSendLogs"], False) + + systemctl_calls = self._stub_log("systemctl.log") + self.assertIn("enable frameos.service", systemctl_calls) + self.assertIn("restart frameos.service", systemctl_calls) + self.assertIn("disable --now frameos_agent.service", systemctl_calls) + self.assertNotIn("restart frameos_agent.service", systemctl_calls) + + checks = self._container_checks() + self.assertTrue(checks["frameos_service_installed"]) + self.assertFalse(checks["agent_service_installed"]) + + def test_existing_frame_json_defaults_enable_backend_agent(self) -> None: + existing_dir = self.out_dir / "srv" / "frameos" / "current" + existing_dir.mkdir(parents=True) + (existing_dir / "frame.json").write_text( + json.dumps( + { + "name": "Existing Frame", + "device": "web_only", + "width": 1024, + "height": 600, + "framePort": 8888, + "frameAccessKey": "existing-access", + "frameAdminAuth": { + "enabled": True, + "user": "owner", + "pass": "existing-admin-pass", + }, + "serverHost": "backend.example", + "serverPort": 9443, + "serverApiKey": "server-api-key", + "serverSendLogs": True, + "agent": { + "agentEnabled": True, + "agentRunCommands": True, + "agentSharedSecret": "agent-shared-secret", + }, + "network": { + "networkCheck": True, + "wifiHotspot": "bootOnly", + "wifiHotspotSsid": "Existing-Setup", + "wifiHotspotPassword": "existing-wifi-pass", + }, + "schedule": {"events": [{"id": "keep-me"}]}, + } + ) + + "\n", + encoding="utf-8", + ) + + result = self._run_setup({}) + + self.assertIn("Backend: backend.example:9443", result.stdout) + frame_json = self._installed_frame_json() + self.assertEqual(frame_json["name"], "Existing Frame") + self.assertEqual(frame_json["device"], "web_only") + self.assertEqual(frame_json["framePort"], 8888) + self.assertEqual(frame_json["frameAccessKey"], "existing-access") + self.assertEqual(frame_json["frameAdminAuth"]["pass"], "existing-admin-pass") + self.assertEqual(frame_json["serverHost"], "backend.example") + self.assertEqual(frame_json["serverPort"], 9443) + self.assertEqual(frame_json["serverApiKey"], "server-api-key") + self.assertEqual(frame_json["serverSendLogs"], True) + self.assertEqual(frame_json["agent"], { + "agentEnabled": True, + "agentRunCommands": True, + "agentSharedSecret": "agent-shared-secret", + }) + self.assertEqual(frame_json["network"]["wifiHotspot"], "bootOnly") + self.assertEqual(frame_json["schedule"], {"events": [{"id": "keep-me"}]}) + + systemctl_calls = self._stub_log("systemctl.log") + self.assertIn("enable frameos_agent.service", systemctl_calls) + self.assertIn("restart frameos_agent.service", systemctl_calls) + + checks = self._container_checks() + self.assertTrue(checks["frameos_service_installed"]) + self.assertTrue(checks["agent_service_installed"]) + + def test_device_menu_prints_real_newlines(self) -> None: + menu = subprocess.run( + [ + "docker", + "run", + "--rm", + "--volume", + f"{ROOT}:/repo:ro", + "--platform", + "linux/amd64", + IMAGE, + "/bin/sh", + "-lc", + "awk '/^copy_scene_payloads\\(\\) /{exit} {print}' /repo/scripts/frameos-setup.sh > /tmp/functions.sh " + "&& . /tmp/functions.sh " + "&& printf '\\n' | choose_device web_only >/tmp/device", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=90, + ) + if menu.returncode != 0: + self.fail( + "device menu container failed\n" + f"return code: {menu.returncode}\n" + f"stdout:\n{menu.stdout}\n" + f"stderr:\n{menu.stderr}\n" + ) + self.assertIn("Device choices:\n 1) web_only", menu.stderr) + self.assertIn("4) pimoroni.inky_impression_7_2025", menu.stderr) + self.assertIn("5) pimoroni.inky_impression_13_2025", menu.stderr) + self.assertIn("6) waveshare.EPD_7in3e", menu.stderr) + self.assertIn("7) waveshare.EPD_13in3e", menu.stderr) + self.assertIn("8) waveshare.EPD_7in5_V2", menu.stderr) + self.assertNotIn("\\nDevice choices", menu.stderr) + self.assertNotIn("custom device key\\nDevice", menu.stderr) + + def _write_fake_release_archive(self) -> None: + artifact_root = self.tmp_path / "artifact" / f"frameos-{VERSION}-{TARGET}" + artifact_root.mkdir(parents=True) + (artifact_root / "metadata.json").write_text(json.dumps({"slug": TARGET}) + "\n", encoding="utf-8") + (artifact_root / "drivers").mkdir() + (artifact_root / "drivers" / "driver-marker").write_text("driver\n", encoding="utf-8") + (artifact_root / "scenes").mkdir() + (artifact_root / "scenes" / "scene-marker").write_text("scene\n", encoding="utf-8") + (artifact_root / "vendor").mkdir() + (artifact_root / "vendor" / "vendor-marker").write_text("vendor\n", encoding="utf-8") + self._write_executable( + artifact_root / "frameos", + """#!/bin/sh + echo "$@" >> /tmp/out/frameos-binary.log + if [ "${1:-}" = "setup" ]; then + exit "${FRAMEOS_STUB_SETUP_EXIT:-0}" + fi + exit 0 + """, + ) + self._write_executable( + artifact_root / "frameos_agent", + """#!/bin/sh + echo "$@" >> /tmp/out/frameos-agent-binary.log + exit 0 + """, + ) + + release_version_dir = self.releases_dir / f"v{VERSION}" + release_version_dir.mkdir(parents=True) + archive_path = release_version_dir / f"frameos-{VERSION}-{TARGET}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(artifact_root, arcname=artifact_root.name) + + def _write_stubs(self) -> None: + stub_dir = self.out_dir / "stubbin" + stub_dir.mkdir(parents=True) + self._write_executable( + stub_dir / "curl", + """#!/bin/sh + destination="" + url="" + while [ "$#" -gt 0 ]; do + case "$1" in + -o) + destination="$2" + shift 2 + ;; + -*) + shift + ;; + *) + url="$1" + shift + ;; + esac + done + case "$url" in + file://*) cp "${url#file://}" "$destination" ;; + *) echo "unsupported test URL: $url" >&2; exit 1 ;; + esac + """, + ) + self._write_executable( + stub_dir / "apt-get", + """#!/bin/sh + echo "$@" >> /tmp/out/apt-get.log + exit 0 + """, + ) + self._write_executable( + stub_dir / "dpkg-query", + """#!/bin/sh + exit 1 + """, + ) + self._write_executable( + stub_dir / "systemctl", + """#!/bin/sh + echo "$@" >> /tmp/out/systemctl.log + exit 0 + """, + ) + + def _run_setup(self, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + merged_env = { + "FRAMEOS_RELEASE_VERSION": VERSION, + "FRAMEOS_RELEASE_BASE_URL": "file:///tmp/releases", + "FRAMEOS_DIR": "/tmp/out/srv/frameos", + "FRAMEOS_AGENT_DIR": "/tmp/out/srv/frameos/agent", + "FRAMEOS_ASSETS_DIR": "/tmp/out/srv/assets", + "FRAMEOS_NETWORK_CHECK_TIMEOUT_SECONDS": "30", + "FRAMEOS_NETWORK_CHECK_URL": "https://networkcheck.frameos.net/", + "FRAMEOS_WIFI_HOTSPOT_TIMEOUT_SECONDS": "300", + "FRAMEOS_MAX_HTTP_RESPONSE_BYTES": "67108864", + "FRAMEOS_DEBUG": "false", + "FRAMEOS_SCALING_MODE": "contain", + "FRAMEOS_IMAGE_ENGINE": "", + "FRAMEOS_FLIP": "", + **env, + } + env_args = [] + for key, value in merged_env.items(): + env_args.extend(["--env", f"{key}={value}"]) + + command = ( + "PATH=/tmp/out/stubbin:$PATH /repo/scripts/frameos-setup.sh " + "&& python3 - <<'PY'\n" + "import json, os\n" + "checks = {\n" + " 'frameos_service_installed': os.path.isfile('/etc/systemd/system/frameos.service'),\n" + " 'agent_service_installed': os.path.isfile('/etc/systemd/system/frameos_agent.service'),\n" + "}\n" + "open('/tmp/out/container-checks.json', 'w').write(json.dumps(checks))\n" + "PY" + ) + result = subprocess.run( + [ + "docker", + "run", + "--rm", + "--volume", + f"{ROOT}:/repo:ro", + "--volume", + f"{self.releases_dir}:/tmp/releases:ro", + "--volume", + f"{self.out_dir}:/tmp/out", + "--platform", + "linux/amd64", + *env_args, + IMAGE, + "/bin/sh", + "-lc", + command, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=90, + ) + if result.returncode != 0: + self.fail( + "setup container failed\n" + f"return code: {result.returncode}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}\n" + ) + return result + + def _installed_frame_json(self) -> dict: + releases = sorted((self.out_dir / "srv" / "frameos" / "releases").glob("release_setup_*")) + self.assertEqual(len(releases), 1) + return json.loads((releases[0] / "frame.json").read_text(encoding="utf-8")) + + def _container_checks(self) -> dict[str, bool]: + return json.loads((self.out_dir / "container-checks.json").read_text(encoding="utf-8")) + + def _stub_log(self, name: str) -> str: + path = self.out_dir / name + return path.read_text(encoding="utf-8") if path.exists() else "" + + def _restore_tmp_permissions(self) -> None: + if not shutil.which("docker") or not hasattr(self, "tmp_path"): + return + subprocess.run( + [ + "docker", + "run", + "--rm", + "--volume", + f"{self.tmp_path}:/tmp/frameos-setup-test", + "--platform", + "linux/amd64", + "--env", + f"HOST_UID={os.getuid()}", + "--env", + f"HOST_GID={os.getgid()}", + IMAGE, + "/bin/sh", + "-lc", + 'chown -R "$HOST_UID:$HOST_GID" /tmp/frameos-setup-test || true', + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=90, + ) + + @staticmethod + def _write_executable(path: Path, contents: str) -> None: + path.write_text(textwrap.dedent(contents).lstrip(), encoding="utf-8") + path.chmod(0o755) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/prebuilt-deps/Dockerfile.quickjs b/tools/prebuilt-deps/Dockerfile.quickjs index d146c0196..a50110030 100644 --- a/tools/prebuilt-deps/Dockerfile.quickjs +++ b/tools/prebuilt-deps/Dockerfile.quickjs @@ -10,8 +10,8 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] ARG TARGETPLATFORM ARG BUILDPLATFORM -ARG QUICKJS_VERSION=2025-04-26 -ARG QUICKJS_SHA256=2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf +ARG QUICKJS_VERSION=2026-06-04 +ARG QUICKJS_SHA256=b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ diff --git a/tools/prebuilt-deps/README.md b/tools/prebuilt-deps/README.md index 21ff56211..6c010b0f2 100644 --- a/tools/prebuilt-deps/README.md +++ b/tools/prebuilt-deps/README.md @@ -33,10 +33,10 @@ directories so you can keep several revisions side-by-side, e.g.: metadata.json nim-2.2.4/bin/* nim-2.2.4/lib/* -quickjs-2025-04-26/include/quickjs/*.h -quickjs-2025-04-26/lib/libquickjs.a +quickjs-2026-06-04/include/quickjs/*.h +quickjs-2026-06-04/lib/libquickjs.a nim-2.2.4/.build-info -quickjs-2025-04-26/.build-info +quickjs-2026-06-04/.build-info ``` You can upload the entire folder as a tarball to your cache server. @@ -58,18 +58,20 @@ QuickJS from source when no published component matches. Override the versions with environment variables when invoking the script: ```bash -NIM_VERSION=2.2.4 QUICKJS_VERSION=2025-04-26 ./tools/prebuilt-deps/build.sh +NIM_VERSION=2.2.4 QUICKJS_VERSION=2026-06-04 ./tools/prebuilt-deps/build.sh ``` ## Cloudflare R2 sync helper Use `tools/prebuilt-deps/r2_sync.py` to mirror the build outputs to the `frameos-archive` Cloudflare R2 bucket. The helper uses the same target -matrix as `build.sh`, bundles each target folder as a `tar.gz` archive and -stores it under `prebuilt-deps/<target>/<versions>/` alongside a -`metadata.json`. A manifest file (`prebuilt-deps/manifest.json`) keeps -track of every target so the script can discover and download the latest -builds automatically. +matrix as `build.sh`, bundles each component into a versioned `tar.gz` +archive, and stores it under keys like +`prebuilt-deps/<target>/quickjs-2026-06-04.tar.gz`. A manifest file +(`prebuilt-deps/manifest.json`) keeps track of the current build for each +target so the scripts can discover and download the right artifacts +automatically. Older archives remain in R2 under their versioned object keys, +but they are not retained as duplicate manifest entries. ### Prerequisites diff --git a/tools/prebuilt-deps/build.sh b/tools/prebuilt-deps/build.sh index e5a712c49..61a940a03 100755 --- a/tools/prebuilt-deps/build.sh +++ b/tools/prebuilt-deps/build.sh @@ -5,8 +5,8 @@ ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" OUTPUT_BASE="${ROOT_DIR}/build/prebuilt-deps" NIM_VERSION="${NIM_VERSION:-2.2.4}" -QUICKJS_VERSION="${QUICKJS_VERSION:-2025-04-26}" -QUICKJS_SHA256="${QUICKJS_SHA256:-2f20074c25166ef6f781f381c50d57b502cb85d470d639abccebbef7954c83bf}" +QUICKJS_VERSION="${QUICKJS_VERSION:-2026-06-04}" +QUICKJS_SHA256="${QUICKJS_SHA256:-b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a}" declare -a COMPONENTS=("nim" "quickjs") diff --git a/tools/prebuilt-deps/manifest.json b/tools/prebuilt-deps/manifest.json index 81d11c583..b693adef6 100644 --- a/tools/prebuilt-deps/manifest.json +++ b/tools/prebuilt-deps/manifest.json @@ -3,256 +3,222 @@ { "target": "ubuntu-24.04-arm64", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" - }, - "object_key": null, - "updated_at": "2026-05-09T05:32:56.595077+00:00", - "component_keys": { - "nim": "prebuilt-deps/ubuntu-24.04-arm64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/ubuntu-24.04-arm64/quickjs-2025-04-26.tar.gz" - }, - "component_md5sums": { - "quickjs": "c25b42ddcb3008a4892a4b6c97770e58", - "nim": "1f00e18e5d873394e41e238ee762d26e" - } - }, - { - "target": "ubuntu-22.04-arm64", - "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:33:03.831436+00:00", + "updated_at": "2026-06-05T14:25:01.373569+00:00", "component_keys": { - "nim": "prebuilt-deps/ubuntu-22.04-arm64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/ubuntu-22.04-arm64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/ubuntu-24.04-arm64/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/ubuntu-24.04-arm64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "6d87c2076e3eaea00aa01a73f87095b5", - "nim": "87cdbb8b1c75329a80260f6ac2303432" + "nim": "611bf63cea55cbc9238ab46e6c2f3db9", + "quickjs": "8ce8040a452606c154abfbc2985a2c6b" } }, { "target": "debian-bookworm-armhf", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:33:24.417199+00:00", + "updated_at": "2026-06-05T14:25:32.361237+00:00", "component_keys": { - "nim": "prebuilt-deps/debian-bookworm-armhf/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-bookworm-armhf/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-bookworm-armhf/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-bookworm-armhf/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "04ac9f039f74e949e19b9e37d09903b5", - "nim": "21f3c403fbafbb545607e4d72f0b4155" + "nim": "3147bf9b2d957bce9c6082f8ffa9ea4c", + "quickjs": "17f268f65e5cfbccfd071a04bf81dafe" } }, { "target": "debian-bookworm-arm64", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:32:26.555804+00:00", + "updated_at": "2026-06-05T14:25:45.195316+00:00", "component_keys": { - "nim": "prebuilt-deps/debian-bookworm-arm64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-bookworm-arm64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-bookworm-arm64/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-bookworm-arm64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "01c94c2e0dcafe67136d520d37198a1f", - "nim": "9ef2d25a490de7ab0f2e0aa789e41f60" + "nim": "a8c5f3403483458967a6b54c13900439", + "quickjs": "b2acd4ec30a4c4f0d3b2db485ee7e322" } }, { "target": "debian-trixie-arm64", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:33:20.150676+00:00", + "updated_at": "2026-06-05T14:26:37.911185+00:00", "component_keys": { - "nim": "prebuilt-deps/debian-trixie-arm64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-trixie-arm64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-trixie-arm64/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-trixie-arm64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "a9bf56d3059d260922cf025e2766fe69", - "nim": "c797a998d577170a849c724732eeba5c" + "nim": "511f6b399883434231c49be93918b7c1", + "quickjs": "0ece7536e0f185bb39606417f931339d" } }, { "target": "debian-trixie-armhf", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" - }, - "object_key": null, - "updated_at": "2026-05-09T05:32:40.350034+00:00", - "component_keys": { - "nim": "prebuilt-deps/debian-trixie-armhf/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-trixie-armhf/quickjs-2025-04-26.tar.gz" - }, - "component_md5sums": { - "quickjs": "6f387fb355ce71ae14df2e87ee2faa58", - "nim": "eb66333c6825c066015c2a400b5b262e" - } - }, - { - "target": "ubuntu-22.04-amd64", - "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:32:35.742094+00:00", + "updated_at": "2026-06-05T14:26:48.294740+00:00", "component_keys": { - "nim": "prebuilt-deps/ubuntu-22.04-amd64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/ubuntu-22.04-amd64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-trixie-armhf/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-trixie-armhf/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "59a40c74cea17e28d3c129ec88434613", - "nim": "96248dd0df9b9789c4808ef7b6f43f67" + "nim": "3114e515ad79e97b4fd69674b2a856cd", + "quickjs": "084e2c8398f5378d5cfd5495170837de" } }, { "target": "ubuntu-24.04-amd64", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:33:13.008405+00:00", + "updated_at": "2026-06-05T14:26:23.588608+00:00", "component_keys": { - "nim": "prebuilt-deps/ubuntu-24.04-amd64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/ubuntu-24.04-amd64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/ubuntu-24.04-amd64/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/ubuntu-24.04-amd64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "6571376cb878a9f28813c22bed0d6c97", - "nim": "2f08c86a5e8196ad31acdb2fbece2273" + "nim": "7d7398aa21e2753569058e7df208aea6", + "quickjs": "ef5de5d4f3a15e8227eb13cc9bb71588" } }, { "target": "debian-trixie-amd64", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:32:49.687542+00:00", + "updated_at": "2026-06-05T14:24:51.996140+00:00", "component_keys": { - "nim": "prebuilt-deps/debian-trixie-amd64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-trixie-amd64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-trixie-amd64/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-trixie-amd64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "985d8b4423677759a7a997cc342fef31", - "nim": "280794b4ce4c8f2c5164d40cce311e91" + "nim": "e08780b54a5acd7ef55ae436d5f6d5db", + "quickjs": "31b3d2808b2c9035c075d6699b457583" } }, { "target": "debian-bookworm-amd64", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:32:19.714701+00:00", + "updated_at": "2026-06-05T14:27:05.180183+00:00", "component_keys": { - "nim": "prebuilt-deps/debian-bookworm-amd64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-bookworm-amd64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-bookworm-amd64/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-bookworm-amd64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "e32a7017603f170a8ae572ed85c9972c", - "nim": "9eb4bf7a662a8a8569f5dd2a4498d79f" + "nim": "10d777179b91f6074baddaaf94443923", + "quickjs": "32181925b58013630fba7099c9b95040" } }, { "target": "debian-bullseye-arm64", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-08T23:24:33.131142+00:00", + "updated_at": "2026-06-05T14:25:24.408667+00:00", "component_keys": { - "nim": "prebuilt-deps/debian-bullseye-arm64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-bullseye-arm64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-bullseye-arm64/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-bullseye-arm64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "5e9138c9430b33de5c2cbd1f7970ab2c", - "nim": "3c31bf03e10ea8d0261eb6dcf1a53323" + "nim": "32de23457df82b1b2d325fc1b1303439", + "quickjs": "7e8ad903544772118eb58fe709355626" } }, { "target": "debian-bullseye-armhf", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-08T23:24:39.639945+00:00", + "updated_at": "2026-06-05T14:25:55.893739+00:00", "component_keys": { - "nim": "prebuilt-deps/debian-bullseye-armhf/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-bullseye-armhf/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-bullseye-armhf/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-bullseye-armhf/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "30f18f912bc97d320a8470e86c27456e", - "nim": "1824a4733c59f3e4f5e293962936590c" + "nim": "11d5be57458f15b24d07583546eec672", + "quickjs": "0b8493481f6163746914f90978400b96" } }, { "target": "debian-bullseye-amd64", "versions": { - "nim": "2.2.10", - "quickjs": "2025-04-26" + "nim": "2.2.4", + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-09T05:33:33.493982+00:00", + "updated_at": "2026-06-05T14:27:20.404493+00:00", "component_keys": { - "nim": "prebuilt-deps/debian-bullseye-amd64/nim-2.2.10.tar.gz", - "quickjs": "prebuilt-deps/debian-bullseye-amd64/quickjs-2025-04-26.tar.gz" + "nim": "prebuilt-deps/debian-bullseye-amd64/nim-2.2.4.tar.gz", + "quickjs": "prebuilt-deps/debian-bullseye-amd64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "quickjs": "91ee03763febcf297345f199e95a24c0", - "nim": "38d2141595e62bee8f59350d7fdbf306" + "nim": "9cd70912a65d9ea2d57501fd9fba53c3", + "quickjs": "53b7f0cdddd107617728c846b7059615" } }, { "target": "ubuntu-26.04-amd64", "versions": { "nim": "2.2.4", - "quickjs": "2025-04-26" + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-28T20:56:53.942401+00:00", + "updated_at": "2026-06-05T14:25:14.126180+00:00", "component_keys": { "nim": "prebuilt-deps/ubuntu-26.04-amd64/nim-2.2.4.tar.gz", - "quickjs": "prebuilt-deps/ubuntu-26.04-amd64/quickjs-2025-04-26.tar.gz" + "quickjs": "prebuilt-deps/ubuntu-26.04-amd64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "nim": "f746a6e6697be540680f3d924baa8129", - "quickjs": "24f7f295403946bb6f26beefeac410f1" + "nim": "40882f9d7c2bc71818b972d820a0f200", + "quickjs": "f30ab0296041f14a95eec455f8052c5a" } }, { "target": "ubuntu-26.04-arm64", "versions": { "nim": "2.2.4", - "quickjs": "2025-04-26" + "quickjs": "2026-06-04" }, "object_key": null, - "updated_at": "2026-05-28T20:57:09.405653+00:00", + "updated_at": "2026-06-05T14:26:08.842314+00:00", "component_keys": { "nim": "prebuilt-deps/ubuntu-26.04-arm64/nim-2.2.4.tar.gz", - "quickjs": "prebuilt-deps/ubuntu-26.04-arm64/quickjs-2025-04-26.tar.gz" + "quickjs": "prebuilt-deps/ubuntu-26.04-arm64/quickjs-2026-06-04.tar.gz" }, "component_md5sums": { - "nim": "1b6635edd13bc164d224ee5fe031913a", - "quickjs": "f7645f0d938b7d950cc9f7f914e7b0b0" + "nim": "eef2e35b2d5449a224d21261d7eafbd5", + "quickjs": "f650e3c456995d519185f2e3352d87b6" } } ]