Skip to content

Commit 51198ae

Browse files
authored
feat(BROWSER-01): add compiled worker host and browser example (#33)
1 parent e0adb31 commit 51198ae

21 files changed

Lines changed: 1333 additions & 22 deletions

‎.github/workflows/ci.yaml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,27 @@ jobs:
4343
run: python scripts/install_distribution.py sdist
4444
- name: Test installed source distribution
4545
run: python -m unittest discover -s tests -v
46+
47+
browser:
48+
runs-on: ubuntu-latest
49+
timeout-minutes: 15
50+
env:
51+
UV_PYTHON: '3.14.7'
52+
steps:
53+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
54+
with:
55+
persist-credentials: false
56+
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
57+
with:
58+
version: '0.12.19'
59+
enable-cache: true
60+
- run: uv python install
61+
- run: uv sync --locked --group browser-tests
62+
- run: uv run --no-sync python -m playwright install --with-deps chromium
63+
- run: uv run --no-sync python scripts/compiler_browser_smoke.py --report build/compiler-browser-report.json
64+
- uses: actions/upload-artifact@v5
65+
if: always()
66+
with:
67+
name: compiler-browser-report
68+
path: build/compiler-browser-report.json
69+
if-no-files-found: error

‎IMPLEMENTATION_PLAN.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,3 +456,10 @@ coroutines, Promise conversion/chaining, synchronous callbacks, source-located
456456
rejections and AbortSignal context cancellation. JavaScript microtask timing and
457457
CPython scheduling differences are documented. Fetch/DOM/worker transport remains
458458
BROWSER-01; no new async runtime dependency or published artifact is introduced.
459+
460+
The [compiled browser target](docs/compiler-browser.md) implements BROWSER-01
461+
with an explicit host namespace, module worker, named text/events, callback
462+
ownership, native GET text Fetch, source errors and stop/restart. A runnable
463+
text_metrics example and real-browser tests cover local/offline-after-load
464+
computation and transport/resource failures. WEB-03 owns verified PWA assembly
465+
and offline reload; QA-03 extends cross-target parity. No package was released.

‎README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,10 @@ ES modules for the approved single-module Python profile. This API is not part
110110
of the published 0.1.0 distribution.
111111

112112
The explicit [async compiler profile](docs/compiler-async.md) adds lazy coroutines,
113-
awaited Promise values, callbacks and context cancellation. Browser host adapters
114-
remain separately tracked.
113+
awaited Promise values, callbacks and context cancellation. The [browser target](docs/compiler-browser.md) adds named DOM controls, callbacks,
114+
native Fetch text and worker stop/restart.
115+
116+
Build the local [text_metrics browser example](docs/compiler-browser.md#run-the-example)
117+
with `uv run python examples/browser_text_metrics.py --output build/browser-text-metrics`.
118+
It uses our emitted modules and helpers in a worker; source parsing remains the
119+
separate independent-interpreter milestone.

‎docs/compiler-async.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,18 +79,18 @@ AbortSignal delivery cannot interrupt busy synchronous execution on its own.
7979
In a worker, the trusted main thread must retain worker termination as the stop
8080
path. The [host protocol](https://github.com/outside-labs/pocket-python/blob/main/docs/browser-host-protocol.md)
8181
uses session/request IDs, serialized tagged values, exactly one terminal result
82-
and CANCELLED/TIMEOUT replies. A future transport adapter must wrap a pending
83-
host result as an adopted Promise, forward cancellation to its request, discard
84-
stale replies and release handles. Raw Promise/Event/DOM objects do not cross
85-
that transport. HOST-01 specifies that adapter; this task does not implement it.
82+
and CANCELLED/TIMEOUT replies. The [browser profile](compiler-browser.md) wraps a pending
83+
host result as an adopted Promise, forwards cancellation to its request, discards
84+
stale replies and releases handles. Raw Promise/Event/DOM objects do not cross
85+
that transport. HOST-01 defines the shared contract; the async-only profile does not itself provide that adapter.
8686

8787
## Fetch boundary
8888

8989
CPython code continues to use the published `fetch_api.fetch` implementation
9090
re-exported by `JavaScript`, including its thread-backed Promise and HTTP types.
9191
Those implementations are not copied into compiled artifacts. `fetch`, Request,
9292
Response, streaming bodies and response JSON methods remain rejected by this
93-
compiler profile. BROWSER-01 adds the separately granted `fetch/1` text operation,
93+
compiler profile. The [browser profile](compiler-browser.md) adds the separately granted `fetch/1` text operation,
9494
lowering to browser-native Fetch through the trusted host with AbortSignal,
9595
origin/CORS, URL, credential, redirect, size and deadline rules. Its first text
9696
metrics UI requires no network access. Streaming, async iterables and standalone

‎docs/compiler-browser.md‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Compiled browser target
2+
3+
The development checkout provides `JavaScript.browser.compile_browser`. It
4+
compiles verified [local source modules](compiler-modules.md), enables the
5+
[async profile](compiler-async.md), and records the complete local worker/host
6+
asset closure. It is not included in the published 0.1.0 distribution.
7+
8+
The target is `python-browser-modules-host-async/1`, compiler ABI
9+
`python-browser-host-esm/1`, using host protocol `pocket-python-host` version 1.
10+
Python computation executes as our emitted JavaScript and compatibility helpers
11+
inside a module worker. No interpreter, server execution or alternate engine is
12+
loaded. New-source parsing remains the independent interpreter's later milestone.
13+
14+
## Run the example
15+
16+
From this checkout:
17+
18+
```sh
19+
uv sync --locked
20+
uv run --no-sync python examples/browser_text_metrics.py --output build/browser-text-metrics
21+
python -m http.server 8000 --bind 127.0.0.1 --directory build/browser-text-metrics
22+
```
23+
24+
Open `http://127.0.0.1:8000/`. Run counts Unicode characters, ASCII-separated
25+
words and LF-based lines. Stop terminates the worker; Restart creates a fresh
26+
session. The example enables Run after its callback is registered. Computation
27+
works offline after loading. Offline reload, service-worker caching and PWA
28+
assembly are WEB-03, not part of this example builder.
29+
30+
The builder reads the approved text_metrics corpus, emits selected local modules,
31+
source maps/notices, the host/worker/protocol modules, a manifest and a small HTML
32+
entry. It requires a new output directory and preserves existing outputs.
33+
The same computation can be imported under CPython in a separate server entry;
34+
client entrypoints and host adapters are explicit. E2E-01 owns the complete
35+
server/client packaging demonstration.
36+
37+
## Python namespace and entry
38+
39+
Only named imports from the compiler's `JavaScript.browser` namespace are
40+
adopted. They belong in the client entry; they are not CPython DOM functions.
41+
42+
```python
43+
from JavaScript.browser import on_click, read_text, write_text, fetch_text
44+
from text_metrics import metrics
45+
46+
async def display(text):
47+
print(metrics(text))
48+
49+
async def main():
50+
await on_click("run", display)
51+
```
52+
53+
| Function | Adopted behavior |
54+
| --- | --- |
55+
| `read_text(control)` | Await the current text/value of a named granted control; return Python str. |
56+
| `write_text(control, text)` | Await replacement with plain text; return None. |
57+
| `on_click(control, callback)` | Register a known compiled synchronous/async function taking one snapshot argument; return an opaque subscription handle. |
58+
| `unsubscribe(subscription)` | Remove the listener and release its callback; return None. Reuse raises STALE_HANDLE. |
59+
| `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. |
60+
61+
Every call returns an adopted Promise that source code awaits. Aliases retain
62+
binding identity. Str arguments stay explicit, and status integers remain BigInt.
63+
There is no implicit JSON/dict/Number conversion. Imports must be unconditional;
64+
module initialization contains imports, definitions, immutable constants, pass or
65+
docstrings. Browser entry requires a directly defined async `main()` without
66+
required arguments. This prevents host calls before the ready handshake.
67+
68+
The original JavaScript Array/Promise registry keeps its own contract.
69+
`from JavaScript import fetch`, arbitrary globals/attributes, selectors, innerHTML,
70+
DOM nodes, POST/options/headers, streaming and response JSON APIs remain outside
71+
this language profile. CPython uses the published `fetch_api` companion through
72+
its server adapter; that implementation is not embedded in browser output.
73+
74+
## Main-thread configuration
75+
76+
Import `mountCompiledApplication` from the generated `browser-host.mjs`. Supply
77+
the verified application's manifest SHA-256, profile `compiled-javascript`/1,
78+
entrypoint `app.mjs`, and engine null. Bind grant names to trusted DOM Elements,
79+
provide a text output Element, and optionally map button names to input snapshot
80+
controls (otherwise the callback receives an empty str). The example supplies a complete configuration. Each required capability
81+
must be granted; source code cannot widen controls or URLs.
82+
83+
The adapter supports output/1, dom-text/1, dom-events/1, handles/1 and the adopted
84+
GET text subset of fetch/1. Files/persistence and unlisted capabilities fail
85+
initialization. No arbitrary host filesystem or native object is exposed.
86+
Fetch grants use exact normalized HTTP(S) URLs without embedded credentials or
87+
fragments. Native Fetch preserves origin/CORS checks, omits credentials, rejects
88+
redirects and enforces a bounded strict-UTF-8 response and deadline. Relative
89+
requests are normalized against the page URL before the exact grant check.
90+
91+
`ready` confirms protocol initialization, before asynchronous application setup
92+
finishes. `main()` then registers handlers. `stop()` aborts pending host work,
93+
removes listeners and terminates the worker. `restart()` stops the current
94+
session and returns a fresh controller. `stats()` exposes lifecycle and resource
95+
counts for integration tests. Optional `onError` receives bounded plain error
96+
metadata; the default output uses a text node. Application code never supplies
97+
HTML or arbitrary host properties to these operations.
98+
99+
## Ordering, lifetime and limits
100+
101+
The adapter implements the [HOST-01 envelope and lifecycle](https://github.com/outside-labs/pocket-python/blob/main/docs/browser-host-protocol.md)
102+
for this adopted surface: fresh 128-bit sessions, independent monotonic sequence
103+
counters, unique request IDs, bounded pending work and terminal replies. Stale
104+
sessions do not advance counters. Malformed active messages/replays close the
105+
session. Valid operation failures reject the source await with an original source
106+
location; an unhandled main/callback exception closes the application.
107+
108+
Events are serialized in a bounded queue. Output is ordered and bounded. A
109+
subscription owns its main-thread listener and worker callback until unsubscribe
110+
or teardown; already queued events for released subscriptions are discarded.
111+
Canceled/timed-out request slots remain bounded until terminal reply or teardown.
112+
Cancellation cannot undo a committed DOM/output effect. Busy synchronous code
113+
requires worker termination; the main thread remains responsive. Worker placement
114+
provides responsiveness isolation, not a general hostile-code security sandbox.
115+
116+
Defaults match HOST-01: 1 MiB messages/responses, value depth 64, 32 pending
117+
requests/subscriptions/events, 65,536 output code points and ten seconds per
118+
operation. Trusted configuration may lower limits. Oversized results return
119+
QUOTA. Stored Python surrogate points survive engine transport; projecting to
120+
DOM UTF-16 and reading back may combine an explicit surrogate pair. Error
121+
metadata retains its source span; full call-frame traceback construction is
122+
separately staged.
123+
124+
The manifest records source/output hashes, maps, notices, required capabilities
125+
and every static module/worker edge. Initialization echoes identity; it does not
126+
independently authenticate files. Deployment/assembly must verify the recorded
127+
assets against a trusted manifest before use. WEB-03 implements that application
128+
profile and cache/publication boundary.
129+
130+
## Verification
131+
132+
```sh
133+
uv sync --locked --group browser-tests
134+
uv run --no-sync python -m playwright install chromium
135+
uv run --no-sync python scripts/compiler_browser_smoke.py --report build/compiler-browser-report.json
136+
```
137+
138+
Use `--channel chrome` for an installed Chrome. Playwright 1.64.0 is a declared
139+
test-only group; existing runtime dependency versions are retained. CI tests
140+
installed wheel/source compiler APIs on Linux/macOS/Windows and runs the local
141+
browser fixture on Linux. The fixture covers CPython/client parity, loaded-worker
142+
offline computation, Fetch status/CORS/credentials/grants/redirect/size/deadline,
143+
source errors, forty callback allocation/release cycles, repeated restart,
144+
pending Fetch cancellation and busy-worker stop. The user example is also run
145+
under a URL subdirectory. Reports record the actual browser and target identity.

‎docs/compiler-language.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,7 @@ This build-time application compiler does not execute newly entered source in
104104
the browser. That is a separate independent-interpreter milestone. Shared
105105
fixtures/host protocols do not make the two engines' IR, bytecode or value models
106106
interchangeable.
107+
108+
The [compiled browser profile](compiler-browser.md) implements the adopted
109+
named controls, callbacks, native Fetch text and worker lifecycle. The original
110+
single-module language profile remains separately identified.

‎docs/compiler-modules.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,5 @@ The text_metrics parity fixture uses its supported metrics module and the
8787
reduced helper containing `character_count`. The original helper's unused slice
8888
utility remains unsupported and causes rejection if supplied unchanged. This
8989
compiler does not perform symbol extraction or silently skip unsupported code
90-
inside a selected module. Browser DOM/Fetch integration and runtime parsing of
91-
new source remain separate tasks.
90+
inside a selected module. The [browser profile](compiler-browser.md) adds DOM/Fetch integration. Runtime
91+
parsing of new source remains an independent-interpreter task.

‎examples/browser_text_metrics.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Build a local text-metrics browser application from the approved source corpus."""
2+
3+
import argparse
4+
import hashlib
5+
import json
6+
from pathlib import Path
7+
8+
from JavaScript.browser import compile_browser
9+
from JavaScript.modules import SourceModule
10+
11+
12+
ROOT = Path(__file__).resolve().parents[1]
13+
APP = '''from JavaScript.browser import on_click
14+
from text_metrics import metrics
15+
async def display(text):
16+
print(metrics(text))
17+
async def main():
18+
await on_click("run", display)
19+
'''
20+
21+
22+
def build(output):
23+
corpus = json.loads((ROOT / 'tests/fixtures/compiler_contract.json').read_text(encoding='utf-8'))
24+
reference = next(case['source'] for case in corpus['cases'] if case['id'] == 'text-metrics-single-module')
25+
computation = reference[:reference.index('\nprint(metrics(')]
26+
artifact = compile_browser((SourceModule.from_text('app', APP), SourceModule.from_text('text_metrics', computation)), entrypoint='app')
27+
identity = {'manifest_sha256': hashlib.sha256(artifact.manifest_bytes).hexdigest(), 'profile': 'compiled-javascript',
28+
'profile_version': 1, 'entrypoint': 'app.mjs', 'engine': None}
29+
html = '''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
30+
<title>Text metrics</title><style>
31+
body{font:1.1rem system-ui,sans-serif;max-width:44rem;margin:4rem auto;padding:0 1.5rem;color:#19362c;background:#f4f7f1}
32+
textarea{box-sizing:border-box;width:100%;min-height:12rem;padding:1rem;font:inherit;border:1px solid #779287;border-radius:.5rem;background:white}
33+
button{padding:.6rem 1rem;font:inherit;border:1px solid #779287;border-radius:.4rem;background:white;cursor:pointer}
34+
#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}
35+
</style><main><h1>Text metrics</h1><p>Count characters, words and lines locally in your browser.</p>
36+
<label for="input">Text</label><textarea id="input" placeholder="Write something…"></textarea>
37+
<nav><button id="run" disabled>Run</button><button id="stop">Stop</button><button id="restart">Restart</button></nav>
38+
<p>Characters, words, lines</p><pre id="result" aria-live="polite"></pre><p id="status" role="status"></p></main>
39+
<script type="module">
40+
import {mountCompiledApplication} from './browser-host.mjs';
41+
const byId=name=>document.getElementById(name);let current;
42+
function start(){
43+
current?.stop();byId('run').disabled=true;byId('result').textContent='';byId('status').textContent='Starting…';
44+
const active=mountCompiledApplication({application:APPLICATION,
45+
capabilities:[{id:'output/1'},{id:'dom-text/1',controls:['input']},{id:'dom-events/1',controls:['run'],events:['click']},{id:'handles/1'}],
46+
controls:{input:byId('input'),run:byId('run')},eventInputs:{run:'input'},output:byId('result'),
47+
onError:error=>{byId('status').textContent=error.type+': '+error.message;}});
48+
current=active;active.ready.then(async()=>{
49+
while(current===active&&active.stats().state==='ready'&&active.stats().subscriptions<1)await new Promise(requestAnimationFrame);
50+
if(current===active&&active.stats().state==='ready'){byId('run').disabled=false;byId('status').textContent='Ready';}
51+
},()=>{});
52+
}
53+
byId('stop').addEventListener('click',()=>{current?.stop();byId('run').disabled=true;byId('status').textContent='Stopped';});
54+
byId('restart').addEventListener('click',start);start();
55+
</script></html>'''.replace('APPLICATION', json.dumps(identity))
56+
files = {item.path: item.data for item in artifact.files}
57+
files.update({'COMPILED-MANIFEST.json': artifact.manifest_bytes, 'index.html': html.encode('utf-8')})
58+
output = Path(output)
59+
if output.exists() or output.is_symlink():
60+
raise ValueError('Choose a new output directory.')
61+
output.mkdir(parents=True)
62+
written = []
63+
try:
64+
for name, data in files.items():
65+
path = output / name
66+
with path.open('xb') as stream:
67+
written.append(path)
68+
stream.write(data)
69+
except BaseException:
70+
for path in reversed(written):
71+
path.unlink(missing_ok=True)
72+
try:
73+
output.rmdir()
74+
except OSError:
75+
pass
76+
raise
77+
return artifact
78+
79+
80+
def main():
81+
parser = argparse.ArgumentParser(description=__doc__)
82+
parser.add_argument('--output', type=Path, required=True)
83+
args = parser.parse_args()
84+
try:
85+
build(args.output)
86+
except ValueError as error:
87+
parser.error(str(error))
88+
print('Created ' + str(args.output))
89+
90+
91+
if __name__ == '__main__':
92+
main()

‎pyproject.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,16 @@ module-name = "JavaScript"
3333
source-include = [
3434
"tests/test_*.py",
3535
"tests/fixtures/conformance.mjs",
36+
"tests/fixtures/browser_protocol.mjs",
3637
"tests/fixtures/compiler_contract.json",
3738
"docs/compatibility.md",
3839
"docs/compiler*.md",
3940
"scripts/install_distribution.py",
41+
"scripts/compiler_browser_smoke.py",
42+
"examples/browser_text_metrics.py",
4043
"CHANGELOG.md",
4144
"RELEASING.md",
4245
]
46+
47+
[dependency-groups]
48+
browser-tests = ["playwright==1.64.0"]

0 commit comments

Comments
 (0)