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.
+Text
+Run Stop Restart
+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
+Run
+Fetch Clear Error
+Busy Unused
+'''.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" },
+]