Skip to content

ci(examples): deploy the stateless examples to demo.pywire.dev - #392

Merged
reecelikesramen merged 144 commits into
mainfrom
claude/project-thread-3ala5u-demo-site
Sep 30, 2026
Merged

reecelikesramen merged 144 commits into
mainfrom
claude/project-thread-3ala5u-demo-site

Conversation

@reecelikesramen

@reecelikesramen reecelikesramen commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Needs #400 (merged): the example Workers use main = "src/entry.py", which #400 teaches pywire build to 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:

  • Each example has a committed wrangler.toml (Worker pywire-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.yml runs on pushes to main (paths filtered) and by hand. It deploys examples/demo-site with wrangler. For each example it builds the client bundle, runs uv run pywire build --platform cloudflare-edge, then uv run --with workers-py pywrangler deploy.
  • UV_NO_EDITABLE=1 makes pywrangler vendor a real copy of the workspace's pywire rather than an editable link, so the demos run main's source.
  • The form builder gets CLIENT_IP_HEADER=cf-connecting-ip, which Cloudflare sets itself.
  • realtime waits for the shared Durable Object target (fix(pywire-templates): run Cloudflare apps in regional Durable Objects #393). taskboard needs a database, so it's left out.

Order:

  1. Merge pywire.dev#18 and run Infra apply. That creates the Workers, routes and DNS.
  2. Reece gives this repo's CLOUDFLARE_API_TOKEN the account-level Workers permission at Editor.
  3. Merge this. The first run uploads the code.
  4. Reece sets the Worker secrets: PYWIRE_SECRET_KEY on both example Workers, plus GROQ_API_KEY and TYPESAFE_API_KEY on pywire-demo-form-builder (commands in examples/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 dev in 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

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
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
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
Base automatically changed from claude/project-thread-3ala5u to main September 30, 2026 00:29
…-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
@reecelikesramen
reecelikesramen marked this pull request as ready for review September 30, 2026 01:37
@reecelikesramen
reecelikesramen merged commit 0770d9f into main Sep 30, 2026
22 checks passed
@reecelikesramen
reecelikesramen deleted the claude/project-thread-3ala5u-demo-site branch September 30, 2026 01:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants