Skip to content

Latest commit

 

History

History
240 lines (201 loc) · 21.7 KB

AGENTS.md

File metadata and controls

240 lines (201 loc) · 21.7 KB

AGENTS.md — Handsontable demo runner

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.

Get the code

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.

Layout

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.

URL model (routing lives in apps/authoring/src/App.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 to handsontable.com.
  • /admin — login-gated internal usage + cost panel (docs/cost-guardrails.md).

Where to make UI/UX changes

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.

Run locally

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=full needs the SPA and the worker on one origin. serveDemoAsset sends frame-ancestors 'self' + X-Frame-Options: SAMEORIGIN for /d/:id and is not wrapped in cors(). Production is same-origin so it works there; point VITE_API_BASE straight at :8787 and the iframe is refused ("localhost refused to connect") and the status probe fails CORS, which renders as ● error on a demo that is fine. vite.config.ts proxies /api, /d and /embed to the worker for exactly this reason — keep VITE_API_BASE on the dev server's own origin. An empty value does not work: App.tsx falls back to :8787 on any falsy value. (pnpm dev:live/dev:full set this correctly for you, as process env, not a file.)
  • VITE_DEV_USER short-circuits currentUser() 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.

Verify before pushing

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 image

The 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 default playwright test skips 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 addInitScript that clears the storage key — it runs on page.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 synthetic mouseover does 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.ts and friends): vite preview has no /api, /d or /embed routes, so they skip unless pointed at a deployment. e2e/share-view.spec.ts additionally depends on a permanent fixture demo (FIXTURE_ID in the spec — currently r-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-tokens page (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 as sessionStorage.hot_token and 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 an afterEach (the 410 doubles as the revocation assertion). Because the credential has no expiry, that step keeps --trace off and scrubs its artifacts — a leaked trace from this repo, which is public, would leak a live token.
  • E2E_AI=1 gates 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.

Build & deploy

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 deploy

⚠️ Prod build config. The app reads VITE_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 OK

VITE_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.

CI/CD

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.

Code comments

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/authoring and scripts, 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.

Conventions / guardrails

  • 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, KV CACHE, R2 ARTIFACTS).
  • Keep the CodeSandbox hosted Sandpack bundler (self-hosting it stack-overflows on HOT v18).
  • Blank starters (DEV-2499, ADR-0030): blank / blank-ts / blank-react are generated by pipeline/blank-starters.mjs, one variant per bucket — pre-17 buckets import the stylesheets and use the string themeName, 17+/next use theme: mainTheme. After editing a template run node pipeline/import.mjs --synthetic (regenerates only these, in every bucket) and node scripts/prepare-container.mjs --generated-only (refreshes BUILD_CONFIG without touching the committed baked contexts). Keep them minimal — pipeline/blank-starters.test.mjs fails 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 in packages/editor-shell/src/dropFiles.ts (no React, no DOM types) so pipeline/drop-files.test.mjs can drive it with fakes — a scripted DataTransfer returns null from webkitGetAsEntry(), so the e2e spec only reaches the plain-files fallback.
  • Imports are Handsontable-only (DEV-2504, ADR-0032). POST /api/import pulls a workspace out of a JSFiddle or StackBlitz URL by parsing the page each one serves — undocumented payloads, so pipeline/import-url.test.mjs pins both against fixtures recorded from real projects; when a provider changes its page that test is what fails. resolveSource is the SSRF gate (exact hosts, https, rebuilt URL); assertHandsontableProject is 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_instances 10/5 is the container cap and the live-preview concurrency limit (no queue sits in front of the pool; the overflow caller gets the at_capacity 503) — raised from 5/3 in DEV-2909, and not to be changed again without redoing the arithmetic in docs/cost-guardrails.md in 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 in runner_settings); the BUDGET_* vars are only defaults. Pool pressure — at_capacity refusals, 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): the at_capacity counter in usage_daily as ADR-0040 C.1 says (recordUsageEvent, workers/api/src/index.ts); awake-seconds as session.end's value (contract §5, per session, bucketable into an hourly view by the Grafana query rather than a dedicated hourly metric); and peak concurrency as the pool.gauge Analytics 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 old SENTRY_SCOPE/VITE_SENTRY_SCOPE flag was removed in DEV-3099). New telemetry follows the ADR's hot.* attribute set and its "never a label" rule for demo id, session id, cf-ray and the user pseudonym, checked by pipeline/telemetry-contract.test.mjs. ADR-0042 (example analytics) ships with it. ADR-0043 (/admin cutover) 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 in docs/observability-contract.md; the deploy runbook, one-time setup and post-deploy checklist are in docs/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/theme does 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, /embed or 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: mainTheme from handsontable/themes, also 17.0.0+) — never the string themeName, whose stylesheet nothing injects. The prod-examples/15|16 branches are the exception: below 17 there is no auto-injection and no handsontable/themes, so their sources keep handsontable/styles/*.css imports + string themeName — 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-*). mainTheme carries no colorScheme, so a bare theme: mainTheme renders 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 the hot-runner-scheme bridge (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 in App.tsx, not in the receiver: a docs example or a Style-panel-wired demo owns its own scheme and is sent auto.