Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 5 additions & 5 deletions docs/compiler-async.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,18 @@ 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

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
Expand Down
145 changes: 145 additions & 0 deletions docs/compiler-browser.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/compiler-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions docs/compiler-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
92 changes: 92 additions & 0 deletions examples/browser_text_metrics.py
Original file line number Diff line number Diff line change
@@ -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 = '''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Text metrics</title><style>
body{font:1.1rem system-ui,sans-serif;max-width:44rem;margin:4rem auto;padding:0 1.5rem;color:#19362c;background:#f4f7f1}
textarea{box-sizing:border-box;width:100%;min-height:12rem;padding:1rem;font:inherit;border:1px solid #779287;border-radius:.5rem;background:white}
button{padding:.6rem 1rem;font:inherit;border:1px solid #779287;border-radius:.4rem;background:white;cursor:pointer}
#run{background:#225841;color:white}nav{display:flex;gap:.5rem;margin:1rem 0}pre{min-height:3rem;white-space:pre-wrap;padding:1rem;background:white;border-radius:.5rem}
</style><main><h1>Text metrics</h1><p>Count characters, words and lines locally in your browser.</p>
<label for="input">Text</label><textarea id="input" placeholder="Write something…"></textarea>
<nav><button id="run" disabled>Run</button><button id="stop">Stop</button><button id="restart">Restart</button></nav>
<p>Characters, words, lines</p><pre id="result" aria-live="polite"></pre><p id="status" role="status"></p></main>
<script type="module">
import {mountCompiledApplication} from './browser-host.mjs';
const byId=name=>document.getElementById(name);let current;
function start(){
current?.stop();byId('run').disabled=true;byId('result').textContent='';byId('status').textContent='Starting…';
const active=mountCompiledApplication({application:APPLICATION,
capabilities:[{id:'output/1'},{id:'dom-text/1',controls:['input']},{id:'dom-events/1',controls:['run'],events:['click']},{id:'handles/1'}],
controls:{input:byId('input'),run:byId('run')},eventInputs:{run:'input'},output:byId('result'),
onError:error=>{byId('status').textContent=error.type+': '+error.message;}});
current=active;active.ready.then(async()=>{
while(current===active&&active.stats().state==='ready'&&active.stats().subscriptions<1)await new Promise(requestAnimationFrame);
if(current===active&&active.stats().state==='ready'){byId('run').disabled=false;byId('status').textContent='Ready';}
},()=>{});
}
byId('stop').addEventListener('click',()=>{current?.stop();byId('run').disabled=true;byId('status').textContent='Stopped';});
byId('restart').addEventListener('click',start);start();
</script></html>'''.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()
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading