Repository navigation
ci(examples): deploy the stateless examples to demo.pywire.dev - #392
Merged
Merged
Conversation
Compiled .wire pages and components now carry `__event_handlers__`, a frozenset of frontmatter function names plus generated `_handler_N` wrappers. `BasePage._dispatch_handler` refuses any resolvable name outside it, so base methods (render, navigate, push_state), instance attributes (attrs) and dunders can no longer be invoked by a client-supplied handler name. Unresolvable names are still silently ignored (stale DOM after hot reload). Hand-rolled BasePage subclasses keep `None` and stay permissive. BREAKING CHANGE: event dispatch of a name that is not a registered handler on a compiled page or component now raises ValueError instead of invoking the attribute.
Excluded from __event_handlers__: @init, @mount, @unmount, @before_load, @before_update, @after_update, @error, @effect, @derived, and @expose (@expose methods are reached server-side via ComponentRef attribute access, never dispatched by client name; component on_* callbacks are plain callables passed as props). A def the template wires directly as a bare event handler stays listed. The allowlist is now emitted after _transform_user_code so the collected hook lists are populated. Tests: framework-invoked defs refused, template-wired defs dispatchable, component-path enforcement (_comp:<key>:render/attrs/__init__/@expose refused, real handler + on_* callback works), unknown names silently ignored.
…ndpoint POST /_pywire/stateless rebuilds the page per request (resolve, instantiate, restore signed snapshot), re-resolves identity from the request only, dispatches through handle_event (allowlist enforced), drains background awaits up to await_budget, and returns the update payload plus a fresh snapshot. GET renders embed the snapshot script and flag stateless in SPA meta; WS/HTTP-session routes are not mounted in this mode. Also enforce the __event_handlers__ allowlist in _handle_form_post (routed from Task 5 review): X-PyWire-Handler names resolvable on the page but absent from the compiled allowlist (e.g. render) are refused with 400; hand-rolled pages (allowlist None) stay permissive.
…, and form-post allowlist - Finding 1: validate msgpack path is str (400 invalid path) and wrap build_page so stale/corrupt snapshots and resolve crashes yield 400 invalid snapshot instead of a 500; non-ASCII path deterministically 404s - Finding 2: embed the snapshot by mutating the rendered Response in place (body + content-length) so Set-Cookie/status applied by page.render() survive; same fix for the cert-hash/upload-token injection block - Finding 3: pre-check __event_handlers__ (page and _comp) before dispatch and drop the over-broad except ValueError, so business ValueErrors reach the 500 path with logger.exception while probing clients get 400 - Finding 4: guard the _comp: branch of _handle_form_post with the component's __event_handlers__ allowlist (None = permissive hand-rolled) - Finding 5: pin msgpack error body shape in tampered-snapshot/no-route/ unlisted-handler tests
…; locked wires survive restore Finding 1: WebTransport bypasses the route list and middleware stack, so unmounting routes left stateful interactive sessions live on HTTP/3 edges. Never instantiate WebTransportHandler when stateless=True; the __call__ gate falls through to Starlette on web_transport_handler is None. Finding 2: restore_page_state and _resolve_component snapshot application wrote client-supplied values into locked wires (stale snapshots signed before a .lock() deploy; component restore replaced the locked wire with a fresh unlocked one, re-emitting the secret). Both now skip attrs whose current value is a locked wire — the frontmatter value always wins. Finding 3: forged non-dict inner 'data' on the stateless endpoint died mid-dispatch as a 500; guarded like 'path' → 400 'invalid data'.
Pins Spec #6: single-item toggle on a 1000-row keyed list ships ONE wrapper-inclusive region (251 B html, 304 B msgpack) via handle_event AND via the stateless POST /_pywire/stateless path (transport parity, 251 B). Keyless contrast test proves the bound is pinned by key= (whole-loop region: 180,827 B on 1000 rows). Playwright e2e: MutationObserver shows all mutations confined to the #500 wrapper, rows 499/501 are isSameNode before/after, structural append still full-loop patches (1001 rows). bench_engine.py re-run, 5000-row scenario (before/after): - keyless baseline (scratch/adhoc/bench_keyless_toggle.py, same page source without key=): region_html=912,817 B, payload=912,869 B (~913 KB) - keyed (bench_engine.py): toggle 1 row -> regions=1, region_html=254 B, payload=308 B; toggle round-trip mean=29.1us; full re-render payload=1,271,879 B unchanged - 1000-row: region_html=251 B, payload=304 B; full render=252,254 B Event dispatch uses the REAL client shape: data-on-click handler name + {"type": "click", "tagName": "BUTTON", "args": {"arg0": N}} (client getArgs JSON-parses dataset arg* keys), not the brief's guessed {"args": {"i": N}}.
…njection
Critical (Phase 3 review): the keyed {$for} region key was interpolated
UNESCAPED into data-pw-region in both the per-iteration wrapper and the
_pw_item_<site> renderer, allowing attribute injection / stored XSS via
key=user["name"]. _keyed_wrapper_str now emits escape_html(key); HTML
attribute parsing round-trips the escaped value back to the raw site#key
id, so server region matching and the client selector keep working
(pinned by test_special_char_key_is_escaped_and_round_trips). Keys
containing quotes, backslashes or whitespace/control characters cannot
round-trip through querySelector('[data-pw-region="<id>"]') regardless
of escaping, so they now raise ValueError at render time alongside the
existing duplicate-key guard (pinned by test_hostile_keys_are_refused).
Minor: pin WireList.__iter__ mutation-during-iteration semantics
(test_list_iter_index_walk_tolerates_mutation). The index walk exists to
yield stable per-slot child proxies that keyed regions depend on; it
tolerates mutation (remove shifts later items left, the shifted-in item
is skipped) instead of raising. Restoring list.__iter__ would drop proxy
identity and break keyed per-item invalidation, so the behavior is kept
and documented.
Bound forms read only the fields the page rendered with $bind. Every other model field keeps its initial value or the model default, and rows the page didn't render are ignored, so a POST like items.999999.name no longer renders a million rows. The rendered set is kept per form like the read-only set and travels in snapshots. Native form POSTs: - render the page first and stop where a GET stops: a @before_load guard that raises or navigates now stops the POST before any dispatch - read the body with limits (1 MB of fields plus 10 files of max_upload_size, counted as it arrives) and read file parts only after the handler is accepted - reach forms in components nested in components and layouts - mark an invalid form's 422 with X-PyWire-Form so the client only morphs those - compare Origin by host name, ignoring the port Also: - a write inside a nested component re-renders the page - non-interactive sessions restore state only into the page that saved it, and never restore the user - validator messages are shown as written, so braces in input no longer break them - @submit on a bound form can name a callback prop - defs named _handler_* or _handle_bind_* are refused, as is a bound form's handler wired to another event, and a <form $bind> on a $for variable - {**data} in a plain @submit handler gets the form fields Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
Brings #338's review fixes, including rendered-only bound forms. To keep wizards, rows and no-JS posts working under rendered-only, every bound form now posts a signed state input (what it rendered, row counts, and a wizard's earlier steps) as its last child; it is bound to the form's model and page path and expires after a day. Wizards read only their rendered fields, and read-only ones keep the server's value (M1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
Text input is debounced by default, so an event typed on one page could fire after an SPA navigation and run the next page's handler of the same name. The client now sends what the page owes before it navigates (and on pagehide), drops events while the next page is on its way, and stamps each event with the page the DOM shows. The server drops an event stamped with another page and answers it with an empty update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
- The upload endpoint counts the body as it arrives (a chunked request has no Content-Length), takes at most 10 files, and holds each file, not the whole request, to max_upload_size, so several small files no longer fail together. Too many files is a 400; other failures no longer echo the exception. - A file uploaded with a page's token resolves only when the reference carries that token, so another browser can't claim it by id. Wizards carry ids in signed state, which is trusted. - Staged files are deleted when the request that sent them fails. - Upload references are resolved only for a handler that may run. - The client uploads again instead of reusing an id older than 30 minutes, before the server's staging expires it. - The Durable Object template passes the app to resolve_page, so its pages use the configured upload store instead of a per-process one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
- Removing a row moves what belongs to the rows after it: rendered names, row counts of nested lists, and a wizard's carried files, so a removed row's file no longer lands on the next row or fails a step. - An error a wizard's handler sets on an earlier step's field takes the wizard back to that step, where it shows. - list[SecretStr] is a secret: never captured, snapshotted or carried, and refused on an early wizard step. - Row indexes must be ASCII digits everywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
- A submitting bound form stays busy until the reply to its own submit (or its failure) arrives; a live check's or a poll's update no longer lets a second submit through. - A submit clears the form's typed-in fields, so leaving a field the handler reset doesn't show "required". - Waiting debounced and throttled events flush in the order they happened, as the docs say. - A configured timing without a duration (`"keyup": "debounce"`) waits the documented 250ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
- A wire is written only through an element the page rendered, and not one rendered disabled, readonly or type="hidden"; a <select> or a checkbox sets only the values it offered. - An enum wire takes the member with the posted value (and its <option> shows selected), date and time wires parse ISO values, and NaN or infinity never reach a float or Decimal wire. - $bind on a $for variable that shadows a frontmatter wire no longer writes the page's wire; it is refused with an explanation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
- A secret_key shorter than 32 bytes no longer signs form state (it could be guessed, and form state decides which fields are read); the app warns and signs with a per-process key. Without a secret_key, the first signed form state logs that no-JS posts only verify in the process that rendered them. - Storage keys can't contain ":", so "C:/..." can't name a Windows drive. - ObjectStore over obstore's file:// store no longer fails every put: it keeps no attributes, so none are sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
- Staged uploads and upload tokens live in a folder only this user can read, one per user, and PyWire refuses a folder another user created. - Upload tokens are swept when pages issue them, not only on upload, so page views alone no longer pile up token files. - save() never gives a random key a page, SVG or script extension the file wasn't declared as: evil.html sent as image/png is kept as .png. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
A plain @submit handler that returned its data, unpacked it or used it in an f-string got an empty form, because the field analysis only saw attribute and key reads. Any use it doesn't recognise now sends every field. Also notes in the forms guide that non-interactive mode keeps page state for the last page served only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
- Page upload tokens are signed instead of stored, so a page view writes nothing. A token is only written down once an upload binds it to a session. The key comes from secret_key, or from a random key file in the private runtime folder that processes on one machine share. - A plain @submit handler gets files only under the names of the page's file inputs; an upload reference under any other field is dropped. Bound forms already keep only their model's file fields. - Signed form state is accepted for an hour after the form was shown (the time staged uploads are kept), down from 24 hours, which bounds how long an old wizard state can be replayed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVwTCj5HkgW4yYuXn1M5TM
# Conflicts: # docs/src/content/docs/guides/forms.md # examples/demo-app/src/pages/forms_test.wire # packages/pywire-language-server/src/pywire_language_server/transpiler.py # packages/pywire-language-server/tests/test_transpiler.py # packages/pywire/pyproject.toml # packages/pywire/src/pywire/__init__.py # packages/pywire/src/pywire/compiler/codegen/generator.py # packages/pywire/src/pywire/compiler/codegen/template.py # packages/pywire/src/pywire/components/__init__.py # packages/pywire/src/pywire/forms/__init__.py # packages/pywire/src/pywire/forms/errors.py # packages/pywire/src/pywire/forms/form.py # packages/pywire/src/pywire/forms/render.py # packages/pywire/src/pywire/forms/schema.py # packages/pywire/src/pywire/forms/shape.py # packages/pywire/src/pywire/runtime/app.py # packages/pywire/src/pywire/runtime/files.py # packages/pywire/src/pywire/runtime/page.py # packages/pywire/src/pywire/runtime/upload_manager.py # packages/pywire/tests/test_forms_bind.py # packages/pywire/tests/test_forms_http.py # packages/pywire/tests/test_forms_pipeline.py # packages/pywire/tests/test_forms_schema.py # uv.lock
…aude/project-thread-3ala5u
pywire now drops an event whose path names another page, and the helper sent none, so events defaulted to "/" and never reached /chat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUEPGc2SgMUHxx4Z28HS8v
Groq (gpt-oss) writes the fields, Jev (TypeSafe System One) decides each field's type and whether it's required, and pywire builds a Pydantic model and a live bound form from the result. One model call per @poll tick, so it runs stateless (Cloudflare Workers) as well as over the WebSocket. Field types come from a fixed whitelist, every string is capped, forms asking for passwords, payment or ID details are refused, and each visitor is rate limited by cookie and IP. Usage or key errors from either service show one message: the demo's author is out of usage, try again later. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUEPGc2SgMUHxx4Z28HS8v
Main now carries the final #345. Conflicts in its files take main's side. The select keeps both rules (form reset and server selection), and the dev server serves a PyWire app itself so its base_path applies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUEPGc2SgMUHxx4Z28HS8v
A landing page on demo.pywire.dev and a Worker per stateless example at demo.pywire.dev/<name>, built from main's pywire by examples/scripts/deploy-demo and redeployed by the Deploy Examples workflow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUEPGc2SgMUHxx4Z28HS8v
…-3ala5u-demo-site # Conflicts: # examples/README.md
Drops examples/scripts/deploy-demo. Each deployed example commits its wrangler.toml and keeps pywire[cli] in its dev group, and the workflow runs pywire build and pywrangler deploy in the example's folder. Routes move to pywire/pywire.dev's Terraform. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUEPGc2SgMUHxx4Z28HS8v
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUEPGc2SgMUHxx4Z28HS8v
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUEPGc2SgMUHxx4Z28HS8v
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Needs #400 (merged): the example Workers use
main = "src/entry.py", which #400 teachespywire buildto keep. The Workers themselves, their routes and DNS are created by pywire/pywire.dev#18.Before: none of the examples ran anywhere public.
After: demo.pywire.dev has a landing page listing the demos, the form builder at demo.pywire.dev/form-builder/, and the stateless demo at demo.pywire.dev/edge-stateless/. Every push to main that touches those examples or pywire itself redeploys them, so the demos always run main.
Each example deploys from its own folder, the same way a create-pywire-app project would. There is no staging step. Examples aren't released, so release-please isn't involved.
How:
wrangler.toml(Workerpywire-demo-<name>,main = "src/entry.py",PYWIRE_BASE_PATH=/<name>) with code and vars only. Terraform in pywire.dev creates the Worker and its routes, the way it creates the docs Pages project.pywire[cli]sits in each example's dev group, so the Worker bundle holds only runtime dependencies..github/workflows/deploy-examples.ymlruns on pushes to main (paths filtered) and by hand. It deploysexamples/demo-sitewith wrangler. For each example it builds the client bundle, runsuv run pywire build --platform cloudflare-edge, thenuv run --with workers-py pywrangler deploy.UV_NO_EDITABLE=1makes pywrangler vendor a real copy of the workspace's pywire rather than an editable link, so the demos run main's source.CLIENT_IP_HEADER=cf-connecting-ip, which Cloudflare sets itself.Order:
CLOUDFLARE_API_TOKENthe account-level Workers permission at Editor.PYWIRE_SECRET_KEYon both example Workers, plusGROQ_API_KEYandTYPESAFE_API_KEYonpywire-demo-form-builder(commands inexamples/README.md). The examples return errors until then.Worth knowing: the form builder's rate limiter keeps its counts in memory. On Workers each isolate has its own memory, so a visitor can get more than 10 builds per 10 minutes. The out-of-usage message still covers the case where the keys run dry.
Tests: in examples/form-builder I ran the build and
UV_NO_EDITABLE=1 uv run --with workers-py pywrangler devin local workerd. The page, CSS and client loaded under/form-builder/, stateless POSTs returned 200, and a build with a fake key showed the out-of-usage message. A dry-run upload is 2.5 MB gzipped. The Examples check passes.🤖 Generated with Claude Code