From 18bd9380b563eff59716a9623f0966744eb747f5 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:34:28 -0700 Subject: [PATCH] feat(BROWSER-01): add compiled worker host and browser example --- .github/workflows/ci.yaml | 24 ++ IMPLEMENTATION_PLAN.md | 7 + README.md | 9 +- docs/compiler-async.md | 10 +- docs/compiler-browser.md | 145 +++++++++++ docs/compiler-language.md | 4 + docs/compiler-modules.md | 4 +- examples/browser_text_metrics.py | 92 +++++++ pyproject.toml | 6 + scripts/compiler_browser_smoke.py | 255 +++++++++++++++++++ src/JavaScript/_browser/browser-host.mjs | 165 ++++++++++++ src/JavaScript/_browser/browser-protocol.mjs | 111 ++++++++ src/JavaScript/_browser/browser-worker.mjs | 117 +++++++++ src/JavaScript/_compiler_runtime.py | 66 +++++ src/JavaScript/browser.py | 60 +++++ src/JavaScript/compiler.py | 24 +- src/JavaScript/emitter.py | 6 +- src/JavaScript/modules.py | 26 +- tests/fixtures/browser_protocol.mjs | 38 +++ tests/test_compiler_browser.py | 91 +++++++ uv.lock | 95 +++++++ 21 files changed, 1333 insertions(+), 22 deletions(-) create mode 100644 docs/compiler-browser.md create mode 100644 examples/browser_text_metrics.py create mode 100644 scripts/compiler_browser_smoke.py create mode 100644 src/JavaScript/_browser/browser-host.mjs create mode 100644 src/JavaScript/_browser/browser-protocol.mjs create mode 100644 src/JavaScript/_browser/browser-worker.mjs create mode 100644 src/JavaScript/browser.py create mode 100644 tests/fixtures/browser_protocol.mjs create mode 100644 tests/test_compiler_browser.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fbda96e..68dcbee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,3 +43,27 @@ jobs: run: python scripts/install_distribution.py sdist - name: Test installed source distribution run: python -m unittest discover -s tests -v + + browser: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + UV_PYTHON: '3.14.7' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: '0.12.19' + enable-cache: true + - run: uv python install + - run: uv sync --locked --group browser-tests + - run: uv run --no-sync python -m playwright install --with-deps chromium + - run: uv run --no-sync python scripts/compiler_browser_smoke.py --report build/compiler-browser-report.json + - uses: actions/upload-artifact@v5 + if: always() + with: + name: compiler-browser-report + path: build/compiler-browser-report.json + if-no-files-found: error diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 64fd095..9be7779 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -456,3 +456,10 @@ coroutines, Promise conversion/chaining, synchronous callbacks, source-located rejections and AbortSignal context cancellation. JavaScript microtask timing and CPython scheduling differences are documented. Fetch/DOM/worker transport remains BROWSER-01; no new async runtime dependency or published artifact is introduced. + +The [compiled browser target](docs/compiler-browser.md) implements BROWSER-01 +with an explicit host namespace, module worker, named text/events, callback +ownership, native GET text Fetch, source errors and stop/restart. A runnable +text_metrics example and real-browser tests cover local/offline-after-load +computation and transport/resource failures. WEB-03 owns verified PWA assembly +and offline reload; QA-03 extends cross-target parity. No package was released. diff --git a/README.md b/README.md index e70a32a..a6b74cb 100644 --- a/README.md +++ b/README.md @@ -110,5 +110,10 @@ ES modules for the approved single-module Python profile. This API is not part of the published 0.1.0 distribution. The explicit [async compiler profile](docs/compiler-async.md) adds lazy coroutines, -awaited Promise values, callbacks and context cancellation. Browser host adapters -remain separately tracked. +awaited Promise values, callbacks and context cancellation. The [browser target](docs/compiler-browser.md) adds named DOM controls, callbacks, +native Fetch text and worker stop/restart. + +Build the local [text_metrics browser example](docs/compiler-browser.md#run-the-example) +with `uv run python examples/browser_text_metrics.py --output build/browser-text-metrics`. +It uses our emitted modules and helpers in a worker; source parsing remains the +separate independent-interpreter milestone. diff --git a/docs/compiler-async.md b/docs/compiler-async.md index 4471b7f..9911d72 100644 --- a/docs/compiler-async.md +++ b/docs/compiler-async.md @@ -79,10 +79,10 @@ AbortSignal delivery cannot interrupt busy synchronous execution on its own. In a worker, the trusted main thread must retain worker termination as the stop path. The [host protocol](https://github.com/outside-labs/pocket-python/blob/main/docs/browser-host-protocol.md) uses session/request IDs, serialized tagged values, exactly one terminal result -and CANCELLED/TIMEOUT replies. A future transport adapter must wrap a pending -host result as an adopted Promise, forward cancellation to its request, discard -stale replies and release handles. Raw Promise/Event/DOM objects do not cross -that transport. HOST-01 specifies that adapter; this task does not implement it. +and CANCELLED/TIMEOUT replies. The [browser profile](compiler-browser.md) wraps a pending +host result as an adopted Promise, forwards cancellation to its request, discards +stale replies and releases handles. Raw Promise/Event/DOM objects do not cross +that transport. HOST-01 defines the shared contract; the async-only profile does not itself provide that adapter. ## Fetch boundary @@ -90,7 +90,7 @@ CPython code continues to use the published `fetch_api.fetch` implementation re-exported by `JavaScript`, including its thread-backed Promise and HTTP types. Those implementations are not copied into compiled artifacts. `fetch`, Request, Response, streaming bodies and response JSON methods remain rejected by this -compiler profile. BROWSER-01 adds the separately granted `fetch/1` text operation, +compiler profile. The [browser profile](compiler-browser.md) adds the separately granted `fetch/1` text operation, lowering to browser-native Fetch through the trusted host with AbortSignal, origin/CORS, URL, credential, redirect, size and deadline rules. Its first text metrics UI requires no network access. Streaming, async iterables and standalone diff --git a/docs/compiler-browser.md b/docs/compiler-browser.md new file mode 100644 index 0000000..51ba3fd --- /dev/null +++ b/docs/compiler-browser.md @@ -0,0 +1,145 @@ +# Compiled browser target + +The development checkout provides `JavaScript.browser.compile_browser`. It +compiles verified [local source modules](compiler-modules.md), enables the +[async profile](compiler-async.md), and records the complete local worker/host +asset closure. It is not included in the published 0.1.0 distribution. + +The target is `python-browser-modules-host-async/1`, compiler ABI +`python-browser-host-esm/1`, using host protocol `pocket-python-host` version 1. +Python computation executes as our emitted JavaScript and compatibility helpers +inside a module worker. No interpreter, server execution or alternate engine is +loaded. New-source parsing remains the independent interpreter's later milestone. + +## Run the example + +From this checkout: + +```sh +uv sync --locked +uv run --no-sync python examples/browser_text_metrics.py --output build/browser-text-metrics +python -m http.server 8000 --bind 127.0.0.1 --directory build/browser-text-metrics +``` + +Open `http://127.0.0.1:8000/`. Run counts Unicode characters, ASCII-separated +words and LF-based lines. Stop terminates the worker; Restart creates a fresh +session. The example enables Run after its callback is registered. Computation +works offline after loading. Offline reload, service-worker caching and PWA +assembly are WEB-03, not part of this example builder. + +The builder reads the approved text_metrics corpus, emits selected local modules, +source maps/notices, the host/worker/protocol modules, a manifest and a small HTML +entry. It requires a new output directory and preserves existing outputs. +The same computation can be imported under CPython in a separate server entry; +client entrypoints and host adapters are explicit. E2E-01 owns the complete +server/client packaging demonstration. + +## Python namespace and entry + +Only named imports from the compiler's `JavaScript.browser` namespace are +adopted. They belong in the client entry; they are not CPython DOM functions. + +```python +from JavaScript.browser import on_click, read_text, write_text, fetch_text +from text_metrics import metrics + +async def display(text): + print(metrics(text)) + +async def main(): + await on_click("run", display) +``` + +| Function | Adopted behavior | +| --- | --- | +| `read_text(control)` | Await the current text/value of a named granted control; return Python str. | +| `write_text(control, text)` | Await replacement with plain text; return None. | +| `on_click(control, callback)` | Register a known compiled synchronous/async function taking one snapshot argument; return an opaque subscription handle. | +| `unsubscribe(subscription)` | Remove the listener and release its callback; return None. Reuse raises STALE_HANDLE. | +| `fetch_text(url)` | Await an allowed native GET and return `(status, final_url, text)` as a Python tuple. HTTP non-2xx statuses remain successful responses. | + +Every call returns an adopted Promise that source code awaits. Aliases retain +binding identity. Str arguments stay explicit, and status integers remain BigInt. +There is no implicit JSON/dict/Number conversion. Imports must be unconditional; +module initialization contains imports, definitions, immutable constants, pass or +docstrings. Browser entry requires a directly defined async `main()` without +required arguments. This prevents host calls before the ready handshake. + +The original JavaScript Array/Promise registry keeps its own contract. +`from JavaScript import fetch`, arbitrary globals/attributes, selectors, innerHTML, +DOM nodes, POST/options/headers, streaming and response JSON APIs remain outside +this language profile. CPython uses the published `fetch_api` companion through +its server adapter; that implementation is not embedded in browser output. + +## Main-thread configuration + +Import `mountCompiledApplication` from the generated `browser-host.mjs`. Supply +the verified application's manifest SHA-256, profile `compiled-javascript`/1, +entrypoint `app.mjs`, and engine null. Bind grant names to trusted DOM Elements, +provide a text output Element, and optionally map button names to input snapshot +controls (otherwise the callback receives an empty str). The example supplies a complete configuration. Each required capability +must be granted; source code cannot widen controls or URLs. + +The adapter supports output/1, dom-text/1, dom-events/1, handles/1 and the adopted +GET text subset of fetch/1. Files/persistence and unlisted capabilities fail +initialization. No arbitrary host filesystem or native object is exposed. +Fetch grants use exact normalized HTTP(S) URLs without embedded credentials or +fragments. Native Fetch preserves origin/CORS checks, omits credentials, rejects +redirects and enforces a bounded strict-UTF-8 response and deadline. Relative +requests are normalized against the page URL before the exact grant check. + +`ready` confirms protocol initialization, before asynchronous application setup +finishes. `main()` then registers handlers. `stop()` aborts pending host work, +removes listeners and terminates the worker. `restart()` stops the current +session and returns a fresh controller. `stats()` exposes lifecycle and resource +counts for integration tests. Optional `onError` receives bounded plain error +metadata; the default output uses a text node. Application code never supplies +HTML or arbitrary host properties to these operations. + +## Ordering, lifetime and limits + +The adapter implements the [HOST-01 envelope and lifecycle](https://github.com/outside-labs/pocket-python/blob/main/docs/browser-host-protocol.md) +for this adopted surface: fresh 128-bit sessions, independent monotonic sequence +counters, unique request IDs, bounded pending work and terminal replies. Stale +sessions do not advance counters. Malformed active messages/replays close the +session. Valid operation failures reject the source await with an original source +location; an unhandled main/callback exception closes the application. + +Events are serialized in a bounded queue. Output is ordered and bounded. A +subscription owns its main-thread listener and worker callback until unsubscribe +or teardown; already queued events for released subscriptions are discarded. +Canceled/timed-out request slots remain bounded until terminal reply or teardown. +Cancellation cannot undo a committed DOM/output effect. Busy synchronous code +requires worker termination; the main thread remains responsive. Worker placement +provides responsiveness isolation, not a general hostile-code security sandbox. + +Defaults match HOST-01: 1 MiB messages/responses, value depth 64, 32 pending +requests/subscriptions/events, 65,536 output code points and ten seconds per +operation. Trusted configuration may lower limits. Oversized results return +QUOTA. Stored Python surrogate points survive engine transport; projecting to +DOM UTF-16 and reading back may combine an explicit surrogate pair. Error +metadata retains its source span; full call-frame traceback construction is +separately staged. + +The manifest records source/output hashes, maps, notices, required capabilities +and every static module/worker edge. Initialization echoes identity; it does not +independently authenticate files. Deployment/assembly must verify the recorded +assets against a trusted manifest before use. WEB-03 implements that application +profile and cache/publication boundary. + +## Verification + +```sh +uv sync --locked --group browser-tests +uv run --no-sync python -m playwright install chromium +uv run --no-sync python scripts/compiler_browser_smoke.py --report build/compiler-browser-report.json +``` + +Use `--channel chrome` for an installed Chrome. Playwright 1.64.0 is a declared +test-only group; existing runtime dependency versions are retained. CI tests +installed wheel/source compiler APIs on Linux/macOS/Windows and runs the local +browser fixture on Linux. The fixture covers CPython/client parity, loaded-worker +offline computation, Fetch status/CORS/credentials/grants/redirect/size/deadline, +source errors, forty callback allocation/release cycles, repeated restart, +pending Fetch cancellation and busy-worker stop. The user example is also run +under a URL subdirectory. Reports record the actual browser and target identity. diff --git a/docs/compiler-language.md b/docs/compiler-language.md index 950aed5..81ac27b 100644 --- a/docs/compiler-language.md +++ b/docs/compiler-language.md @@ -104,3 +104,7 @@ This build-time application compiler does not execute newly entered source in the browser. That is a separate independent-interpreter milestone. Shared fixtures/host protocols do not make the two engines' IR, bytecode or value models interchangeable. + +The [compiled browser profile](compiler-browser.md) implements the adopted +named controls, callbacks, native Fetch text and worker lifecycle. The original +single-module language profile remains separately identified. diff --git a/docs/compiler-modules.md b/docs/compiler-modules.md index bbaabc7..f2ade7d 100644 --- a/docs/compiler-modules.md +++ b/docs/compiler-modules.md @@ -87,5 +87,5 @@ The text_metrics parity fixture uses its supported metrics module and the reduced helper containing `character_count`. The original helper's unused slice utility remains unsupported and causes rejection if supplied unchanged. This compiler does not perform symbol extraction or silently skip unsupported code -inside a selected module. Browser DOM/Fetch integration and runtime parsing of -new source remain separate tasks. +inside a selected module. The [browser profile](compiler-browser.md) adds DOM/Fetch integration. Runtime +parsing of new source remains an independent-interpreter task. diff --git a/examples/browser_text_metrics.py b/examples/browser_text_metrics.py new file mode 100644 index 0000000..4c3c473 --- /dev/null +++ b/examples/browser_text_metrics.py @@ -0,0 +1,92 @@ +"""Build a local text-metrics browser application from the approved source corpus.""" + +import argparse +import hashlib +import json +from pathlib import Path + +from JavaScript.browser import compile_browser +from JavaScript.modules import SourceModule + + +ROOT = Path(__file__).resolve().parents[1] +APP = '''from JavaScript.browser import on_click +from text_metrics import metrics +async def display(text): + print(metrics(text)) +async def main(): + await on_click("run", display) +''' + + +def build(output): + corpus = json.loads((ROOT / 'tests/fixtures/compiler_contract.json').read_text(encoding='utf-8')) + reference = next(case['source'] for case in corpus['cases'] if case['id'] == 'text-metrics-single-module') + computation = reference[:reference.index('\nprint(metrics(')] + artifact = compile_browser((SourceModule.from_text('app', APP), SourceModule.from_text('text_metrics', computation)), entrypoint='app') + identity = {'manifest_sha256': hashlib.sha256(artifact.manifest_bytes).hexdigest(), 'profile': 'compiled-javascript', + 'profile_version': 1, 'entrypoint': 'app.mjs', 'engine': None} + html = ''' +Text metrics

Text metrics

Count characters, words and lines locally in your browser.

+ + +

Characters, words, lines

+'''.replace('APPLICATION', json.dumps(identity)) + files = {item.path: item.data for item in artifact.files} + files.update({'COMPILED-MANIFEST.json': artifact.manifest_bytes, 'index.html': html.encode('utf-8')}) + output = Path(output) + if output.exists() or output.is_symlink(): + raise ValueError('Choose a new output directory.') + output.mkdir(parents=True) + written = [] + try: + for name, data in files.items(): + path = output / name + with path.open('xb') as stream: + written.append(path) + stream.write(data) + except BaseException: + for path in reversed(written): + path.unlink(missing_ok=True) + try: + output.rmdir() + except OSError: + pass + raise + return artifact + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + try: + build(args.output) + except ValueError as error: + parser.error(str(error)) + print('Created ' + str(args.output)) + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml index 7bd129b..9e4813c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,10 +33,16 @@ module-name = "JavaScript" source-include = [ "tests/test_*.py", "tests/fixtures/conformance.mjs", + "tests/fixtures/browser_protocol.mjs", "tests/fixtures/compiler_contract.json", "docs/compatibility.md", "docs/compiler*.md", "scripts/install_distribution.py", + "scripts/compiler_browser_smoke.py", + "examples/browser_text_metrics.py", "CHANGELOG.md", "RELEASING.md", ] + +[dependency-groups] +browser-tests = ["playwright==1.64.0"] diff --git a/scripts/compiler_browser_smoke.py b/scripts/compiler_browser_smoke.py new file mode 100644 index 0000000..2c244fa --- /dev/null +++ b/scripts/compiler_browser_smoke.py @@ -0,0 +1,255 @@ +"""Exercise the compiled worker/host target using local assets and HTTP fixtures.""" + +import argparse +from functools import partial +import hashlib +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from threading import Thread +import time + +from playwright.sync_api import sync_playwright + +from JavaScript.browser import compile_browser +from JavaScript.modules import SourceModule + + +ROOT = Path(__file__).resolve().parents[1] +APP = '''from JavaScript.browser import read_text, write_text, on_click, unsubscribe, fetch_text +from text_metrics import metrics +async def run(text): + current = await read_text("input") + if current != text: + raise TypeError("input changed during this run") + print(metrics(text)) +async def load(text): + response = await fetch_text(await read_text("url")) + print(response[0], response[2]) +async def clear(text): + await write_text("result", "") +async def fail(text): + return 1 // 0 +async def busy(text): + await write_text("status", "busy") + total = 0 + for first in text: + for second in text: + for third in text: + total = total + 1 + await write_text("status", "completed") +async def main(): + for ignored in (1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40): + temporary = await on_click("unused", run) + await unsubscribe(temporary) + await on_click("run", run) + await on_click("fetch", load) + await on_click("clear", clear) + await on_click("fail", fail) + await on_click("busy", busy) +''' + + +class Handler(SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + self.server.observations.append({'path': self.path, 'cookie': self.headers.get('Cookie')}) + if self.path in {'/fixture.txt', '/large.txt', '/slow.txt', '/missing.txt', '/denied.txt', '/redirect'}: + if self.path == '/slow.txt': + time.sleep(3) + if self.path == '/redirect': + self.send_response(302) + self.send_header('Location', '/fixture.txt') + self.end_headers() + return + body = b'x' * 128 if self.path == '/large.txt' else b'missing' if self.path == '/missing.txt' else b'local fixture' + self.send_response(404 if self.path == '/missing.txt' else 200) + self.send_header('Content-Type', 'text/plain; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + return + super().do_GET() + + +def sources(): + corpus = json.loads((ROOT / 'tests/fixtures/compiler_contract.json').read_text(encoding='utf-8')) + reference = next(case['source'] for case in corpus['cases'] if case['id'] == 'text-metrics-single-module') + metrics = reference[reference.index('SEPARATORS ='):reference.index('\nprint(metrics(')] + metrics = 'from .helpers import character_count\n' + metrics + return (SourceModule.from_text('app', APP), + SourceModule.from_text('text_metrics', 'from .metrics import metrics', is_package=True), + SourceModule.from_text('text_metrics.metrics', metrics), + SourceModule.from_text('text_metrics.helpers', 'def character_count(text):\n return len(text)')) + + +def smoke(channel=None): + artifact = compile_browser(sources(), entrypoint='app') + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for item in artifact.files: + (root / item.path).parent.mkdir(parents=True, exist_ok=True) + (root / item.path).write_bytes(item.data) + subprocess.run([sys.executable, str(ROOT / 'examples/browser_text_metrics.py'), '--output', str(root / 'example')], check=True, capture_output=True, text=True, timeout=15) + for item in artifact.manifest['files']: + assert hashlib.sha256((root / item['path']).read_bytes()).hexdigest() == item['sha256'] + servers, threads = [], [] + for _ in range(2): + server = ThreadingHTTPServer(('127.0.0.1', 0), partial(Handler, directory=str(root))) + server.observations = [] + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + servers.append(server) + threads.append(thread) + origin = f'http://127.0.0.1:{servers[0].server_port}' + cross = f'http://127.0.0.1:{servers[1].server_port}' + app = {'manifest_sha256': hashlib.sha256(artifact.manifest_bytes).hexdigest(), 'profile': 'compiled-javascript', + 'profile_version': 1, 'entrypoint': 'app.mjs', 'engine': None} + urls = [origin + path for path in ['/fixture.txt', '/large.txt', '/redirect', '/slow.txt', '/missing.txt']] + [cross + '/fixture.txt'] + html = '''Text metrics + + +

+'''.replace('APPLICATION', json.dumps(app)).replace('URLS', json.dumps(urls)) + (root / 'index.html').write_text(html, encoding='utf-8') + checks = [] + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel=channel) + try: + context = browser.new_context() + page = context.new_page() + page.set_default_timeout(10000) + page.goto(origin) + page.wait_for_function('window.controller?.stats().subscriptions===5') + assert page.evaluate('window.failures.length') == 0 + checks.append('callback-release-and-ready') + values = ['', 'hello world', 'A😀 é\nline two', 'a\u00a0b'] + source = next(item.data.decode() for item in sources() if item.name == 'text_metrics.metrics') + reference = (next(item.data.decode() for item in sources() if item.name == 'text_metrics.helpers') + '\n' + + source.replace('from .helpers import character_count\n', '') + '\nimport json\nprint(json.dumps([str(metrics(value)) for value in ' + repr(values) + ']))') + oracle = json.loads(subprocess.check_output([sys.executable, '-I', '-S', '-X', 'utf8', '-c', reference], text=True)) + for value, expected in zip(values, oracle): + page.click('#clear') + page.wait_for_function('document.getElementById("result").textContent===""') + page.fill('#input', value) + page.click('#run') + page.wait_for_function('expected=>document.getElementById("result").textContent===expected', arg=expected + '\n') + checks.append('cpython-client-metrics-parity') + context.set_offline(True) + page.fill('#input', 'Offline😀') + page.click('#clear') + page.wait_for_function('document.getElementById("result").textContent===""') + page.click('#run') + page.wait_for_function('document.getElementById("result").textContent==="(8, 1, 1)\\n"') + context.set_offline(False) + checks.append('loaded-worker-offline-computation') + page.evaluate("document.cookie='fixture_cookie=present; SameSite=Lax'") + page.click('#clear');page.wait_for_function('document.getElementById("result").textContent===""') + page.click('#fetch') + page.wait_for_function('document.getElementById("result").textContent==="200 local fixture\\n"') + assert [row for row in servers[0].observations if row['path'] == '/fixture.txt'][-1]['cookie'] is None + checks.append('native-fetch-credentials-omitted') + page.click('#clear');page.wait_for_function('document.getElementById("result").textContent===""') + page.fill('#url', origin + '/missing.txt');page.click('#fetch') + page.wait_for_function('document.getElementById("result").textContent==="404 missing\\n"') + checks.append('native-fetch-http-status') + for path, expected in [('/denied.txt', 'DENIED'), ('/large.txt', 'QUOTA'), ('/redirect', 'IO'), (cross + '/fixture.txt', 'IO'), ('/slow.txt', 'TIMEOUT')]: + page.evaluate('async options=>{await window.start(options)}', {'deadline_ms': 1000} if path == '/slow.txt' else {}) + page.wait_for_function('window.controller.stats().subscriptions===5') + page.fill('#url', path if path.startswith('http') else origin + path) + page.click('#fetch') + page.wait_for_function('window.failures.length===1') + error = page.evaluate('window.failures[0]') + assert error['code'] == expected, error + assert error['location']['filename'] == 'app.py', error + assert page.evaluate('window.controller.stats().subscriptions') == 0 + assert not any(row['path'] == '/denied.txt' for row in servers[0].observations) + checks.append('fetch-grants-size-redirect-cors-timeout') + page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5') + page.fill('#url', origin + '/slow.txt') + with page.expect_event('requestfailed', predicate=lambda request: request.url == origin + '/slow.txt') as stopped: + with page.expect_request(origin + '/slow.txt'): + page.click('#fetch') + page.evaluate('window.controller.stop()') + assert stopped.value.failure + assert page.evaluate('window.controller.stats().pending') == 0 + assert page.evaluate('window.failures.length') == 0 + checks.append('fetch-stop-cancellation') + page.evaluate('window.start({},true).catch(()=>{})') + page.wait_for_function('window.failures.length===1') + assert page.evaluate('window.failures[0].code') == 'UNAVAILABLE' + assert page.evaluate('window.controller.stats().subscriptions') == 0 + checks.append('missing-required-capability') + page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5') + page.click('#fail');page.wait_for_function('window.failures.length===1') + error = page.evaluate('window.failures[0]') + assert error['type'] == 'ZeroDivisionError' and error['location']['line'] == 14, error + checks.append('python-error-source-location') + sessions = [] + for _ in range(3): + page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5') + sessions.append(page.evaluate('window.controller.stats().session')) + page.evaluate('window.controller.stop()') + assert page.evaluate('window.controller.stats()')['subscriptions'] == 0 + assert len(set(sessions)) == len(sessions) + checks.append('stop-restart-handle-baseline') + page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5') + page.fill('#input', 'x' * 2000);page.click('#busy') + page.wait_for_function('document.getElementById("status").textContent==="busy"') + page.evaluate('window.controller.stop();document.getElementById("status").textContent="Stopped"') + assert page.evaluate('window.controller.stats().state') == 'closed' + assert page.locator('#status').inner_text() == 'Stopped' + checks.append('busy-worker-termination') + page.goto(origin + '/example/') + page.wait_for_function('document.getElementById("status").textContent==="Ready"') + page.fill('#input', 'A😀 é\nline two');page.click('#run') + page.wait_for_function('document.getElementById("result").textContent==="(14, 4, 2)\\n"') + page.click('#stop');assert page.locator('#status').inner_text() == 'Stopped' + page.click('#restart');page.wait_for_function('document.getElementById("status").textContent==="Ready"') + checks.append('subdirectory-user-example') + return {'browser': browser.version, 'compiler_abi': artifact.manifest['compiler']['abi'], + 'host_protocol': 1, 'input_mode': 'compiled-module', 'checks': checks} + finally: + browser.close() + finally: + for server in servers: + server.shutdown();server.server_close() + for thread in threads: + thread.join(timeout=5) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--channel') + parser.add_argument('--report', type=Path) + args = parser.parse_args() + report = smoke(args.channel) + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report)) + + +if __name__ == '__main__': + main() diff --git a/src/JavaScript/_browser/browser-host.mjs b/src/JavaScript/_browser/browser-host.mjs new file mode 100644 index 0000000..f121a89 --- /dev/null +++ b/src/JavaScript/_browser/browser-host.mjs @@ -0,0 +1,165 @@ +import {Peer,DEFAULT_LIMITS,MAX_ID,application,grants,limits,record,exact,text,id,tagText,untagText,handle,none,failure,errorInfo,validError,equal} from './browser-protocol.mjs'; + +// The caller supplies verified application identity and trusted DOM bindings. +export function mountCompiledApplication(options) { + const app=application(options.application), capabilities=grants(options.capabilities), budget=limits(options.limits); + const bindings=options.controls||{}, eventInputs=options.eventInputs||{}; + const granted=new Map(capabilities.map(value=>[value.id,value])); + for(const grant of capabilities)for(const control of grant.controls||[]){ + if(!Object.hasOwn(bindings,control)||!(bindings[control] instanceof Element))throw new TypeError('Bind every granted control to an Element.'); + } + for(const [button,input] of Object.entries(eventInputs))if(!granted.get('dom-events/1')?.controls.includes(button)||!granted.get('dom-text/1')?.controls.includes(input))throw new TypeError('Event snapshots require granted button/input controls.'); + if(granted.has('output/1')&&!(options.output instanceof Element))throw new TypeError('Output requires a trusted text container.'); + const session=Array.from(crypto.getRandomValues(new Uint8Array(16)),value=>value.toString(16).padStart(2,'0')).join(''); + const worker=new Worker(new URL('./browser-worker.mjs',import.meta.url),{type:'module'}); + const peer=new Peer(session,value=>worker.postMessage(value),budget.message_bytes); + const pending=new Map(), subscriptions=new Map(); + let state='initializing',lastRequest=0,nextHandle=0,outputCount=0,resolveReady,rejectReady; + const ready=new Promise((resolve,reject)=>{resolveReady=resolve;rejectReady=reject;}); + // A caller may install UI handlers before awaiting ready; retain the rejection. + ready.catch(()=>{}); + const initTimer=setTimeout(()=>fatal(failure('TIMEOUT','Worker initialization timed out.')),budget.deadline_ms); + const writeError=error=>{ + const info=errorInfo(error); + if(typeof options.onError==='function')options.onError(info); + else if(options.output instanceof Element)options.output.append(document.createTextNode(info.type+': '+info.message+'\n')); + }; + function dispose(reason='stop') { + if(state==='closed')return; + const before=state;state='closed';clearTimeout(initTimer); + for(const item of pending.values()){clearTimeout(item.timer);item.controller.abort();} + pending.clear(); + for(const item of subscriptions.values())item.element.removeEventListener('click',item.listener); + subscriptions.clear(); + try{peer.send('close',{reason});}catch{} + worker.onmessage=null;worker.onerror=null;worker.terminate(); + if(before==='initializing')rejectReady(failure('CANCELLED','Worker initialization stopped.','AbortError')); + } + function fatal(error) { + if(state==='closed')return; + rejectReady(error);dispose('error');writeError(error); + } + const control=(name,capability)=>{ + if(!text(name,64)||!granted.get(capability)?.controls.includes(name))throw failure('DENIED','Control is outside its granted capability.'); + return bindings[name]; + }; + const read=element=>'value' in element?String(element.value):element.textContent; + const write=(element,value)=>{if('value' in element)element.value=value;else element.textContent=value;}; + const requireCapability=name=>{if(!granted.has(name))throw failure('UNAVAILABLE','Capability is not granted: '+name);}; + const unsubscribe=wire=>{ + handle(wire,'main','event.subscription'); + const entry=subscriptions.get(wire.id); + if(!entry)throw failure('STALE_HANDLE','Subscription has been released.'); + entry.element.removeEventListener('click',entry.listener);subscriptions.delete(wire.id); + }; + async function dispatch(operation,args,signal) { + if(signal.aborted||state==='closed')throw failure('CANCELLED','Host operation cancelled.','AbortError'); + if(!record(args))throw failure('BAD_VALUE','Host arguments must be a plain record.'); + if(operation==='output.write') { + requireCapability('output/1'); + if(!exact(args,['stream','text'])||!['stdout','stderr'].includes(args.stream))throw failure('BAD_VALUE','Invalid output arguments.'); + const value=untagText(args.text,budget.output_codepoints),count=args.text.codepoints.length; + if(outputCount+count>budget.output_codepoints)throw failure('QUOTA','Output limit exceeded.'); + outputCount+=count;options.output.append(document.createTextNode(value));return none(); + } + if(operation==='dom.read') { + requireCapability('dom-text/1');if(!exact(args,['control']))throw failure('BAD_VALUE','Invalid read arguments.'); + const value=tagText(read(control(args.control,'dom-text/1'))); + if(value.codepoints.length>budget.output_codepoints)throw failure('QUOTA','Control text exceeds its limit.'); + return value; + } + if(operation==='dom.write') { + requireCapability('dom-text/1');if(!exact(args,['control','text']))throw failure('BAD_VALUE','Invalid write arguments.'); + const element=control(args.control,'dom-text/1'),value=untagText(args.text,budget.output_codepoints);write(element,value);return none(); + } + if(operation==='events.subscribe') { + requireCapability('dom-events/1');requireCapability('handles/1'); + if(!exact(args,['control','event','callback'])||args.event!=='click')throw failure('BAD_VALUE','Only a named click subscription is supported.'); + handle(args.callback,'worker','callback'); + const element=control(args.control,'dom-events/1'); + if(subscriptions.size>=budget.subscriptions||nextHandle>=MAX_ID)throw failure('QUOTA','Subscription limit exceeded.'); + const wire={type:'handle',owner:'main',kind:'event.subscription',id:'m'+(++nextHandle)}; + const listener=()=>{ + if(state!=='ready'||!subscriptions.has(wire.id))return; + try{ + const input=Object.hasOwn(eventInputs,args.control)?eventInputs[args.control]:null; + const value=tagText(input?read(control(input,'dom-text/1')):''); + if(value.codepoints.length>budget.output_codepoints)throw failure('QUOTA','Event text exceeds its limit.'); + peer.send('event',{subscription:wire,callback:args.callback,value}); + }catch(error){fatal(error);} + }; + element.addEventListener('click',listener);subscriptions.set(wire.id,{element,listener,callback:args.callback});return wire; + } + if(operation==='events.unsubscribe') { + requireCapability('dom-events/1');requireCapability('handles/1'); + if(!exact(args,['subscription']))throw failure('BAD_VALUE','Invalid unsubscribe arguments.'); + unsubscribe(args.subscription);return none(); + } + if(operation==='handles.release') { + requireCapability('handles/1'); + if(!exact(args,['handles'])||!Array.isArray(args.handles)||args.handles.length>budget.subscriptions)throw failure('BAD_VALUE','Invalid handle release.'); + for(const wire of args.handles){handle(wire,'main','event.subscription');if(subscriptions.has(wire.id))unsubscribe(wire);} + return none(); + } + if(operation==='fetch.text') { + requireCapability('fetch/1'); + if(!exact(args,['url','method'])||!text(args.url,2048)||args.method!=='GET')throw failure('BAD_VALUE','The first Fetch adapter accepts GET text only.'); + let url; + try{url=new URL(args.url,location.href);}catch{throw failure('BAD_VALUE','Invalid Fetch URL.');} + if(url.username||url.password||url.hash||!granted.get('fetch/1').urls.includes(url.href))throw failure('DENIED','URL is outside the exact Fetch grant.'); + try { + const response=await fetch(url.href,{method:'GET',credentials:'omit',redirect:'error',signal}); + const reader=response.body?.getReader(),parts=[];let bytes=0; + if(reader)try{ + while(true){const {done,value}=await reader.read();if(done)break;bytes+=value.byteLength; + if(bytes>budget.response_bytes){await reader.cancel();throw failure('QUOTA','Fetch response exceeds its byte limit.');}parts.push(value);} + }finally{reader.releaseLock();} + const body=new Uint8Array(bytes);let offset=0;for(const part of parts){body.set(part,offset);offset+=part.length;} + const value=new TextDecoder('utf-8',{fatal:true}).decode(body); + return {type:'dict',entries:[['status',{type:'int',value:String(response.status)}],['url',tagText(response.url)],['text',tagText(value)]].map(([key,item])=>[tagText(key),item])}; + }catch(error){if(error.code==='QUOTA')throw error;if(signal.aborted)throw failure('CANCELLED','Fetch cancelled.','AbortError');throw failure('IO','Fetch failed.','FetchError');} + } + throw failure('UNAVAILABLE','Host operation is not implemented.'); + } + function settle(request,value,error) { + const entry=pending.get(request);if(!entry||state==='closed')return; + clearTimeout(entry.timer);pending.delete(request); + try{peer.send('result',{reply_to:request,...(error?{error:errorInfo(error,'host')}:{value})});}catch(caught){ + if(!error)try{peer.send('result',{reply_to:request,error:errorInfo(failure('QUOTA','Result exceeds its message limit.'),'host')});return;}catch{} + fatal(caught); + } + } + function request(message) { + if(!id(message.id,'w')||Number(message.id.slice(1))<=lastRequest||!text(message.operation,64)||!Number.isSafeInteger(message.timeout_ms)||message.timeout_ms<1)throw failure('PROTOCOL','Invalid or repeated request.','ProtocolError','protocol'); + lastRequest=Number(message.id.slice(1)); + if(pending.size>=budget.pending_requests){peer.send('result',{reply_to:message.id,error:errorInfo(failure('QUOTA','Pending request limit exceeded.'),'host')});return;} + const controller=new AbortController(); + const timer=setTimeout(()=>{controller.abort();settle(message.id,null,failure('TIMEOUT','Host operation timed out.'));},Math.min(message.timeout_ms,budget.deadline_ms)); + pending.set(message.id,{controller,timer}); + Promise.resolve().then(()=>dispatch(message.operation,message.arguments,controller.signal)).then(value=>settle(message.id,value),error=>settle(message.id,null,error)); + } + worker.onmessage=event=>{ + if(state==='closed')return; + try{ + const message=peer.receive(event.data);if(!message)return; + if(message.kind==='ready'&&state==='initializing') { + application(message.application); + if(!exact(message.limits,Object.keys(DEFAULT_LIMITS)))throw failure('PROTOCOL','Ready must report every effective limit.','ProtocolError','protocol'); + if(!Array.isArray(message.capabilities)||message.capabilities.some(value=>typeof value!=='string')||!equal(message.application,app)||!equal(message.capabilities,capabilities.map(value=>value.id))||!equal(limits(message.limits),budget))throw failure('PROTOCOL','Worker ready identity mismatch.','ProtocolError','protocol'); + state='ready';clearTimeout(initTimer);resolveReady();return; + } + if(message.kind==='fatal'){validError(message.error);fatal(message.error);return;} + if(state!=='ready')throw failure('PROTOCOL','Worker request before ready.','ProtocolError','protocol'); + if(message.kind==='request'){request(message);return;} + if(message.kind==='cancel') { + if(!id(message.reply_to,'w')||Number(message.reply_to.slice(1))>lastRequest)throw failure('PROTOCOL','Unknown cancellation ID.','ProtocolError','protocol'); + const entry=pending.get(message.reply_to);if(entry){entry.controller.abort();settle(message.reply_to,null,failure('CANCELLED','Host operation cancelled.','AbortError'));}return; + } + throw failure('PROTOCOL','Unexpected worker message.','ProtocolError','protocol'); + }catch(error){fatal(error);} + }; + worker.onerror=()=>fatal(failure('IO','Compiled worker failed to load or execute.','RuntimeError','runtime')); + try{peer.send('init',{application:app,capabilities,limits:budget});}catch(error){fatal(error);} + return Object.freeze({ready,stop:()=>dispose('stop'),restart:()=>{dispose('restart');return mountCompiledApplication(options);}, + stats:()=>({state,session,pending:pending.size,subscriptions:subscriptions.size,handles:subscriptions.size})}); +} diff --git a/src/JavaScript/_browser/browser-protocol.mjs b/src/JavaScript/_browser/browser-protocol.mjs new file mode 100644 index 0000000..b0639f4 --- /dev/null +++ b/src/JavaScript/_browser/browser-protocol.mjs @@ -0,0 +1,111 @@ +export const PROTOCOL = 'pocket-python-host'; +export const DEFAULT_LIMITS = Object.freeze({message_bytes:1048576,value_depth:64,pending_requests:32,subscriptions:32,output_codepoints:65536,response_bytes:1048576,deadline_ms:10000}); +export const MAX_ID = 2147483647; +export const record = value => value!==null && typeof value==='object' && !Array.isArray(value); +export function failure(code,message,type='HostError',domain='host') { + return Object.assign(new Error(message),{name:type,type,code,domain}); +} +export function exact(value,keys) { + return record(value) && Object.keys(value).length===keys.length && keys.every(key=>Object.hasOwn(value,key)); +} +export function text(value,maximum=4096) { return typeof value==='string' && value.length<=maximum; } +export function id(value,prefix) { return typeof value==='string' && new RegExp('^'+prefix+'[1-9][0-9]*$').test(value) && Number(value.slice(1))<=MAX_ID; } +export function limits(input={}) { + if(!record(input)||Object.keys(input).some(key=>!Object.hasOwn(DEFAULT_LIMITS,key)))throw failure('PROTOCOL','Unknown limits.','ProtocolError','protocol'); + const result={...DEFAULT_LIMITS,...input}; + if(Object.entries(result).some(([key,value])=>!Number.isSafeInteger(value)||value<1||value>DEFAULT_LIMITS[key]))throw failure('PROTOCOL','Limits must be positive and no larger than this adapter supports.','ProtocolError','protocol'); + return Object.freeze(result); +} +export function application(value) { + if(!exact(value,['manifest_sha256','profile','profile_version','entrypoint','engine'])|| + !/^[0-9a-f]{64}$/.test(value.manifest_sha256)||value.profile!=='compiled-javascript'||value.profile_version!==1||value.entrypoint!=='app.mjs'||value.engine!==null) { + throw failure('PROTOCOL','Unsupported compiled application identity.','ProtocolError','protocol'); + } + return {...value}; +} +export function grants(input) { + if(!Array.isArray(input)||input.length>5)throw failure('PROTOCOL','Invalid capability grants.','ProtocolError','protocol'); + const seen=new Set(); + return input.map(value=>{ + if(!record(value)||!text(value.id,64)||seen.has(value.id))throw failure('PROTOCOL','Duplicate/invalid capability.','ProtocolError','protocol'); + seen.add(value.id); + const fields={'output/1':['id'],'dom-text/1':['id','controls'],'dom-events/1':['id','controls','events'],'handles/1':['id'],'fetch/1':['id','urls']}; + if(!Object.hasOwn(fields,value.id)||!exact(value,fields[value.id]))throw failure('PROTOCOL','Unsupported capability shape.','ProtocolError','protocol'); + if(value.controls&&(!Array.isArray(value.controls)||value.controls.length>32||new Set(value.controls).size!==value.controls.length||value.controls.some(name=>!text(name,64)||!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name))))throw failure('PROTOCOL','Invalid named controls.','ProtocolError','protocol'); + if(value.events&&(!Array.isArray(value.events)||value.events.length!==1||value.events[0]!=='click'))throw failure('PROTOCOL','Only click events are adopted.','ProtocolError','protocol'); + if(value.urls&&(!Array.isArray(value.urls)||value.urls.length>32||new Set(value.urls).size!==value.urls.length||value.urls.some(url=>{ + if(!text(url,2048))return true; + try{const parsed=new URL(url);return !['http:','https:'].includes(parsed.protocol)||parsed.username||parsed.password||parsed.hash||parsed.href!==url;}catch{return true;} + })))throw failure('PROTOCOL','Fetch requires exact normalized HTTP(S) URL grants.','ProtocolError','protocol'); + return structuredClone(value); + }); +} +export const tagText = value => ({type:'str',codepoints:Array.from(value,point=>point.codePointAt(0))}); +export function untagText(value,maximum=1048576) { + if(!exact(value,['type','codepoints'])||value.type!=='str'||!Array.isArray(value.codepoints)||value.codepoints.length>maximum||!Array.from(value.codepoints).every(point=>Number.isInteger(point)&&point>=0&&point<=0x10ffff))throw failure('BAD_VALUE','Expected a bounded code-point string.'); + const chunks=[]; + for(let index=0;index ({type:'none'}); +export function handle(value,owner,kind) { + if(!exact(value,['type','owner','kind','id'])||value.type!=='handle'||value.owner!==owner||value.kind!==kind||!id(value.id,owner==='main'?'m':'w'))throw failure('BAD_VALUE','Invalid resource handle.'); + return value; +} +export function tagged(value,depth=0,maxDepth=64) { + if(depth>maxDepth||!record(value)||!text(value.type,16))throw failure('BAD_VALUE','Invalid tagged value.'); + if(value.type==='none'&&exact(value,['type']))return; + if(value.type==='bool'&&exact(value,['type','value'])&&typeof value.value==='boolean')return; + if(value.type==='int'&&exact(value,['type','value'])&&text(value.value,4097)&&/^(0|[1-9][0-9]*|-[1-9][0-9]*)$/.test(value.value))return; + if(value.type==='float'&&exact(value,['type','bits'])&&typeof value.bits==='string'&&/^[0-9a-f]{16}$/.test(value.bits))return; + if(value.type==='str'){untagText(value);return;} + if(['tuple','list'].includes(value.type)&&exact(value,['type','items'])&&Array.isArray(value.items)&&value.items.length<=1024){for(const item of value.items)tagged(item,depth+1,maxDepth);return;} + if(value.type==='dict'&&exact(value,['type','entries'])&&Array.isArray(value.entries)&&value.entries.length<=1024){ + for(const pair of value.entries){if(!Array.isArray(pair)||pair.length!==2)throw failure('BAD_VALUE','Invalid dict pair.');for(const item of pair)tagged(item,depth+1,maxDepth);}return; + } + if(value.type==='handle'&&['main','worker'].includes(value.owner)&&text(value.kind,64)){ + handle(value,value.owner,value.kind);return; + } + throw failure('BAD_VALUE','Unknown or malformed tagged value.'); +} +export function errorInfo(error,domain='runtime') { + const result={domain:error.domain||domain,code:typeof error.code==='string'?error.code:'RUNTIME',type:typeof error.type==='string'?error.type:typeof error.name==='string'?error.name:'RuntimeError',message:typeof error.message==='string'?error.message.slice(0,4096):'Operation failed.',frames:[]}; + const at=error.location; + if(at&&text(at.filename,256)&&['line','column','end_line','end_column'].every(key=>Number.isSafeInteger(at[key])&&at[key]>0&&at[key]<=1000000))result.location={...at}; + return result; +} +export function validError(value) { + if(!record(value)||!['protocol','host','runtime'].includes(value.domain)||!text(value.code,64)||!text(value.type,128)||!text(value.message,4096)||!Array.isArray(value.frames)||value.frames.length>128)throw failure('PROTOCOL','Malformed error packet.','ProtocolError','protocol'); + if(value.location!==undefined&&(!exact(value.location,['filename','line','column','end_line','end_column'])||!text(value.location.filename,256)||!['line','column','end_line','end_column'].every(key=>Number.isSafeInteger(value.location[key])&&value.location[key]>0&&value.location[key]<=1000000)||value.location.end_line!['domain','code','type','message','location','frames'].includes(key)))throw failure('PROTOCOL','Unknown error fields.','ProtocolError','protocol'); +} +export function equal(left,right) { + const sorted=value=>Array.isArray(value)?value.map(sorted):record(value)?Object.fromEntries(Object.keys(value).sort().map(key=>[key,sorted(value[key])])):value; + return JSON.stringify(sorted(left))===JSON.stringify(sorted(right)); +} +export class Peer { + constructor(session,post,budget=DEFAULT_LIMITS.message_bytes) { + if(typeof session!=='string'||!/^[0-9a-f]{32}$/.test(session))throw failure('PROTOCOL','Invalid session.','ProtocolError','protocol'); + this.session=session;this.post=post;this.budget=budget;this.sent=0;this.received=0; + } + size(value) { + let bytes; + try{bytes=new TextEncoder().encode(JSON.stringify(value)).length;}catch{throw failure('PROTOCOL','Message is not a bounded plain value.','ProtocolError','protocol');} + if(bytes>this.budget)throw failure('PROTOCOL','Message size exceeded.','ProtocolError','protocol'); + } + send(kind,fields={}) { + if(this.sent>=MAX_ID)throw failure('PROTOCOL','Sequence exhausted.','ProtocolError','protocol'); + const value={protocol:PROTOCOL,version:1,session:this.session,seq:this.sent+1,kind,...fields}; + this.size(value);this.sent++;this.post(value); + } + receive(value) { + if(record(value)&&value.session!==this.session)return null; + this.size(value); + if(!record(value)||value.protocol!==PROTOCOL||value.version!==1||value.session!==this.session||value.seq!==this.received+1||value.seq>MAX_ID)throw failure('PROTOCOL','Invalid envelope or sequence.','ProtocolError','protocol'); + const base=['protocol','version','session','seq','kind']; + const fields={init:['application','capabilities','limits'],ready:['application','capabilities','limits'],request:['id','operation','arguments','timeout_ms'],event:['subscription','callback','value'],cancel:['reply_to'],close:['reason'],closed:[],fatal:['error']}; + const extra=value.kind==='result'?(Object.hasOwn(value,'value')?['reply_to','value']:['reply_to','error']):Object.hasOwn(fields,value.kind)?fields[value.kind]:null; + if(!extra||!exact(value,[...base,...extra]))throw failure('PROTOCOL','Unexpected message fields/kind.','ProtocolError','protocol'); + this.received++;return value; + } +} diff --git a/src/JavaScript/_browser/browser-worker.mjs b/src/JavaScript/_browser/browser-worker.mjs new file mode 100644 index 0000000..762eaad --- /dev/null +++ b/src/JavaScript/_browser/browser-worker.mjs @@ -0,0 +1,117 @@ +import {createModule} from './app.mjs'; +import {Peer,DEFAULT_LIMITS,MAX_ID,application,grants,limits,exact,id,tagText,tagged,handle,failure,errorInfo,validError} from './browser-protocol.mjs'; + +const REQUIRED_CAPABILITIES=/*CAPABILITIES*/[]; +let peer=null,state='created',budget=null,controller=null,requestCounter=0,callbackCounter=0,lastHandle=0,outputCount=0,eventCount=0; +const pending=new Map(),callbacks=new Map(),subscriptions=new Map(); +let output=Promise.resolve(),events=Promise.resolve(); +function close(reason='stop') { + if(state==='closed')return; + state='closed';controller?.abort(); + for(const entry of pending.values()){clearTimeout(entry.timer);entry.reject(failure('CANCELLED','Worker closed.','AbortError'));} + pending.clear();callbacks.clear();subscriptions.clear(); + try{peer?.send('closed');}catch{} + self.close(); +} +function fatal(error) { + if(state==='closed')return; + try{peer?.send('fatal',{error:errorInfo(error)});}catch{} + close('error'); +} +function request(operation,args) { + if(state!=='ready')return Promise.reject(failure('PROTOCOL','No host requests before ready.','ProtocolError','protocol')); + if(pending.size>=budget.pending_requests||requestCounter>=MAX_ID)return Promise.reject(failure('QUOTA','Pending host request limit exceeded.')); + const requestId='w'+(++requestCounter); + return new Promise((resolve,reject)=>{ + const entry={resolve,reject,operation,args,done:false,timer:null}; + entry.timer=setTimeout(()=>{ + if(!pending.has(requestId)||entry.done)return; + entry.done=true;reject(failure('TIMEOUT','Host request timed out.')); + try{peer.send('cancel',{reply_to:requestId});}catch(error){fatal(error);} + // Retain the slot until the peer's terminal reply or teardown. + },budget.deadline_ms); + pending.set(requestId,entry); + try{peer.send('request',{id:requestId,operation,arguments:args,timeout_ms:budget.deadline_ms});} + catch(error){clearTimeout(entry.timer);pending.delete(requestId);reject(error);} + }); +} +const host={request, + registerCallback(fn) { + if(typeof fn!=='function'||callbacks.size>=budget.subscriptions||callbackCounter>=MAX_ID)throw failure('QUOTA','Callback limit exceeded.'); + const wire={type:'handle',owner:'worker',kind:'callback',id:'w'+(++callbackCounter)}; + callbacks.set(wire.id,fn);return wire; + }, + releaseCallback(wire){handle(wire,'worker','callback');callbacks.delete(wire.id);} +}; +function write(value) { + if(typeof value!=='string')throw failure('BAD_VALUE','Output requires text.'); + const taggedText=tagText(value); + if(outputCount+taggedText.codepoints.length>budget.output_codepoints)throw failure('QUOTA','Output limit exceeded.'); + outputCount+=taggedText.codepoints.length; + output=output.then(()=>request('output.write',{stream:'stdout',text:taggedText})); + output.catch(fatal); +} +function result(message) { + if(!id(message.reply_to,'w')||!pending.has(message.reply_to))throw failure('PROTOCOL','Duplicate or unknown host reply.','ProtocolError','protocol'); + const entry=pending.get(message.reply_to); + if(Object.hasOwn(message,'error'))validError(message.error);else tagged(message.value,0,budget.value_depth); + clearTimeout(entry.timer);pending.delete(message.reply_to); + if(Object.hasOwn(message,'error')){if(!entry.done)entry.reject(message.error);return;} + if(entry.operation==='events.subscribe') { + handle(message.value,'main','event.subscription'); + const number=Number(message.value.id.slice(1)); + if(number<=lastHandle)throw failure('PROTOCOL','Reused main resource ID.','ProtocolError','protocol'); + lastHandle=number; + if(entry.done){request('handles.release',{handles:[message.value]}).catch(fatal);return;} + subscriptions.set(message.value.id,entry.args.callback.id); + } + if(entry.operation==='events.unsubscribe')subscriptions.delete(entry.args.subscription.id); + if(entry.operation==='handles.release')for(const wire of entry.args.handles)subscriptions.delete(wire.id); + if(!entry.done)entry.resolve(message.value); +} +function event(message) { + handle(message.subscription,'main','event.subscription');handle(message.callback,'worker','callback'); + tagged(message.value,0,budget.value_depth); + const expected=subscriptions.get(message.subscription.id); + if(!expected){ + if(Number(message.subscription.id.slice(1))<=lastHandle)return; + throw failure('PROTOCOL','Event references an unknown subscription.','ProtocolError','protocol'); + } + if(expected!==message.callback.id)throw failure('PROTOCOL','Event callback does not own its subscription.','ProtocolError','protocol'); + if(!callbacks.has(expected))return; + if(eventCount>=budget.pending_requests)throw failure('QUOTA','Event queue overflow.'); + eventCount++; + events=events.then(async()=>{ + if(state!=='ready'||subscriptions.get(message.subscription.id)!==expected||!callbacks.has(expected))return; + await callbacks.get(expected)(message.value);await output; + }).finally(()=>{eventCount--;}); + events.catch(fatal); +} +async function initialize(message) { + if(state!=='created')throw failure('PROTOCOL','Duplicate initialization.','ProtocolError','protocol'); + peer=new Peer(message.session,value=>self.postMessage(value)); + const init=peer.receive(message);if(!init||init.kind!=='init')throw failure('PROTOCOL','Worker requires init first.','ProtocolError','protocol'); + if(!exact(init.limits,Object.keys(DEFAULT_LIMITS)))throw failure('PROTOCOL','Init must declare every limit.','ProtocolError','protocol'); + state='initializing';const app=application(init.application),capabilities=grants(init.capabilities);budget=limits(init.limits);peer.budget=budget.message_bytes; + if(REQUIRED_CAPABILITIES.some(name=>!capabilities.some(value=>value.id===name)))throw failure('UNAVAILABLE','A compiled application capability is missing.'); + controller=new AbortController(); + const module=createModule({write,signal:controller.signal,host}); + if(typeof module.main!=='function')throw failure('BAD_VALUE','Browser entry requires main().'); + state='ready';peer.send('ready',{application:app,capabilities:capabilities.map(value=>value.id),limits:budget}); + await module.main();await output; +} +self.onmessage=eventMessage=>{ + if(state==='closed')return; + if(state==='created'){initialize(eventMessage.data).catch(fatal);return;} + try{ + const message=peer.receive(eventMessage.data);if(!message)return; + if(message.kind==='close'){ + if(!['stop','restart','completed','error'].includes(message.reason))throw failure('PROTOCOL','Invalid close reason.','ProtocolError','protocol'); + close(message.reason);return; + } + if(state!=='ready')throw failure('PROTOCOL','Host message before ready.','ProtocolError','protocol'); + if(message.kind==='result'){result(message);return;} + if(message.kind==='event'){event(message);return;} + throw failure('PROTOCOL','Unexpected host message.','ProtocolError','protocol'); + }catch(error){fatal(error);} +}; diff --git a/src/JavaScript/_compiler_runtime.py b/src/JavaScript/_compiler_runtime.py index f6d593a..8aff583 100644 --- a/src/JavaScript/_compiler_runtime.py +++ b/src/JavaScript/_compiler_runtime.py @@ -297,6 +297,71 @@ class $PromiseValue { }); $fail('TypeError', 'Attribute is outside the adopted async registry.', location); } +'''), + 'browser': (('async', 'str', 'tuple'), r''' +if (!host || !['request','registerCallback','releaseCallback'].every(name => typeof host[name] === 'function')) { + throw new TypeError('The browser profile requires its explicit worker host bridge.'); +} +class $HostHandle { constructor(wire) { this.wire=Object.freeze({...wire}); Object.freeze(this); } } +const $subscriptions = new Map(); +function $hostText(value, location) { + if (!(value instanceof $Str)) $fail('TypeError', 'Browser text/control/URL arguments require Python str.', location); + return $text(value); +} +function $hostDecode(value, location, depth=0) { + if (depth > 64 || !value || typeof value !== 'object') $fail('TypeError', 'Invalid host value.', location); + if (value.type === 'none') return null; + if (value.type === 'bool' && typeof value.value === 'boolean') return value.value; + if (value.type === 'int' && typeof value.value === 'string' && value.value.length <= 4097 && /^(0|[1-9][0-9]*|-[1-9][0-9]*)$/.test(value.value)) return BigInt(value.value); + if (value.type === 'str' && Array.isArray(value.codepoints) && value.codepoints.length <= 1048576 && value.codepoints.every(point => Number.isInteger(point) && point>=0 && point<=0x10ffff)) return $str(value.codepoints); + if (value.type === 'tuple' && Array.isArray(value.items) && value.items.length <= 1024) return $tuple(value.items.map(item=>$hostDecode(item,location,depth+1))); + if (value.type === 'handle' && value.owner === 'main' && value.kind === 'event.subscription' && /^m[1-9][0-9]*$/.test(value.id)) return new $HostHandle(value); + $fail('TypeError', 'Host result is outside the adopted browser values.', location); +} +function $hostFailure(error, location) { + if (error instanceof $PyError) return error; + const result = new $PyError(typeof error?.type==='string' ? error.type : 'HostError', + typeof error?.message==='string' ? error.message.slice(0,4096) : 'Host operation failed.', location); + result.code = typeof error?.code==='string' ? error.code : 'IO'; return result; +} +function $request(operation, args, location, decode=value=>$hostDecode(value,location)) { + $checkpoint(location); + return new $PromiseValue(Promise.resolve().then(()=>host.request(operation,args)).then(decode).catch(error=>{throw $hostFailure(error,location);})); +} +const $browser = Object.freeze({ + read_text: $fn('read_text',1,[],(args,where)=>$request('dom.read',{control:$hostText(args[0],where)},where)), + write_text: $fn('write_text',2,[],(args,where)=>{ + $hostText(args[1],where); + return $request('dom.write',{control:$hostText(args[0],where),text:{type:'str',codepoints:[...args[1].codepoints]}},where); + }), + on_click: $fn('on_click',2,[],(args,where)=>{ + if (!(args[1] instanceof $Callable)) $fail('TypeError','on_click requires a compiled Python callback.',where); + const control=$hostText(args[0],where), callback=host.registerCallback(async value=>{ + $checkpoint(where); + const result=$call(args[1],[$hostDecode(value,where)],where); + if (result instanceof $Coroutine || result instanceof $PromiseValue) await $await(result,where); + }); + const pending=$request('events.subscribe',{control,event:'click',callback},where,value=>{ + const handle=$hostDecode(value,where);$subscriptions.set(handle,callback);return handle; + }); + return new $PromiseValue(pending.promise.catch(error=>{host.releaseCallback(callback);throw error;})); + }), + unsubscribe: $fn('unsubscribe',1,[],(args,where)=>{ + const handle=args[0]; + if (!(handle instanceof $HostHandle) || !$subscriptions.has(handle)) { + const error=new $PyError('HostError','Subscription handle is stale.',where);error.code='STALE_HANDLE';throw error; + } + return $request('events.unsubscribe',{subscription:handle.wire},where,value=>{ + host.releaseCallback($subscriptions.get(handle));$subscriptions.delete(handle);return $hostDecode(value,where); + }); + }), + fetch_text: $fn('fetch_text',1,[],(args,where)=>$request('fetch.text',{url:$hostText(args[0],where),method:'GET'},where,value=>{ + if (value?.type!=='dict' || !Array.isArray(value.entries) || value.entries.length!==3) $fail('TypeError','Malformed Fetch result.',where); + const entries=new Map(value.entries.map(pair=>[$hostText($hostDecode(pair[0],where),where),$hostDecode(pair[1],where)])); + if (!['status','url','text'].every(key=>entries.has(key)) || typeof entries.get('status')!=='bigint' || !(entries.get('url') instanceof $Str) || !(entries.get('text') instanceof $Str)) $fail('TypeError','Malformed Fetch fields.',where); + return $tuple(['status','url','text'].map(key=>entries.get(key))); + })) +}); '''), 'async-expose': (('async', 'input'), r''' const $asyncExposed = new WeakMap(); @@ -349,6 +414,7 @@ class $PromiseValue { 'javascript': ('$JS', '$JArray', '$attribute'), 'input': ('$input', '$expose'), 'async': ('$checkpoint', '$await', '$cfn', '$PromiseRegistry', '$asyncAttribute'), 'async-expose': ('$asyncExpose',), + 'browser': ('$browser',), } diff --git a/src/JavaScript/browser.py b/src/JavaScript/browser.py new file mode 100644 index 0000000..fce17e5 --- /dev/null +++ b/src/JavaScript/browser.py @@ -0,0 +1,60 @@ +"""Build the explicit compiled browser profile and its local host assets.""" + +import ast +import hashlib +from importlib.resources import files + +from .compiler import CompileError, Diagnostic, SourceSpan +from .emitter import CompiledArtifact, OutputFile, _json +from .modules import SourceModule, compile_modules + + +def compile_browser(sources: tuple[SourceModule, ...], *, entrypoint: str, + source_map: bool = True, notices: tuple[OutputFile, ...] = ()) -> CompiledArtifact: + """Compile a verified allowlist with async main(), without executing it.""" + artifact = compile_modules(sources, entrypoint=entrypoint, source_map=source_map, + async_enabled=True, browser_enabled=True, notices=notices) + entry = next(source for source in sources if source.name == entrypoint) + tree = ast.parse(entry.data.decode('utf-8')) + main = next((node for node in tree.body if isinstance(node, ast.AsyncFunctionDef) and node.name == 'main'), None) + if main is None or len(main.args.defaults) != len(main.args.posonlyargs) + len(main.args.args): + raise CompileError(Diagnostic('PJS110', 'Browser entry requires an async main() callable without required arguments.', + SourceSpan(entry.filename, 1, 1, 1, 1), 'bind')) + if any(len(source.filename) > 256 for source in sources): + raise ValueError('Browser source identities must fit the host protocol filename limit of 256.') + required = set() + if 'print' in artifact.manifest['helpers']: + required.add('output/1') + for source in sources: + if source.filename not in {record['path'] for record in artifact.manifest['sources']}: + continue + for node in ast.walk(ast.parse(source.data.decode('utf-8'))): + if isinstance(node, ast.ImportFrom) and node.module == 'JavaScript.browser': + for alias in node.names: + if alias.name in {'read_text', 'write_text'}: + required.add('dom-text/1') + elif alias.name in {'on_click', 'unsubscribe'}: + required.update({'dom-events/1', 'handles/1'}) + elif alias.name == 'fetch_text': + required.add('fetch/1') + output = list(artifact.files) + names = ['browser-protocol.mjs', 'browser-host.mjs', 'browser-worker.mjs'] + for name in names: + data = files('JavaScript').joinpath('_browser', name).read_bytes() + if name == 'browser-worker.mjs': + marker = b'/*CAPABILITIES*/[]' + if data.count(marker) != 1: + raise ValueError('Browser worker template identity mismatch.') + data = data.replace(marker, _json(sorted(required)).encode()) + output.append(OutputFile(name, data, 'executable-module' if name == 'browser-worker.mjs' else 'compatibility-helper')) + output = tuple(sorted(output, key=lambda item: item.path)) + manifest = artifact.manifest + manifest['browser'] = {'protocol': 'pocket-python-host', 'version': 1, 'host': 'browser-host.mjs', + 'worker': 'browser-worker.mjs', 'entrypoint_export': 'main', 'capabilities': sorted(required)} + manifest['imports'].extend([{'from': 'browser-host.mjs', 'to': 'browser-protocol.mjs'}, + {'from': 'browser-host.mjs', 'to': 'browser-worker.mjs'}, + {'from': 'browser-worker.mjs', 'to': 'app.mjs'}, + {'from': 'browser-worker.mjs', 'to': 'browser-protocol.mjs'}]) + manifest['imports'].sort(key=lambda item: (item['from'], item['to'])) + manifest['files'] = [{'path': item.path, 'size': len(item.data), 'sha256': hashlib.sha256(item.data).hexdigest(), 'role': item.role} for item in output] + return CompiledArtifact(output, (_json(manifest) + '\n').encode()) diff --git a/src/JavaScript/compiler.py b/src/JavaScript/compiler.py index 5a7ae9e..5231a2a 100644 --- a/src/JavaScript/compiler.py +++ b/src/JavaScript/compiler.py @@ -93,6 +93,9 @@ class SourceImport: value_type: ValueType +_BROWSER_APIS = {'read_text': 1, 'write_text': 2, 'on_click': 2, 'unsubscribe': 1, 'fetch_text': 1} + + _BUILTINS = {'len', 'print', 'isinstance', 'str', 'int', 'bool', 'tuple', 'TypeError'} _TYPES = {'str', 'int', 'bool', 'tuple'} _BINARY = {ast.Add: '+', ast.Sub: '-', ast.Mult: '*', ast.FloorDiv: '//', ast.Mod: '%'} @@ -117,7 +120,7 @@ def accepted(value): class _FrontEnd: - def __init__(self, source, filename, source_imports=None, *, async_enabled=False): + def __init__(self, source, filename, source_imports=None, *, async_enabled=False, browser_enabled=False): self.filename = filename self.lines = source.splitlines() or [''] self.bindings = {} @@ -126,6 +129,7 @@ def __init__(self, source, filename, source_imports=None, *, async_enabled=False self.types = {} self.source_imports = source_imports or {} self.async_enabled = async_enabled + self.browser_enabled = browser_enabled self.async_scopes = set() try: self.tree = ast.parse(source, filename=filename) @@ -167,6 +171,15 @@ def _declare_module(self): self.root[imported.name] = binding self.types[binding.id] = imported.value_type continue + if isinstance(node, ast.ImportFrom) and node.module == 'JavaScript.browser' and self.browser_enabled: + if node.level or any(alias.name not in _BROWSER_APIS for alias in node.names): + self.fail('PJS110', 'Unsupported browser host import.', node) + for alias in node.names: + name = alias.asname or alias.name + if name in self.root: + self.fail('PJS100', 'Module rebinding is outside the immutable profile.', node) + self.root[name] = self.declare(name, 'module', 'javascript', 'JavaScript.browser.' + alias.name) + continue names = [] kind, target = 'constant', None if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): @@ -229,7 +242,7 @@ def expression(self, node, scope): if binding.kind == 'function': kind, target = self.types.get(binding.id, 'callable'), binding.id elif binding.kind == 'javascript': - kind = 'javascript-type' + kind = 'javascript-callable' if (target or '').startswith('JavaScript.browser.') else 'javascript-type' elif binding.kind == 'builtin': kind = 'python-type' if binding.name in _TYPES else 'callable' elif binding.kind == 'source': @@ -272,6 +285,8 @@ def expression(self, node, scope): allowed = {'builtin:len': ('python-int', 1), 'builtin:print': ('python-none', None), 'builtin:isinstance': ('python-bool', 2), 'builtin:TypeError': ('python-exception', 1), 'JavaScript.Array.of': ('javascript-array', None)} + if self.browser_enabled: + allowed.update({'JavaScript.browser.' + name: ('python-promise', count) for name, count in _BROWSER_APIS.items()}) if self.async_enabled: allowed.update({'JavaScript.Promise.resolve': ('python-promise', 1), 'JavaScript.Promise.reject': ('python-promise', 1), @@ -287,6 +302,8 @@ def expression(self, node, scope): arguments = tuple(self.expression(argument, scope) for argument in node.args) if any(argument.value_type == 'python-coroutine' for argument in arguments): self.fail('PJS120', 'Coroutine arguments must be awaited first.', node) + if target == 'JavaScript.browser.on_click' and arguments[1].value_type not in {'callable', 'async-callable'}: + self.fail('PJS120', 'on_click requires a known compiled Python callback.', node) if target == 'Promise.then' and (len(arguments) > 2 or any( argument.value_type not in {'callable', 'python-none'} for argument in arguments)): self.fail('PJS120', 'then accepts up to two plain synchronous Python callbacks or None.', node) @@ -347,6 +364,9 @@ def statements(self, nodes, scope=None, loop=0): return tuple(self.statement(node, scope, loop) for node in nodes) def statement(self, node, scope, loop): + if self.browser_enabled and scope is None and not isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.AsyncFunctionDef, ast.Assign, ast.Pass)): + if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)): + self.fail('PJS100', 'Browser module initialization requires imports, definitions or immutable constants.', node) if isinstance(node, (ast.Import, ast.ImportFrom)) and scope is None: if node not in self.tree.body: self.fail('PJS100', 'Imports require unconditional module bindings.', node) diff --git a/src/JavaScript/emitter.py b/src/JavaScript/emitter.py index 629f7a1..7820698 100644 --- a/src/JavaScript/emitter.py +++ b/src/JavaScript/emitter.py @@ -163,7 +163,11 @@ def statement(self, node, indent): target = self.bindings[identifier].target if target == 'JavaScript.Promise': self.use('async') - value = {'JavaScript': '$JS', 'JavaScript.Array': '$JArray', 'JavaScript.Promise': '$PromiseRegistry'}[target] + if target.startswith('JavaScript.browser.'): + self.use('browser') + value = '$browser[' + _json(target.rsplit('.', 1)[1]) + ']' + else: + value = {'JavaScript': '$JS', 'JavaScript.Array': '$JArray', 'JavaScript.Promise': '$PromiseRegistry'}[target] self.add(f'{self.names[identifier]}={value};', indent, node) elif kind == 'function': self.use('call') diff --git a/src/JavaScript/modules.py b/src/JavaScript/modules.py index 2dbbe71..5fa8047 100644 --- a/src/JavaScript/modules.py +++ b/src/JavaScript/modules.py @@ -60,7 +60,8 @@ def from_text(cls, name, text, *, is_package=False, filename=None): class _Modules: - def __init__(self, sources, entrypoint, async_enabled): + def __init__(self, sources, entrypoint, async_enabled, browser_enabled): + self.browser_enabled = browser_enabled self.async_enabled = async_enabled if not isinstance(sources, tuple) or any(not isinstance(source, SourceModule) for source in sources): raise TypeError('sources must be a tuple of verified SourceModule records') @@ -104,6 +105,7 @@ def __init__(self, sources, entrypoint, async_enabled): self.fail(name, 'Source imports require unconditional module bindings.', node, 'PJS100') registry = isinstance(node, ast.Import) and all(alias.name == 'JavaScript' for alias in node.names) registry = registry or isinstance(node, ast.ImportFrom) and not node.level and node.module == 'JavaScript' + registry = registry or (self.browser_enabled and isinstance(node, ast.ImportFrom) and not node.level and node.module == 'JavaScript.browser') if registry: continue if isinstance(node, ast.Import): @@ -180,22 +182,26 @@ def lower(self, name): SourceImport(alias.asname or alias.name, base, alias.name, self.export_type(base, alias.name, name, node)) for alias in node.names) for node, base in self.imports[name]} - frontend = _FrontEnd(source.data.decode('utf-8'), source.filename, imports, async_enabled=self.async_enabled) + frontend = _FrontEnd(source.data.decode('utf-8'), source.filename, imports, async_enabled=self.async_enabled, browser_enabled=self.browser_enabled) body = frontend.statements(frontend.tree.body) return ModuleIR(source.filename, source.sha256, tuple(frontend.bindings[key] for key in sorted(frontend.bindings)), body, - profile='python-browser-modules-async/1' if self.async_enabled else 'python-browser-modules/1') + profile='python-browser-modules-host-async/1' if self.browser_enabled else 'python-browser-modules-async/1' if self.async_enabled else 'python-browser-modules/1') def compile_modules(sources: tuple[SourceModule, ...], *, entrypoint: str, - source_map: bool = True, async_enabled: bool = False, notices: tuple[OutputFile, ...] = ()) -> CompiledArtifact: + source_map: bool = True, async_enabled: bool = False, browser_enabled: bool = False, notices: tuple[OutputFile, ...] = ()) -> CompiledArtifact: """Compile a verified source allowlist, never reading or executing target files.""" if type(source_map) is not bool: raise TypeError('source_map must be bool') if type(async_enabled) is not bool: raise TypeError('async_enabled must be bool') - graph = _Modules(sources, entrypoint, async_enabled) + if type(browser_enabled) is not bool or (browser_enabled and not async_enabled): + raise ValueError('browser_enabled requires the explicit async profile') + graph = _Modules(sources, entrypoint, async_enabled, browser_enabled) output, required, module_records = [], {'async-expose' if async_enabled else 'input'}, [] + if browser_enabled: + required.add('browser') modules = sorted(graph.trees) for index, name in enumerate(modules): source = graph.sources[name] @@ -208,7 +214,7 @@ def compile_modules(sources: tuple[SourceModule, ...], *, entrypoint: str, 'exports': exports, 'source': source.filename, 'sha256': source.sha256}) helpers = helper_closure(required) bindings = [binding for helper in helpers for binding in HELPER_BINDINGS[helper]] - runtime = ["export function createRuntime(write" + (", signal = null" if async_enabled else "") + ") {", " if(typeof write!=='function')throw new TypeError('write must be a function');"] + runtime = ["export function createRuntime(write" + (", signal = null" if async_enabled else "") + (", host = null" if browser_enabled else "") + ") {", " if(typeof write!=='function')throw new TypeError('write must be a function');"] for helper in helpers: runtime.extend(' ' + line for line in helper_source(helper).splitlines()) runtime.extend([' return Object.freeze({' + ','.join(bindings) + '});', '}']) @@ -219,8 +225,8 @@ def compile_modules(sources: tuple[SourceModule, ...], *, entrypoint: str, packages = _json([record['module'] for record in module_records if record['is_package']]) location = _json({'filename': graph.sources[entrypoint].filename, 'line': 1, 'column': 1, 'end_line': 1, 'end_column': 1}) expose = '$asyncExpose' if async_enabled else '$expose' - signal_option = ', signal = null' if async_enabled else '' - signal_argument = ',signal' if async_enabled else '' + signal_option = (', signal = null' if async_enabled else '') + (', host = null' if browser_enabled else '') + signal_argument = (',signal' if async_enabled else '') + (',host' if browser_enabled else '') entry = '\n'.join(imports) + '\n' + f''' export function createModule({{write = text => console.log(text.slice(0,-1)){signal_option}}} = {{}}) {{ const runtime=createRuntime(write{signal_argument}), factories=new Map({registry}), packages=new Set({packages}), cache=new Map(), loading=new Set(); @@ -254,8 +260,8 @@ def compile_modules(sources: tuple[SourceModule, ...], *, entrypoint: str, raise ValueError('Artifact file/directory paths collide.') files = tuple(sorted(output, key=lambda item: item.path)) manifest = {'kind': 'python-javascript-compiled', 'schema_version': 1, - 'compiler': {'name': 'Python-JavaScript', 'version': compiler_version, 'abi': 'python-browser-modules-async-esm/1' if async_enabled else 'python-browser-modules-esm/1'}, - 'language_profile': 'python-browser-modules-async/1' if async_enabled else 'python-browser-modules/1', 'target': 'ecmascript-2022-module', + 'compiler': {'name': 'Python-JavaScript', 'version': compiler_version, 'abi': 'python-browser-host-esm/1' if browser_enabled else 'python-browser-modules-async-esm/1' if async_enabled else 'python-browser-modules-esm/1'}, + 'language_profile': 'python-browser-modules-host-async/1' if browser_enabled else 'python-browser-modules-async/1' if async_enabled else 'python-browser-modules/1', 'target': 'ecmascript-2022-module', 'unicode_version': unicodedata.unidata_version, 'entrypoint': 'app.mjs', 'entrypoint_export': 'createModule', 'application_exports': next(record['exports'] for record in module_records if record['module'] == entrypoint), 'modules': module_records, 'helpers': list(helpers), diff --git a/tests/fixtures/browser_protocol.mjs b/tests/fixtures/browser_protocol.mjs new file mode 100644 index 0000000..db3b426 --- /dev/null +++ b/tests/fixtures/browser_protocol.mjs @@ -0,0 +1,38 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +const {Peer,limits,grants,tagText,untagText,tagged,application}=await import(process.argv[2]); +const session='000102030405060708090a0b0c0d0e0f'; +test('sessions and independent sequence counters reject replay and malformed envelopes',()=>{ + const sent=[],peer=new Peer(session,message=>sent.push(message)); + peer.send('closed');peer.send('closed');assert.equal(sent[1].seq,2); + assert.equal(peer.receive({...sent[0],session:'f'.repeat(32)}),null); + assert.equal(peer.received,0);assert.equal(peer.receive(sent[0]).kind,'closed'); + assert.throws(()=>peer.receive(sent[0]),{code:'PROTOCOL'}); + assert.throws(()=>peer.receive({...sent[1],extra:true}),{code:'PROTOCOL'}); + assert.equal(peer.received,1); + assert.throws(()=>peer.receive({...sent[1],kind:'result',reply_to:'w1',value:{type:'none'},error:{}}),{code:'PROTOCOL'}); +}); +test('message, value and capability bounds are explicit',()=>{ + const peer=new Peer(session,()=>{},256); + assert.throws(()=>peer.send('fatal',{error:{message:'x'.repeat(500)}}),{code:'PROTOCOL'}); + const cyclic={};cyclic.value=cyclic;assert.throws(()=>peer.size(cyclic),{code:'PROTOCOL'}); + assert.throws(()=>tagged({type:'int',value:'-0'}),{code:'BAD_VALUE'}); + assert.throws(()=>tagged({type:'constructor'}),{code:'BAD_VALUE'}); + assert.throws(()=>tagged({type:'str',codepoints:[0x110000]}),{code:'BAD_VALUE'}); + assert.throws(()=>limits({deadline_ms:10001}),{code:'PROTOCOL'}); + assert.throws(()=>limits({constructor:1}),{code:'PROTOCOL'}); + assert.throws(()=>grants([{id:'files/1'}]),{code:'PROTOCOL'}); + assert.throws(()=>grants([{id:'fetch/1',urls:['javascript:alert(1)']}]),{code:'PROTOCOL'}); + assert.throws(()=>grants([{id:'fetch/1',urls:['https://name:secret@example.test/']}]),{code:'PROTOCOL'}); +}); +test('Unicode transport preserves stored points and declares DOM projection',()=>{ + const value={type:'str',codepoints:[65,0x1f600,0xd83d,0xde00,0x301]};tagged(value); + assert.deepEqual(value.codepoints,[65,0x1f600,0xd83d,0xde00,0x301]); + assert.deepEqual(tagText(untagText(value)).codepoints,[65,0x1f600,0x1f600,0x301]); +}); +test('compiled profile identity rejects interpreted engines and unknown formats',()=>{ + const app={manifest_sha256:'0'.repeat(64),profile:'compiled-javascript',profile_version:1,entrypoint:'app.mjs',engine:null}; + assert.deepEqual(application(app),app); + assert.throws(()=>application({...app,engine:{implementation:'other'}}),{code:'PROTOCOL'}); + assert.throws(()=>application({...app,entrypoint:'https://example.test/main.mjs'}),{code:'PROTOCOL'}); +}); diff --git a/tests/test_compiler_browser.py b/tests/test_compiler_browser.py new file mode 100644 index 0000000..bd7b7df --- /dev/null +++ b/tests/test_compiler_browser.py @@ -0,0 +1,91 @@ +import hashlib +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +import JavaScript +from JavaScript.browser import compile_browser +from JavaScript.compiler import CompileError +from JavaScript.emitter import compile_source +from JavaScript.modules import SourceModule + + +SOURCE = '''from JavaScript.browser import read_text as read, write_text, on_click, unsubscribe, fetch_text +async def selected(text): + print(text) +async def main(): + subscription = await on_click("run", selected) + value = await read("input") + await write_text("result", value) + await unsubscribe(subscription) + response = await fetch_text("https://example.invalid/text") + print(response[0], response[2]) +''' + + +class CompilerBrowserTests(unittest.TestCase): + def test_profile_capabilities_hashes_static_closure_and_source_maps(self): + sources = (SourceModule.from_text('app', SOURCE),) + artifact = compile_browser(sources, entrypoint='app') + self.assertEqual(artifact, compile_browser(sources, entrypoint='app')) + manifest = artifact.manifest + self.assertEqual(manifest['compiler']['abi'], 'python-browser-host-esm/1') + self.assertEqual(manifest['language_profile'], 'python-browser-modules-host-async/1') + self.assertEqual(manifest['browser']['capabilities'], ['dom-events/1', 'dom-text/1', 'fetch/1', 'handles/1', 'output/1']) + paths = {item.path for item in artifact.files} + self.assertTrue({'browser-host.mjs', 'browser-worker.mjs', 'browser-protocol.mjs'} <= paths) + for record, item in zip(manifest['files'], artifact.files): + self.assertEqual(record['path'], item.path) + self.assertEqual(record['sha256'], hashlib.sha256(item.data).hexdigest()) + self.assertEqual(record['size'], len(item.data)) + for edge in manifest['imports']: + self.assertIn(edge['from'], paths) + self.assertIn(edge['to'], paths) + + def test_compiled_bridge_types_callbacks_and_release_without_host_execution_fallback(self): + artifact = compile_browser((SourceModule.from_text('app', SOURCE),), entrypoint='app') + runner = '''import {createModule} from './app.mjs'; +let stdout='',callback=null;const calls=[]; +const str=value=>({type:'str',codepoints:Array.from(value,ch=>ch.codePointAt(0))}); +const host={registerCallback:fn=>{callback=fn;return{type:'handle',owner:'worker',kind:'callback',id:'w1'}}, +releaseCallback:()=>{callback=null},request:async(operation,args)=>{ + calls.push({operation,args}); + if(operation==='events.subscribe'){await callback(str('A😀'));return{type:'handle',owner:'main',kind:'event.subscription',id:'m1'}} + if(operation==='dom.read')return str('A😀'); + if(operation==='fetch.text')return{type:'dict',entries:[[str('status'),{type:'int',value:'200'}],[str('url'),str('https://example.invalid/text')],[str('text'),str('local')]]}; + return{type:'none'}; +}}; +const app=createModule({host,write:text=>stdout+=text});await app.main(); +console.log(JSON.stringify({stdout,calls,released:callback===null})); +''' + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for item in artifact.files: + (root / item.path).write_bytes(item.data) + (root / 'run.mjs').write_text(runner, encoding='utf-8') + result = subprocess.run(['node', '--unhandled-rejections=strict', str(root / 'run.mjs')], + check=True, capture_output=True, text=True, encoding='utf-8', timeout=10) + result = json.loads(result.stdout) + self.assertEqual(result['stdout'], 'A😀\n200 local\n') + self.assertTrue(result['released']) + self.assertEqual([call['operation'] for call in result['calls']], ['events.subscribe', 'dom.read', 'dom.write', 'events.unsubscribe', 'fetch.text']) + self.assertEqual(result['calls'][2]['args']['text']['codepoints'], [65, 0x1f600]) + + def test_protocol_shapes_sequences_stale_sessions_and_negative_capabilities(self): + protocol = Path(JavaScript.__file__).parent / '_browser/browser-protocol.mjs' + fixture = Path(__file__).parent / 'fixtures/browser_protocol.mjs' + subprocess.run(['node', str(fixture), protocol.as_uri()], check=True, capture_output=True, text=True, timeout=10) + + def test_browser_imports_initialization_and_entry_boundaries_fail_without_execution(self): + sources = ['print("initialization")\nasync def main():\n pass', + 'async def main(value):\n return value', 'def main():\n return 1', + 'from JavaScript.browser import location\nasync def main():\n pass', + 'from JavaScript.browser import on_click\nasync def main():\n await on_click("run",1)', + 'from JavaScript.browser import *\nasync def main():\n pass'] + for source in sources: + with self.subTest(source=source), self.assertRaises(CompileError): + compile_browser((SourceModule.from_text('app', source),), entrypoint='app') + with self.assertRaises(CompileError): + compile_source(SOURCE, async_enabled=True) diff --git a/uv.lock b/uv.lock index 318649e..a1d7a1c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,53 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + [[package]] name = "javascript-fetch-api" version = "0.2.0" @@ -20,6 +67,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/a6/7c057df3ddd6799e7f6381da326917903ba52babb972a109702e1077b4b6/javascript_json-0.1.0-py3-none-any.whl", hash = "sha256:e119be47ba76924bb1757af6a55c0b7cd53e0d08b55f8157fc0081fc65052bef", size = 5047, upload-time = "2026-10-16T19:08:53.384Z" }, ] +[[package]] +name = "playwright" +version = "1.64.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/b7/24e5c283694e7a63aa376ddde395031decd7d627c77b34a58953176e8a19/playwright-1.64.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d76a501c9930b5a097b00e2448cda2200122a1e8e4be762ff535c1b076277737", size = 44347913, upload-time = "2026-10-13T23:35:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b3/99f6c07a7f59adb3830e42cda2e2093dc1c2cbd1f69fe4393ead30c88257/playwright-1.64.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8de42430e9a7c8b04ec963856d484d36ffd452882318ebad2df3e6fb49a8197a", size = 43014183, upload-time = "2026-10-13T23:35:24.195Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f4/e0ddecd32342cac462777ef9123de833486743709bd437458c49e004d750/playwright-1.64.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:61e4e0801bfd76b30e04635aaec45647df707881ccf14382471fcb0eaeb1d16f", size = 44347915, upload-time = "2026-10-13T23:35:27.476Z" }, + { url = "https://files.pythonhosted.org/packages/65/8b/78c19b805d52323122385b9c3f000144608deac38635455978af6d14807b/playwright-1.64.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:5a59af1b230b234008524a5d42b613b233d4256f73bc1dd25bf3f11db0c81b75", size = 48286664, upload-time = "2026-10-13T23:35:30.981Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7e/c100ee3c59ff42e3199880d77e8f59f3230fdb0dd1ecfd21a0a203bb5a68/playwright-1.64.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:727d20be6a0884e946b2471774dd960ec95519ba533e61329038526d9aea9a23", size = 47972215, upload-time = "2026-10-13T23:35:34.976Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6e/3746bca44a77ad29fb4bf1722ad3345d5e936d5363d8daffbf23b226d3a4/playwright-1.64.0-py3-none-win32.whl", hash = "sha256:8b9f18dc1c23143ac0a5b3c59015db30e9413cc52e1ddddfc2836a7fbad7165a", size = 38675586, upload-time = "2026-10-13T23:35:38.665Z" }, + { url = "https://files.pythonhosted.org/packages/e9/be/1dd8a65e3713c50c64b2580f50494ece90a4a9ede6e23788ef120e54c1f2/playwright-1.64.0-py3-none-win_amd64.whl", hash = "sha256:2c14d105548876b15bea5e7eca77bf0d8ff4ba0c607c1ae931067f3d1b010369", size = 38675591, upload-time = "2026-10-13T23:35:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/683ee3eb28902208d1d0c1d2eb3d6ddd99bc0184063babc6a2a7886c5799/playwright-1.64.0-py3-none-win_arm64.whl", hash = "sha256:97a5c247f1130f3343f097caf3bb1e79358d6b6cfa3550d97ecd721d1905911a", size = 34593061, upload-time = "2026-10-13T23:35:45.768Z" }, +] + +[[package]] +name = "pyee" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/f1/fdedc2c75c3e31a330659c85e5793bb18b3397981fbf0844c6dee5b18926/pyee-14.0.0.tar.gz", hash = "sha256:76dd0f4314ecd27f02dc73589dea7fd3853f9b6176d8ef9b122860657e3602de", size = 98760, upload-time = "2026-08-13T04:26:11.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/12/5347938b1f9a6453f0dbdfcc3e2388a1320ef9b9ec17fbefbc4ab647ea98/pyee-14.0.0-py3-none-any.whl", hash = "sha256:3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", size = 15553, upload-time = "2026-08-13T04:26:09.916Z" }, +] + [[package]] name = "python-javascript" version = "0.1.0" @@ -29,8 +107,25 @@ dependencies = [ { name = "javascript-json" }, ] +[package.dev-dependencies] +browser-tests = [ + { name = "playwright" }, +] + [package.metadata] requires-dist = [ { name = "javascript-fetch-api", specifier = ">=0.2.0,<0.3.0" }, { name = "javascript-json", specifier = ">=0.1.0,<0.2.0" }, ] + +[package.metadata.requires-dev] +browser-tests = [{ name = "playwright", specifier = "==1.64.0" }] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]