Self-hosted live-demo system for Handsontable. Renders every example live at any
Handsontable version, lets the internal team edit code in the browser, and mints
permanent shareable/embeddable links. Also serves every documentation-guide
example (opened via ?docs= URLs — see docs/docs-examples.md). Replaces
CodeSandbox/StackBlitz and the former render-ms microservice (removed). Lives in
the runner/ folder of handsontable/examples.
Production: https://demos.handsontable.com, on the main Handsontable Cloudflare
account (not the sandbox). wrangler whoami tells you which account you are pointed at.
git clone git@github.com:handsontable/examples.git
cd examples/runner
pnpm install # pnpm monorepo; Node 20+Work happens on a feature branch off master, opened as a PR against handsontable/examples.
Cursor Bugbot auto-reviews each PR push.
| Path | What it is |
|---|---|
apps/authoring/ |
The web app (Vite + React 19). SPA served as Cloudflare Workers Assets → worker handsontable-demos-authoring. |
packages/editor-shell/ |
Framework-agnostic editor UI: toolbar, file tree, code editor, preview pane, theme. Most UI/UX lives here. |
packages/runtime/ |
The DemoRuntime engines: sandpack.ts (Tier-1, in-browser bundler) and container.ts (Tier-2, live Cloudflare Sandbox container). Plus version.ts (HOT version dispatch). |
workers/api/ |
Orchestration + sharing worker handsontable-demos-api (sessions, /api/demos, /d, /embed, build snapshotter, /api/chat, and /api/mcp/demos + PATCH /api/mcp/demos/:id + GET /api/mcp/demos/:id/status — headless create/update for the Handsontable MCP, ADR-0033; cold tier-2 builds run detached via the BuildJob DO, ADR-0039). |
config/frameworks.json |
Single source of truth per example (tier, engine, wrappers, entry). pipeline/import.mjs → starter buckets + the catalog.json index. Key order is picker order. synthetic: true = no examples/ directory; pipeline/blank-starters.mjs generates the files per bucket (the blank templates, ADR-0030). |
apps/authoring/public/docs-examples/ |
Generated documentation-guide examples (manifest + one CatalogEntry JSON each). pipeline/import-docs.mjs + wrap-docs-example.mjs. |
apps/authoring/public/starter-examples/ |
Generated starter buckets, one per Handsontable major plus next (DEV-2213), each pinned to a concrete version. pipeline/import.mjs; catalog.json is only the files-free index. |
containers/, scripts/ |
Live/builder Dockerfiles + baked deps; prepare-container.mjs, warm.ts. |
docs/ |
Architecture, ADRs, run/deploy notes. docs/guide/*.md is the in-app guide at /guide — four role-based tracks, rendered raw (ADR-0034); edit the markdown, not a .tsx. |
/— public playground (pick an example, edit, live preview). Logged-in users see Fork./edit/:id— login-gated editor bound to a saved demo: edit title/description/code + Save (rebuilds the snapshot) + Share./share/:id— public, read-only-persistence playground: browse/edit files on the fly + Download (.zip of current edits), but no Save and version is locked./d/:id— the built static demo (served from R2, framed by/share)./embed/:id— docs-only embed, frame-locked tohandsontable.com./admin— login-gated internal usage + cost panel (docs/cost-guardrails.md).
Most visual work is in packages/editor-shell/src/:
theme.ts— colors/spacing/fonts. Brand accent is Handsontable blue#1A42E8.Toolbar.tsx,FileTree.tsx,EditorShell.tsx,CodeEditor.tsx,PreviewPane.tsx,styles.ts,logo.svg.
App-level chrome (top bars, share/edit modes, dialogs) is in apps/authoring/src/:
App.tsx, MyDemos.tsx, ShareLinks.tsx.
The editor shell is consumed as TypeScript source by the app (Vite compiles it) — no separate build step. Edit shell files and the app picks them up on hot-reload.
pnpm dev (Tier 1), pnpm dev:live (+ the API worker, Docker), pnpm dev:full
(+ o11y, compose, the Slack capture server) — one orchestrator
(scripts/dev.mjs --tier=1|2|full) behind all three; pnpm o11y:dev is a
separate standalone o11y-only command. Full details, every port/env var, and
the .dev.vars bootstrap rules are in docs/run-and-deploy.md's "Run
locally" section — this is just the two traps worth knowing up front if you
ever run a piece by hand instead:
?mode=fullneeds the SPA and the worker on one origin.serveDemoAssetsendsframe-ancestors 'self'+X-Frame-Options: SAMEORIGINfor/d/:idand is not wrapped incors(). Production is same-origin so it works there; pointVITE_API_BASEstraight at:8787and the iframe is refused ("localhost refused to connect") and the status probe fails CORS, which renders as● erroron a demo that is fine.vite.config.tsproxies/api,/dand/embedto the worker for exactly this reason — keepVITE_API_BASEon the dev server's own origin. An empty value does not work:App.tsxfalls back to:8787on any falsy value. (pnpm dev:live/dev:fullset this correctly for you, as process env, not a file.)VITE_DEV_USERshort-circuitscurrentUser()before the fetch. Any test of the real broker path has to override it (VITE_DEV_USER= pnpm --filter @handsontable/demo-authoring dev) or it silently exercises the bypass instead.
pnpm --filter @handsontable/demo-runtime build
pnpm --filter @handsontable/demo-editor-shell typecheck
pnpm --filter @handsontable/demo-authoring typecheck
( cd workers/api && npx wrangler deploy --dry-run ) # typechecks the worker + builds the imageThe demo-runtime build is first on purpose: apps/authoring typechecks against
packages/runtime/dist, not its source, so a stale dist fails on symbols the source has.
Testing rules live in docs/TESTING.md — the discipline
(intent-first, never fake green), the which-test-where table, the env-gate
taxonomy, and the house assertion idioms — plus the two skills
(.claude/skills/runner-test-discipline/, .claude/skills/runner-playwright-e2e/).
A PR that changes runner/{apps,packages,workers}/** source must also change a
test — machine-enforced by the presence gate (scripts/check-test-presence.mjs,
the presence job in ci.yml; escape via a Refactor-only:/Test-plan: trailer).
The quick list — what a green E2E run does and does not prove:
- The specs that actually mount Sandpack are gated behind
E2E_LIVE=1. A defaultplaywright testskips every one, so a green default run proves nothing about preview mount/teardown. - A persistence bug is invisible to a within-page test. It only shows up in a spec that reloads
and re-measures. Watch for an
addInitScriptthat clears the storage key — it runs onpage.reload()too, silently defeating the assertion it was meant to isolate. Each test already gets a fresh context, so no reset is needed. - Interaction states need a real pointer and
getComputedStyle— see ADR-0026. A syntheticmouseoverdoes not fire CSS:hover, and a screenshot cannot tell a subtle live hover from a dead one. - Backend-bound specs self-gate on
E2E_BASE_URL(share-view.spec.tsand friends):vite previewhas no/api,/dor/embedroutes, so they skip unless pointed at a deployment.e2e/share-view.spec.tsadditionally depends on a permanent fixture demo (FIXTURE_IDin the spec — currentlyr-react-18-0-0). Never revoke it; if it is lost, mint a replacement titled "E2E fixture — do not revoke" from any signed-in session and update the constant. - The authed write round-trip needs
E2E_API_TOKEN(share-create-live.spec.ts, and the session-leak spec rides the same step): a persistent API token minted on the deployed app's/api-tokenspage (DEV-2583, ADR-0037). It never expires, so the spec self-skips only when the secret is absent — a token that no longer validates fails the run, because that means somebody revoked it. The spec injects it assessionStorage.hot_tokenand the client resolves identity for it against our own API, which is what keeps the real Share button under test. It creates one real demo and revokes it in anafterEach(the 410 doubles as the revocation assertion). Because the credential has no expiry, that step keeps--trace offand scrubs its artifacts — a leaked trace from this repo, which is public, would leak a live token. E2E_AI=1gates the live LLM answer checks (ai-live.spec.ts): two API-level calls per run, real budget, shared 8/min-per-IP rate bucket — a 429 skips rather than fails.
Merging to master deploys automatically (see "CI/CD" below). To deploy by hand from
each dir:
# API worker
cd workers/api && npx wrangler deploy
# Authoring app
cd apps/authoring && pnpm --filter @handsontable/demo-authoring build && npx wrangler deployVITE_API_BASE at build time:
apps/authoring/.env.production(committed, no secrets) →VITE_API_BASE=https://demos.handsontable.com.apps/authoring/.env.local(gitignored) holds the dev bypass (VITE_DEV_USER) + local API base.
Always build prod with .env.local absent or the dev-login bypass leaks into the bundle.
After building, sanity-check:
grep -rl "localhost:8787\|VITE_DEV_USER\|dev@handsontable.com" apps/authoring/dist && echo BAD || echo OKVITE_API_BASE itself is not the risk — .env.production is mode-specific, so it outranks
.env.local and the base compiles to demos.handsontable.com either way. dev@handsontable.com
is what catches a leaked .env.local; localhost:8787 catches a missing .env.production
(the || "http://localhost:8787" fallback in App.tsx surviving into the bundle). Don't widen
that term to a bare localhost: — catalog README text mentions dev-server ports and it false-fires.
This grep does not, and does not need to, cover VITE_TELEMETRY_LOCAL (a separate local-only
build-time flag, .env.example) — scripts/check-telemetry-leak.mjs is the dedicated check for
that one, run right after the production build in CI.
Six workflows live in .github/workflows/ at the repo root:
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
every PR (+ manual dispatch) | the CI DAG: presence + unit → build → authoring → e2e (in the pinned Playwright container). PRs are the only place the full suite runs — master does not repeat it. |
master.yml |
every push to master (or manual dispatch with per-target checkboxes) |
deploy-first: one shared build job (workspace + authoring, artifacts reused), then path-gated deploy-authoring/deploy-api ship the downloads — a broken build still never reaches wrangler — then one @smoke E2E run against prod. Merge-skew is covered by branch protection ("require branches to be up to date"), not by re-running the suite. |
e2e-live.yml |
manual, nightly canary (03:00 UTC, prod + AI), or workflow_call with smoke: true from master.yml |
everything ci.yml cannot run: live renders, container suites, the share viewer/round-trip, AI answer checks. Dispatch inputs: base_url, ai, pkg_pr_new_ref (DEV-2198). |
e2e-starter-matrix.yml |
manual + nightly (01:00 UTC) | every starter × major through a live session; serialized against the global container cap. |
import-docs.yml |
manual, or repository_dispatch: docs-examples-sync from the docs repo |
re-imports the documentation-guide examples. |
import-starters.yml |
manual, repository_dispatch: starter-examples-sync, weekly cron, or push touching examples/** |
re-imports the versioned starter buckets (each from prod-examples/<major> when the branch exists, else master), rebuilds the catalog index + container contexts, opens a PR. |
The two deploy workflows authenticate with a repository secret (CLOUDFLARE_API_TOKEN);
no credential is committed. CI reads the pnpm version from runner/package.json so it
does not pick up the repo-root manifest.
Comments state what the code cannot say for itself, once. Review and fix history belongs in the commit message and the PR, not in the source. This applies to people and agents alike, and reviewers treat a violation as a finding.
- No review ids. No finding, round, task or review ids (
F32,A-C1,R9,T06, "fix round", "per the reviewer") in comments or test titles. Keep contract and ADR pointers (contract §5,ADR-0041 §L.9) and ClickUp ids (DEV-NNNN) of work that is still open. - Present tense. Describe the code as it is: no "used to", "before this fix", "the old …", "was changed", "fails without the fix", and no revert-check evidence.
- No tombstones. When you delete code, delete the comments that talk about it.
- One sentence of why. Say why a guard, constant or ordering exists, with the measurement when it sets a limit, and stop. Leave out the alternatives you considered, the failure story, a restatement of what the code plainly does, and rationale that is already written down elsewhere (point to it).
- Length caps. A file header is at most 6 lines, a constant or field doc at most 3, a function doc at most 8. Only an ordering or concurrency proof may run longer, by up to about 6 lines.
- Density. In
apps/authoringandscripts, stay near the existing code's density, about 0.4 comment lines per code line. - Tests. A test title states the behaviour, with no id. A block comment is worth it only for a non-obvious setup or oracle, under the same caps.
- Docs (contract, ADRs, runbook) record rules and decisions, not the story of how an issue was found.
- No secrets in git. Auth is the Handsontable Google login broker (per-user token, sessionStorage). Dev bypasses live only in gitignored
.env.local/.dev.vars. - Use wrangler against the main CF account for any Cloudflare resource (D1
DB, KVCACHE, R2ARTIFACTS). - Keep the CodeSandbox hosted Sandpack bundler (self-hosting it stack-overflows on HOT v18).
- Blank starters (DEV-2499, ADR-0030):
blank/blank-ts/blank-reactare generated bypipeline/blank-starters.mjs, one variant per bucket — pre-17 buckets import the stylesheets and use the stringthemeName, 17+/next usetheme: mainTheme. After editing a template runnode pipeline/import.mjs --synthetic(regenerates only these, in every bucket) andnode scripts/prepare-container.mjs --generated-only(refreshesBUILD_CONFIGwithout touching the committed baked contexts). Keep them minimal —pipeline/blank-starters.test.mjsfails on a file or plugin creeping in. - FILES drag & drop (DEV-2500, ADR-0031) is text-only — a workspace is
Record<string, string>end to end, so a dropped image has nowhere to live. Refusals are reported, never silent;.env*is never accepted. The traversal lives inpackages/editor-shell/src/dropFiles.ts(no React, no DOM types) sopipeline/drop-files.test.mjscan drive it with fakes — a scriptedDataTransferreturnsnullfromwebkitGetAsEntry(), so the e2e spec only reaches the plain-filesfallback. - Imports are Handsontable-only (DEV-2504, ADR-0032).
POST /api/importpulls a workspace out of a JSFiddle or StackBlitz URL by parsing the page each one serves — undocumented payloads, sopipeline/import-url.test.mjspins both against fixtures recorded from real projects; when a provider changes its page that test is what fails.resolveSourceis the SSRF gate (exact hosts, https, rebuilt URL);assertHandsontableProjectis the product gate and belongs on every future whole-project entry point. CodeSandbox is refused on purpose: its API answers 403 behind a bot challenge, and we do not work around that. An import is a conversion, not a copy (DEV-2509): CDN<script>tags become npm dependencies imported under the global's own identifier, and Handsontable's CDN CSS becomes an npm import so the demo follows the version picker. Copying verbatim produced demos that could not run. - Tier-2 containers stay warm while a tab is open (client keepalive +
sleepAfter=5m); disk is ephemeral, so a slept container cold-boots on return. - Cost guardrails (DEV-2030, ADR-0022):
max_instances10/5 is the container cap and the live-preview concurrency limit (no queue sits in front of the pool; the overflow caller gets theat_capacity503) — raised from 5/3 in DEV-2909, and not to be changed again without redoing the arithmetic indocs/cost-guardrails.mdin the same commit. Spend degrades live sessions in stages while static shares keep serving. The dollar thresholds and the enforcement switch are editable at runtime in/admin(stored inrunner_settings); theBUDGET_*vars are only defaults. Pool pressure —at_capacityrefusals, awake-seconds and sampled peak concurrency — was specified by ADR-0040 and is delivered under ADR-0041 (built, on this branch), as Analytics Engine points rather than the hourly-bucketed D1 rows ADR-0040 originally proposed (docs/cost-guardrails.md): theat_capacitycounter inusage_dailyas ADR-0040 C.1 says (recordUsageEvent,workers/api/src/index.ts); awake-seconds assession.end'svalue(contract §5, per session, bucketable into an hourly view by the Grafana query rather than a dedicated hourly metric); and peak concurrency as thepool.gaugeAnalytics Engine point sampled every 5 minutes (telemetry/cron.ts#emitPoolGauge) — because daily totals cannot yield peak concurrency and that is what sizing the pool actually depends on. - Observability (ADR-0041, deployed to production): a self-hosted, sleeping Loki + Grafana box (
workers/o11y/,containers/o11y/) fed over OTLP, with metrics in Workers Analytics Engine, plus Sentry for uncaught errors (errors escaping a handler, ADR-0041 §E.1) and spend alerts (plus a few explicit captures for failures with no other channel — contract §11). Handled diagnostics (reportError,reportDiagnostic, the demo-runtime relay) go to the o11y stack only, with no switch to send them to Sentry too (contract §11; the oldSENTRY_SCOPE/VITE_SENTRY_SCOPEflag was removed in DEV-3099). New telemetry follows the ADR'shot.*attribute set and its "never a label" rule for demo id, session id, cf-ray and the user pseudonym, checked bypipeline/telemetry-contract.test.mjs. ADR-0042 (example analytics) ships with it. ADR-0043 (/admincutover) follows after launch, once ADR-0041 itself is Accepted (see its own status line — two items are pending: a real-Workers-isolate symbolication measurement, and an R2 retention-lifecycle clock still running). Shared names, Analytics Engine slots and payload shapes are frozen indocs/observability-contract.md; the deploy runbook, one-time setup and post-deploy checklist are indocs/run-and-deploy.md's Observability and Launch plan sections. - Ask AI (DEV-2047,
docs/example-chat.md): chat panel scoped to the open example; docs chunks are retrieved in the browser (Cloudflare blocks Worker→workers.dev, error 1042), the Worker adds Algolia page links and calls LiteLLM. Model edits are proposed, never auto-applied, and every answer is metered into the cost ledger. - Style panel (DEV-2047,
docs/style-panel.md): Theme Builder's controls applied to the open example via Handsontable's JS theme API (registerTheme/params), written into the demo as a real module and wired into the grid;POST /api/themedoes natural-language styling. Controls show the demo's own Handsontable version's preset defaults — fetched at runtime, override state stays empty until you edit — and the panel is gated off below core 17, which has no theme API (DEV-2560). - Analytics are anonymous by construction — no cookies, no IPs, no user agents, no query strings, no per-request rows; unique visitors use a daily-rotating salted hash. Keep it that way when touching
workers/api/src/analytics.ts. The one exception is outside the runner's own telemetry: the authoring SPA loads the company's HubSpot + Reo marketing tags behind the Cookiebot consent banner, for signed-out visitors on the production host only, and never into/d,/embedor previews (ADR-0044,apps/authoring/src/marketingTags.ts). - No manual Handsontable CSS imports in
examples/on master (DEV-2200). Master feeds the 17+/next buckets, where core CSS auto-injects (since 17.0.0) and theming uses the JS theme object (theme: mainThemefromhandsontable/themes, also 17.0.0+) — never the stringthemeName, whose stylesheet nothing injects. Theprod-examples/15|16branches are the exception: below 17 there is no auto-injection and nohandsontable/themes, so their sources keephandsontable/styles/*.cssimports + stringthemeName— do not "clean up" CSS imports on those branches. - A starter states its colour scheme; the shell drives the ones that don't (DEV-2561,
docs/adr/0035-*).mainThemecarries nocolorScheme, so a baretheme: mainThemerenders whatever the visitor's OS prefers — which is why every starter passes{ ...mainTheme, colorScheme: 'light' }. A demo with no declaration of its own follows the shell's toggle over thehot-runner-schemebridge (packages/runtime/src/scheme.ts), injected into the preview document on both tiers and never into the authored file map. Whether the shell may decide is settled inApp.tsx, not in the receiver: a docs example or a Style-panel-wired demo owns its own scheme and is sentauto.