Skip to content

Self-hosted live demo runner (replaces CodeSandbox; retires render-ms)#37

Open
qunabu wants to merge 26 commits into
masterfrom
runner-live-demos
Open

Self-hosted live demo runner (replaces CodeSandbox; retires render-ms)#37
qunabu wants to merge 26 commits into
masterfrom
runner-live-demos

Conversation

@qunabu

@qunabu qunabu commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Self-hosted system that renders every Handsontable example live at any version, lets the internal team edit demos and share permanent client links, and can embed demos on the docs — replacing CodeSandbox and, eventually, render-ms. All code lives under runner/ (pnpm monorepo). render-ms/ is untouched; removal is a follow-up.

Architecture — one UX, two engines behind a DemoRuntime adapter

  • Tier 1 (7): client-side Sandpack in-browser bundler — example1, javascript, typescript, react, react-js, ant-design, vue. Free, instant, zero per-view cost.
  • Tier 2 (6): Cloudflare Sandbox containers running the real dev server + HMR — angular, next.js, next-shadcn.js, astro, nuxt, remix.
  • The editor shell binds only to DemoRuntime; the author can't tell which engine runs. No Nodebox / WebContainers.

What's included

  • config/frameworks.json + pipeline/import.mjscatalog.json (all 13 examples migrated).
  • packages/runtimeDemoRuntime, SandpackRuntime, ContainerRuntime, resolveRuntime, and applyHandsontableVersion (ported from render-ms: semver / npm partials / pkg.pr.new; lockstep wrapper pinning; majors 15–19).
  • packages/editor-shell — white-label editor (file tree, CodeMirror 6, live preview, version picker, Share) + theme.ts.
  • apps/authoring — Vite/React app behind the Handsontable Google login broker (@handsontable.com); fork → title/description → Share; "My demos"; copy client link + docs embed URL.
  • workers/api — orchestration + sharing Worker: Tier-2 sessions (Sandbox SDK, proxyToSandbox HMR), POST/GET/PATCH/DELETE /api/demos, /d/:id, /embed/:id (frame-ancestors handsontable.com), render-ms shim (/codesandbox-vm, /codesandbox-browser, /r/:framework).
  • containers/* — one baked-deps image per Tier-2 framework + a generic builder for the snapshotter.
  • pipeline / scripts — catalog importer, container prep, warm.ts.
  • docs/ — architecture, run-and-deploy, self-host-bundler, create-and-share-a-demo, cloudflare-resources, and 18 ADRs.

Deployed (main Handsontable account 15111272…)

  • API: https://handsontable-demos-api.handsoncode.workers.dev
  • Authoring: https://handsontable-demos-authoring.handsoncode.workers.dev
  • D1 handsontable-demos, KV handsontable-demos-cache, R2 handsontable-demos.

Verified

  • Tier-1 React + Vue render live and edit live in the shell (browser).
  • Tier-2 Remix: container dev server + HMR through the unified shell (browser).
  • Share: fork → build in container → /d/:id renders prebuilt static; embed sends frame-ancestors; revoke → 410.
  • render-ms shim builds in production and renders (/codesandbox-vm?example-dir=javascript&handsontable-version=18.0.0/d/r-javascript-18-0-0/).
  • All 6 Tier-2 images build; both workers deployed; broker login matches the publish-app skill.

Follow-ups (need ops)

  • Redeploy the hot-mcp login broker (branch broker-allow-handsoncode-workers-dev) so real logins work from handsoncode.workers.dev.
  • Add *.demos.handsontable.com wildcard DNS → API worker for live Tier-2 preview (ADR-0011), then set PREVIEW_HOST + redeploy. Static shares/embeds work without it.
  • Retire render-ms after parity confirmation.

Note

Low Risk
Single-line demo styling change in a non-production example; risk is mainly an unstyled Handsontable grid if no replacement CSS path exists.

Overview
Removes the handsontable/dist/handsontable.full.min.css side-effect import from the Vanilla (Parcel) example1 entry (examples/example1/index.ts), so the demo no longer pulls the monolithic dist stylesheet through JS.

The example still uses handsontable/base and registerAllModules(); verify that grid/theme styles still come from somewhere else (e.g. index.htmlstyles.css or explicit handsontable/styles/* imports like other examples), since this diff only deletes the import.

Reviewed by Cursor Bugbot for commit 338d161. Bugbot is set up for automated code reviews on this repo. Configure here.

qunabu added 13 commits July 8, 2026 10:45
Deliverable 1 of the self-hosted demo runner that replaces CodeSandbox and,
later, the render-ms redirect microservice.

- config/frameworks.json: single source of truth (tier, wrappers, dev/build
  commands, ports) for all 13 examples. Angular is Tier 2 (needs a real Node
  dev server). 7 Tier-1 (Sandpack) / 6 Tier-2 (Cloudflare Sandbox containers).
- pipeline/import.mjs: deterministic catalog importer; excludes build
  artifacts, lockfiles, CodeSandbox metadata, binaries; validates entries.
- catalog.json: all 13 examples normalized to starting templates.
- packages/runtime: DemoRuntime interface, resolveRuntime, and
  applyHandsontableVersion (ported from render-ms; pins core + wrapper in
  lockstep, never touches @handsontable/pikaday; pkg.pr.new supported).
- packages/editor-shell: branding theme tokens (white-label).
- pnpm workspace, tsconfig base, .env.example, docs/architecture.md.
…side frameworks

Deliverable 2.

- packages/runtime/src/sandpack.ts: SandpackRuntime implementing DemoRuntime
  over @codesandbox/sandpack-client (classic in-browser bundler). White-label
  (showOpenInCodeSandbox:false); Sandpack option/setup types derived from the
  loader signature; exported via the ./sandpack subpath so DOM code never
  reaches the sharing Worker. Applies applyHandsontableVersion before mount;
  writeFile streams edits via updateSandbox.
- config/frameworks.json + catalog: sandpackEnvironment per Tier-1 example
  (parcel / create-react-app[-typescript] / vue-cli).
- version.ts: fix expandPartialNumericSemver under noUncheckedIndexedAccess.
- Verified from compiled dist: version validation (semver, npm partials, major
  cap, pkg.pr.new id/URL) and lockstep pinning are correct and pure.
Deliverable 3.

- packages/editor-shell: framework-agnostic React editor. EditorShell composes
  a file tree, CodeMirror 6 code editor (language by extension, dark theme),
  a single preview-iframe slot, and a toolbar (framework tag, Handsontable
  version picker, Save, Share) styled from theme.ts. White-label: our logo only.
  The shell binds only to props/callbacks — no knowledge of Sandpack vs
  container. Re-keys the editor on active-file change so buffers swap correctly.
- apps/authoring: Vite+React app assembling the shell with a DemoRuntime. Loads
  catalog.json, picks an example, mounts SandpackRuntime for Tier 1 with version
  dispatch, streams edits via writeFile. Tier-2 shows an editing-enabled/
  preview-disabled placeholder until Deliverable 4.
- Verified live in a browser against the hosted bundler: React (Vite, TS) and
  Vue 3 render a live Handsontable grid at v18.0.0; editing a column header
  updates the preview live; switching examples swaps files/engine correctly.

Cloudflare Access gates this app at the edge at deploy time (no in-app auth).
Plan/design updates per new requirements:

- Auth is now the shared Handsontable Google login broker (@handsontable.com
  only, no passwords), superseding Cloudflare Access. Worker validates the
  per-user token server-side via /broker/userinfo; created_by = verified email.
  Deploy on handsontable-sandbox.workers.dev (a broker allowed return host).
- Internal per-user demos + fork flow: fork any existing demo (catalog starter
  or saved demo), add title + description, save -> unique /d/:id client URL.
  D1 schema gains description + forked_from (+ index); created_by powers
  'My demos'.
- Docs-only embed: GET /embed/:id read-only, locked to handsontable.com via CSP
  frame-ancestors (+ Sec-Fetch/Origin checks) so no other site can embed it.
- .env.example: LOGIN_BROKER_URL, EMBED_ALLOWED_ANCESTORS, workers.dev origins.
Generating/copying the embeddable /embed/:id URL is shown only to signed-in
internal users (broker session); serving the embed stays public but frame-locked
to handsontable.com.
Provisioned in account 15111272c53ed0aaf84a908f0c9c7f8b (NOT the sandbox):
- D1 handsontable-demos (5fc0854f-...) region EEUR, schema 0001_init.sql applied
- KV handsontable-demos-cache (6620876d...)
- R2 handsontable-demos (created after R2 enabled on the account)

- workers/api/wrangler.jsonc: real D1/KV/R2 bindings + account_id + broker/embed
  vars.
- workers/api/migrations/0001_init.sql: demos (+ description, forked_from) and
  build_cache tables, indexes.
- docs/cloudflare-resources.md: authoritative resource inventory.
- .env.example / architecture.md: point at the main account + its workers.dev
  subdomain instead of the sandbox.

Resources are managed with wrangler (the Cloudflare MCP is bound to the sandbox
account, so it is not used here). Stray D1/KV mistakenly created in the sandbox
were deleted.
…roven end-to-end

Deliverable 4 (first framework proven; other 4 Tier-2 examples follow).

- workers/api: orchestration Worker on the Cloudflare Sandbox SDK. Per-session
  container runs the real dev server; proxyToSandbox() proxies the preview URL
  incl. WebSocket/HMR. Routes: POST /api/session (write files -> start dev ->
  exposePort -> previewUrl), POST /api/session/:id/file (stream edit), DELETE
  (destroy). CORS for the authoring app. Shallow Sandbox types avoid TS2589.
- containers/remix: Dockerfile (FROM cloudflare/sandbox:0.12.3, deps baked,
  EXPOSE 5173) + generated build-context package.json.
- scripts/prepare-container.mjs: build the container context from the catalog.
- packages/runtime/container.ts: ContainerRuntime implements DemoRuntime over the
  session API (iframe.src = preview URL; debounced writeFile -> HMR). Exposed via
  the ./container subpath.
- apps/authoring: Tier-2 now uses ContainerRuntime via the same shell as Tier-1.

Verified locally (wrangler dev + Docker): Remix boots in a Sandbox container,
renders a live Handsontable grid through the preview URL, and edits made in the
unified editor HMR into the preview within seconds — the author cannot tell it
is a different engine than the Tier-1 React demo.
- config/frameworks.json: next-shadcn.js gets its own container (deps differ from
  Next 13). scripts/prepare-container.mjs now generates a Dockerfile +
  package.json per Tier-2 framework and workers/api/src/frameworks.generated.ts
  (framework -> dev command, port, DO binding) from the catalog.
- containers/{remix,angular,next,next-shadcn,astro,nuxt}: baked-deps images.
- workers/api: one Sandbox subclass + DO binding + image per framework; session
  ids encode the container (<container>--<uuid>) so file/delete route to the
  right image. Dev commands bind 0.0.0.0 at the framework's port.
- docs/adr: 18 Architecture Decision Records (0001–0018) + index.

Container images build-verified via docker build: astro, nuxt (OK); next,
angular, next-shadcn building. Remix already proven live end-to-end (ADR-0002).
- workers/api/share.ts: snapshotter runs the real framework build in a generic
  builder container (npm install + buildCommand with node_modules/.bin on PATH),
  collects the static output, uploads to R2, records metadata in D1, mints a
  short id. Builds immutable + deduped per (framework, ht_version, files_hash)
  via build_cache. Serves /d/:id and /embed/:id from R2.
- workers/api/auth.ts: broker-token validation (@handsontable.com only) with a
  local DEV_AUTH_EMAIL bypass (gitignored .dev.vars).
- Endpoints: POST /api/demos (fork->build->R2->id), GET /api/demos (My demos),
  GET /api/demos/:id (public, cached, 410 if revoked), PATCH/DELETE (owner),
  GET /d/:id[/*] (immutable, X-Frame-Options DENY), GET /embed/:id[/*]
  (frame-ancestors handsontable.com only). Trailing-slash redirect for relative
  asset paths.
- containers/builder: generic Node image for the snapshotter. BUILD_CONFIG for
  all 13 examples generated by prepare-container.mjs.

Verified locally (wrangler dev + Docker): shared a javascript demo -> built in
container -> /d/:id/ renders the prebuilt static grid from R2; My demos lists it;
embed sends frame-ancestors; revoke -> 410.
Worker live at handsontable-demos-api.handsoncode.workers.dev (main account).
7 Sandbox container apps created; D1/KV/R2 bound; migrations applied. Static
sharing + viewer work on workers.dev now; live Tier-2 sessions still need a
wildcard custom domain (ADR-0011).
- apps/authoring/auth.ts: Handsontable login broker client (Sign in with
  Handsontable, @handsontable.com only; token in sessionStorage; Log out).
  Matches the publish-app skill's login experience. Dev bypass via VITE_DEV_USER.
- App gated behind login; shows signed-in email + Log out. Share opens a dialog
  (title + description) -> POST /api/demos -> client link + docs embed URL with
  Copy buttons (login-gated by construction). 'My demos' side panel lists the
  user's demos with View / Fork.
- workers/api: PREVIEW_HOST for wildcard preview URLs in prod (ADR-0011);
  GET /api/demos/:id/source (auth) for forking a saved demo; share.ts stores a
  source snapshot (never served as a public asset) and strips type-check steps
  (tsc/vue-tsc) from build commands so static snapshots don't fail on types.

Verified locally end-to-end in the browser: signed-in editor -> edit React demo
-> Share (title/description) -> real container build -> client link + embed URL;
/d/:id renders the prebuilt grid. Requires broker allowlist for
handsoncode.workers.dev (done in hot-mcp, pending broker redeploy).
- D8 render-ms compatibility shim: GET /codesandbox-vm, /codesandbox-browser,
  and /r/:framework map old public deep links (example-dir + handsontable-version)
  to a build-or-reuse PUBLIC static render with a deterministic /d/:id
  (migrate.ts; version 'latest' resolved via npm). Reuses applyHandsontableVersion
  from the runtime package.
- Fix: idx_demos_buildkey is now NON-unique (migration 0002; artifact dedupe
  lives in build_cache) so two users can share identical files independently;
  createDemo upserts by id (deterministic ids for shim renders).
- Friendly root index at / (was a bare 404 — this is the API worker).
- instance_type standard -> standard-1 (deprecation).
- apps/authoring: wrangler.jsonc (static SPA Workers Assets) + deployed to
  handsontable-demos-authoring.handsoncode.workers.dev. Prod build omits the
  dev login bypass.
- docs/: run-and-deploy, self-host-bundler, create-and-share-a-demo (non-technical).

Verified in prod: API root 200 (friendly index), authoring app serves; API
redeployed (version with shim). render-ms links build-on-first-hit then cache.
Iterates the catalog × the latest N stable Handsontable versions (majors 15-19,
from npm) and hits the deployed /r/:framework render endpoint so each is built
and cached in R2 (reusing the prod build+cache path). Configurable via
WARM_API_BASE / WARM_VERSIONS / WARM_EXAMPLES / WARM_CONCURRENCY; optional
WARM_TIER2_CONTAINERS=1 warms one container per Tier-2 framework.
Comment thread runner/apps/authoring/src/App.tsx Outdated
Comment thread runner/apps/authoring/src/App.tsx Outdated
Comment thread runner/apps/authoring/src/App.tsx Outdated
- Fork of a different-framework demo was clobbered by the entry-reset effect
  (High). Removed that effect; a new loadWorkspace() replaces framework + files
  + lineage atomically. Example dropdown now uses selectExample().
- Fork of a same-framework demo didn't remount the runtime, so the preview kept
  the old project (Medium). Added a mountGen counter to the mount effect deps so
  a wholesale file replacement always remounts.
- forkedFrom lineage was set before the source fetch succeeded (Medium). openFork
  now fetches first and only loads/sets lineage on success; failure leaves the
  workspace untouched and surfaces an error.
Comment thread runner/apps/authoring/src/ShareDialog.tsx
Comment thread runner/apps/authoring/src/App.tsx
@qunabu

qunabu commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Fixed all three Bugbot findings in the fork flow in commit b256e05:

  • High — Fork cleared on framework change: removed the useEffect([entry]) that reset files/lineage. A new loadWorkspace(fw, files, lineage) replaces framework + files + lineage atomically; the example dropdown uses selectExample(). Forking a different-framework demo now keeps the forked source loaded.
  • Medium — Fork skips live preview remount: added a mountGen counter to the mount effect deps, so a wholesale file replacement (example switch or same-framework fork) always remounts the runtime and the preview reflects the forked code.
  • Medium — Fork lineage set before load: openFork now fetches the source first and only loads it + sets forkedFrom on success; a failed/unauthorized load leaves the workspace and lineage untouched and surfaces an error.

Authoring app redeployed with the fix.

- Authoring editor/playground is now PUBLIC; sign-in only gates creating a
  client demo (Share) and 'My demos'. Anonymous visitors get a Sign-in control;
  Share when signed out starts the broker login. (ADR-0007 amended.)
- Version picker uses REAL published versions via new GET /api/versions
  (npm-backed, KV-cached) + a custom-version input for nightlies / pkg.pr.new
  builds (e.g. 0.0.0-next-<sha>-<date>). Removes the old hardcoded invalid list.
- workers/api: PREVIEW_HOST=demos.handsontable.com + wildcard route
  *.demos.handsontable.com/* (workers_dev kept enabled) for live Tier-2 preview.

Deployed: API + authoring. NOTE: live Tier-2 preview still needs a TLS cert
covering *.demos.handsontable.com (Universal SSL does not cover 2nd-level
wildcards) — order via Advanced Certificate Manager.
Comment thread runner/apps/authoring/src/App.tsx
Comment thread runner/apps/authoring/src/MyDemos.tsx
Example + version are read from the URL on load and written back on change
(history.replaceState), so links reopen the app at that example/version. Login
return_to now includes the query so a deep link survives the broker round-trip.
Comment thread runner/apps/authoring/src/App.tsx
Comment thread runner/apps/authoring/src/App.tsx
qunabu added 3 commits July 8, 2026 14:23
…-shims

- Branding: Handsontable blue #1A42E8 accent (no green); real logo SVG inlined
  (self-hosted asset) replaces the 'Handsontable Demos' title + placeholder mark.
- Toolbar gated by auth: public users see the version dropdown + a 'Fork this
  demo' button only; Save/Share/custom-version appear after sign-in (login only
  gates creating a client demo). Removed meaningless 'Tier N' from the picker.
- My demos: View / Edit(fork) / Delete (revoke).
- SandpackRuntime.ensureSandpackDeps: inject transitive runtime deps the
  in-browser bundler doesn't resolve (@swc/helpers for handsontable; pikaday +
  moment + jquery for @handsontable/pikaday). Fixes the 'cannot find module'
  crashes; kept the free hosted bundler (self-host has a v18 stack overflow).

Known follow-up: react-js/ant-design compile but render blank on the classic
bundler (root-level entry vs create-react-app /src convention) — needs per-example
entry normalization or containers.
…ck html/env fixes

- Changing the Handsontable version now re-pins /package.json in the editor
  (handsontable + framework wrapper in lockstep via applyHandsontableVersion),
  so what you see matches what the bundler/container installs.
- SandpackRuntime: normalize create-react-app -> create-react-app-typescript
  env; strip <base> tags from HTML (breaks the preview iframe); + moment shim.
  Fixes javascript rendering; react/vue/ts render. react-js/ant-design still
  blank on the classic bundler (tracked separately).
proxyToSandbox() hard-requires a DO namespace literally named 'Sandbox'; the
per-framework namespaces (SANDBOX_REMIX, ...) made env.Sandbox undefined, so
container previews returned 'Proxy routing error'. Collapse to ONE Sandbox class
backed by a generic image (containers/live) that installs each demo's deps at
session start and runs the real dev server; builder keeps its own class.
Migration v2 deletes the 6 framework classes and adds Sandbox. Session ids are
sanitized for the preview hostname; port-wait bumped to 45s (install + boot).

Verified in PRODUCTION: a Remix session renders a live Handsontable grid over
https://<port>-<id>-<token>.demos.handsontable.com/ (wildcard ACM cert active).
Comment thread runner/apps/authoring/src/App.tsx
qunabu added 2 commits July 8, 2026 15:30
…ample1 CSS

- Tier-2 sessions now boot asynchronously: POST /api/session returns immediately
  after starting a background install+dev process (logged to /tmp/boot.log) and
  exposing the port. New GET /api/session/:id/status?port= returns { ready, log }.
  ContainerRuntime polls it, streams progress via onProgress, and only points the
  iframe at the preview when the dev server is up. PreviewPane shows a spinner +
  live boot log so Angular/Next no longer look frozen on 'Booting…'.
- examples/react-js: move entry to /src/index.jsx + drop <base href='.'> so it
  matches the working react example structure.
- examples/example1: remove obsolete 'handsontable/dist/handsontable.full.min.css'
  import (not needed since 17.1).
…lane)

exposePort(3000) is rejected ('sandbox control plane'). angular->4200,
next/next-shadcn/nuxt->3001, remix 5173/astro 4321 unchanged. EXPOSE updated.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6e5b398. Configure here.

Comment thread examples/example1/index.ts
qunabu added 2 commits July 8, 2026 15:46
…rsion R2 cache

- Route react-js + ant-design through the container engine (engine:'container'
  in catalog; App/runtimes select by engine, not tier). They render exactly as
  authored (real vite dev).
- Fast, budget-wise Tier-2 boots:
  * one generic live image bakes each framework's node_modules into /baked/<key>
    (scripts/prepare-container.mjs); session hardlink-copies it so a
    default-version boot skips npm install. No idle warm pool.
  * per-(framework,version) node_modules cache in R2 via GET/PUT
    /api/nmcache/:framework/:version — the container restores a cached tarball
    when available, else installs (seeded from baked) and uploads it for next
    time. Boot script: cache -> baked+install -> cache upload -> dev.
- Cleanup: single Sandbox model; removed per-framework binding/container config.
… phase

- Fork action shows only for signed-in users (anonymous visitors browse/edit/
  preview and sign in from the top bar).
- ContainerRuntime keeps the loading indicator past dev-server-ready through the
  client render phase: on iframe load it waits a short grace before firing ready
  (SPAs like Angular bootstrap after the HTML loads), with a hard fallback.
  Progress shows 'Dev server ready — rendering the demo…'.
qunabu added 3 commits July 8, 2026 16:05
…curl noise

The per-version cache upload was on the critical path, delaying the dev server
start. Move the tar+PUT into a background subshell so the dev server starts right
after install; silence the expected 404 on a cache miss.
…utes

API worker claims demos.handsontable.com/{api,d,embed,r,codesandbox-*} + keeps the
*.demos wildcard (previews); authoring app owns the catch-all demos.handsontable.com/*
(more-specific API routes win). Authoring built with VITE_API_BASE=
https://demos.handsontable.com so client/embed links are same-origin + clean.
Needs a proxied DNS record for demos.handsontable.com to resolve.
Tier-2 frameworks rebuild server-side (a few seconds) after an edit, unlike
instant in-browser Tier-1. Show an 'Applying changes…' pill on the preview after
a container edit so the rebuild latency is visible (edits do reflect; Angular's
esbuild rebuild just isn't instant).
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.

1 participant