Skip to content

feat(gnoweb): built-in playground(2)#5421

Draft
moul wants to merge 44 commits into
masterfrom
playground2
Draft

feat(gnoweb): built-in playground(2)#5421
moul wants to merge 44 commits into
masterfrom
playground2

Conversation

@moul

@moul moul commented Apr 3, 2026

Copy link
Copy Markdown
Member

Summary

Exploration PR: add playground capabilities directly into gnoweb instead of a separate standalone app (replacing the need for gnostudio/studio).

Context: The current play.gno.land is a complex React+WASM app in a separate repo. This PR explores integrating interactive features directly into gnoweb as a Go-native, single-binary solution.

Related:

New features

  • /_/play — Scratch pad editor with multi-file tabs, keyboard shortcuts (Ctrl+Enter = run), share via URL
  • ?eval on any realm page — Expression evaluator with live qeval against the node, quick-call buttons for all read-only functions, history with re-run
  • ?fork on any package/realm — Loads all .gno source files into the playground editor
  • POST /_/api/eval — JSON API for expression evaluation
  • GET /_/api/funcs?path=... — JSON API for listing package functions
  • "Eval" and "Fork" nav links added to header for realm/package pages

Architecture

  • Pure Go backend + vanilla JS (no React, no npm at runtime)
  • Server-side execution via existing vm/qeval RPC queries
  • All new code lives in gno.land/pkg/gnoweb/ — same binary, same deploy
  • Frontend uses the existing controller pattern (data-controller attributes + lazy-loaded JS)
  • ~460 lines of Go, ~280 lines of TS, ~270 lines of CSS

What's NOT done yet (follow-up work)

  • CodeMirror editor integration (currently textarea)
  • Server-side gno run / gno test / gno fmt
  • WebSocket REPL
  • Wallet/Adena integration for deploy
  • gnodev hot-reload integration via SSE
  • Sandbox/rate-limiting for public instances

Design principles

  1. Go-only — No Node.js runtime dependency
  2. Part of gnoweb — Not a separate app
  3. Server-side execution — Real node via RPC, no WASM
  4. Context-aware — Knows what realm/package you're viewing
  5. Progressive — Simple eval → scratch pad → full editor
  6. Single binarygnoweb embeds everything

Test plan

  • All existing gnoweb tests pass
  • Go compiles clean
  • TypeScript compiles clean (esbuild)
  • Manual test with gnodev
  • Test ?eval on a live realm
  • Test ?fork loads source correctly
  • Test /_/play scratch pad

@Gno2D2

Gno2D2 commented Apr 3, 2026

Copy link
Copy Markdown
Collaborator

🛠 PR Checks Summary

🔴 Changes related to gnoweb must be reviewed by its codeowners

Manual Checks (for Reviewers):
  • IGNORE the bot requirements for this PR (force green CI check)
Read More

🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers.

✅ Automated Checks (for Contributors):

🔴 Changes related to gnoweb must be reviewed by its codeowners

☑️ Contributor Actions:
  1. Fix any issues flagged by automated checks.
  2. Follow the Contributor Checklist to ensure your PR is ready for review.
    • Add new tests, or document why they are unnecessary.
    • Provide clear examples/screenshots, if necessary.
    • Update documentation, if required.
    • Ensure no breaking changes, or include BREAKING CHANGE notes.
    • Link related issues/PRs, where applicable.
☑️ Reviewer Actions:
  1. Complete manual checks for the PR, including the guidelines and additional checks if applicable.
📚 Resources:
Debug
Automated Checks
Changes related to gnoweb must be reviewed by its codeowners

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 A changed file matches this pattern: ^gno.land/pkg/gnoweb/ (filename: gno.land/pkg/gnoweb/app.go)

Then

🔴 Requirement not satisfied
└── 🔴 Or
    ├── 🔴 Or
    │   ├── 🔴 And
    │   │   ├── 🔴 Pull request author is user: alexiscolin
    │   │   └── 🔴 This user reviewed pull request: gfanton (with state "APPROVED")
    │   └── 🔴 And
    │       ├── 🔴 Pull request author is user: gfanton
    │       └── 🔴 This user reviewed pull request: alexiscolin (with state "APPROVED")
    └── 🔴 And
        ├── 🟢 Not (🔴 Pull request author is user: alexiscolin)
        ├── 🟢 Not (🔴 Pull request author is user: gfanton)
        └── 🔴 Or
            ├── 🔴 This user reviewed pull request: alexiscolin (with state "APPROVED")
            └── 🔴 This user reviewed pull request: gfanton (with state "APPROVED")

Manual Checks
**IGNORE** the bot requirements for this PR (force green CI check)

If

🟢 Condition met
└── 🟢 On every pull request

Can be checked by

  • Any user with comment edit permission

@codecov

codecov Bot commented Apr 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.83333% with 46 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
gno.land/pkg/gnoweb/handler_playground.go 80.50% 16 Missing and 7 partials ⚠️
gno.land/pkg/gnoweb/handler_http.go 77.77% 12 Missing and 4 partials ⚠️
gno.land/pkg/gnoweb/components/view_run.go 0.00% 4 Missing ⚠️
gno.land/pkg/gnoweb/client.go 0.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@alexiscolin alexiscolin added the a/ux User experience, product, marketing community, developer experience team label Apr 7, 2026
@moul moul changed the title feat(gnoweb): integrated playground, eval, and fork views feat(gnoweb): built-in playground(2) Apr 8, 2026
moul added 3 commits April 9, 2026 16:21
- pre-allocate `funcs` slice in BuildEvalFuncs (prealloc lint)
- fix biome formatting in controller-eval.ts and controller-playground.ts
…code

- Add TestHTTPHandler_PlaygroundPage, TestHTTPHandler_EvalView, TestHTTPHandler_ForkView
- Add TestHandlerPlaygroundEval and TestHandlerPlaygroundFuncs
- Remove unused playgroundViewParams wrapper and ui/playground_editor template
jeronimoalbi and others added 13 commits April 28, 2026 10:22
Addresses milestone 0 (M0) from #5549

---------

Co-authored-by: Alexis Colin <alexis@jaunebleu.co>
This is an initial functional basic implementation for Playground
scrollable tabs which should be improved in the future.

It is required to be able to edit many files, for example when forking
code.
Playground can share code though a URL. Code can be large, so before
being shared it's compressed.

Also adds a length limit check as a safeguard because code can get too
big to be shared using a URL.
Addresses milestone 1 (M1) from #5549

The "Edit" link opens the file being edited in the built-in playground
within the package/realm fork mode.
…5638)

Adds support for downloading multiple playground files as ".tar", or as
a single ".gno" file when only one file is available.

Downloading multiple files is handy in case users want to use `gnokey`
to run or deploy the code written in the playground.
Done to bring output into view when the view is only the editor.

@alexiscolin alexiscolin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for pushing this: the direction (Go-native, single binary, kill-WASM) is great @jeronimoalbi

I have not tested and checked the design and front-end right now in this first review but:

Main thing I'd like to discuss: I'd like to see the playground land as a self-contained module under pkg/gnoweb/feature/playground/, mirroring what the state-explorer refactor takes in #5649. Right now, gnoweb core is not light as it should be: so this would be a transitional shape, already cleaned and ready, toward a feature framework like featureapi/ leaf package + capability interfaces. I'll open an issue with the full design ASAP so we have a proper place to discuss it.

A few blockers to address before merge:

  • Security hardening on the new public endpoints
  • Goroutine leak + HTTP-200-on-error in handler_playground.go
  • Front (few stuff I saw): prompt() for filename, Tab keyboard trap in run view

And this question worth a spike: again, since we aim to keep gnoweb lightweight with minimal dependencies and JavaScript:

309 KB CM6 bundle vs ~12 KB CodeJar + a tokenizer emitting chroma classes: would reuse the existing /_chroma/style.css and unify the source view / editor theme.

Comment on lines +143 to +155
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if ip, _, err := net.SplitHostPort(strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])); err == nil {
return ip
}
return strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

X-Forwarded-For is honored without any trusted-proxy gate. Any client can send X-Forwarded-For: 1.2.3.4 rotated per request to force each one into a fresh bucket: the 10/3s budget becomes infinite.

If gnoweb runs behind a known proxy (nginx, cloudflare (to come asap)), gate XFF on RemoteAddr being in a configured CIDR allowlist. If not, fall back to RemoteAddr only.

Suggested change
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if ip, _, err := net.SplitHostPort(strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])); err == nil {
return ip
}
return strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}
// clientIP returns the request's source IP. X-Forwarded-For is honored only
// when RemoteAddr is inside one of the trusted CIDRs (empty default = trust nothing).
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if !isFromTrustedProxy(host, trustedProxies) {
return host
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first := strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])
if ip := net.ParseIP(first); ip != nil {
return ip.String()
}
}
return host
}

Comment on lines +790 to +802
decoded, err := base64.StdEncoding.DecodeString(initialCode)
if err == nil {
if gnourl.Query.Has("z") {
r := flate.NewReader(bytes.NewReader(decoded))
if plain, err := io.ReadAll(r); err == nil {
initialCode = string(plain)
}

r.Close()
} else {
initialCode = string(decoded)
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

flate.NewReader + io.ReadAll with no size cap. A ~5 KB compressed payload of zeros decompresses to >1 GB -> OOM in a single GET. The URL is public, shareable, cacheable thus trivially weaponizable.

Also: the io.ReadAll error (L794) and r.Close() error (L798) are swallowed silently.

Suggested change
decoded, err := base64.StdEncoding.DecodeString(initialCode)
if err == nil {
if gnourl.Query.Has("z") {
r := flate.NewReader(bytes.NewReader(decoded))
if plain, err := io.ReadAll(r); err == nil {
initialCode = string(plain)
}
r.Close()
} else {
initialCode = string(decoded)
}
}
const maxPlaygroundCodeBytes = 256 << 10 // 256 KiB
if gnourl.Query.Has("z") {
r := flate.NewReader(bytes.NewReader(decoded))
defer r.Close()
plain, err := io.ReadAll(io.LimitReader(r, maxPlaygroundCodeBytes+1))
if err != nil {
h.Logger.Warn("playground deflate read failed", "error", err)
} else if len(plain) > maxPlaygroundCodeBytes {
h.Logger.Warn("playground code exceeds size limit",
"size", len(plain), "limit", maxPlaygroundCodeBytes)
} else {
initialCode = string(plain)
}
} else {
initialCode = string(decoded)
}

}

var req evalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

json.NewDecoder(r.Body).Decode(&req) with no http.MaxBytesReader. Combined with no length cap on req.Expression (L174-185), an attacker can POST GBs of JSON or 10 MB-long expressions, each forwarded to the node via qeval. I got the same issue on the State Explorer Front End refactor I did...

Comment on lines +134 to +140
func handlerPlaygroundFuncs(logger *slog.Logger, cli ClientAdapter) http.Handler {
h := &playgroundAPIHandler{
logger: logger,
client: cli,
}
return http.HandlerFunc(h.serveFuncs)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

handlerPlaygroundFuncs builds playgroundAPIHandler with limiter: nil (L138 omits it). The handler calls Client.Doc(): a full node RPC. Spam this endpoint means saturate the node. Same amplification vector the eval limiter was meant to close, on a sibling endpoint the same JS controller calls on every package switch.

}

// GetForkView loads all source files from a package and redirects to playground with the code.
func (h *HTTPHandler) GetForkView(ctx context.Context, gnourl *weburl.GnoURL) (int, *components.View) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ListFiles + serial loop calling Client.File() per .gno file. No file count cap, no byte cap, no request timeout. A package with many files = many serial RPCs + multi-MB string concat in one request. Also slow (N × RTT) for legitimate packages with 5-10 files.

h.logger.Debug("playground eval result", "took", took, "error", err)

if err != nil {
writeJSON(w, http.StatusOK, evalResponse{Error: err.Error()})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Returning 200 with {"error": ...} for backend RPC failures breaks HTTP semantics, monitoring (you can't grep 5xx), and fetch().ok on the frontend.


jdoc, err := h.client.Doc(r.Context(), pkgPath)
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{"error": err.Error()})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Returning 200 with {"error": ...} for backend RPC failures breaks HTTP semantics, monitoring (you can't grep 5xx), and fetch().ok on the frontend. Same as above

Comment thread gno.land/pkg/gnoweb/frontend/js/controller-playground.ts
Comment thread gno.land/pkg/gnoweb/frontend/js/controller-playground.ts Outdated
assetsHandler := cacheAssetHandler(AssetHandler())
mux.Handle(assetsBase, http.StripPrefix(assetsBase, assetsHandler))

// Handle playground API endpoints

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These two lines wire playground endpoints directly into the root mux. I'd like to suggest reshaping the playground as a self-contained module under pkg/gnoweb/feature/playground/, matching what the state-explorer
refactor takes in #5649. Same shape, applied to playground:

  pkg/gnoweb/feature/playground/
  ├── feature.go            // entry / Register(mux, deps)
  ├── handler.go            // GetPlaygroundView, GetForkView, GetRunView (today in handler_http.go:779-868)
  ├── handler_api.go        // serveEval, serveFuncs (today in handler_playground.go)
  ├── ratelimit.go          // playgroundRateLimiter (today in handler_playground.go)
  ├── validate.go           // pkg_path sanitization, base64+deflate decode helpers
  ├── client.go             // local subset interface (Eval, Doc, ListFiles, File)
  ├── templates/playground.html, run.html
  ├── frontend/controller-playground.ts, controller-run.ts, controller-action-eval.ts
  ├── frontend/playground.css   // own file, not 594 lines appended to 06-blocks.css (now 3163 lines)
  └── *_test.go

This is a transitional shape (same as I did for #5649 for state-explorer this week). The final form is a feature framework: a featureapi/ leaf package with capability interfaces (QueryHandler for ?eval/?fork/?run, RouteProvider for //play + //api/*, LayoutContributor for the Eval/Fork header nav, Middlewarer for rate limiting). Playground would actually be the densest user of the framework: it implements all four. I'll open an issue with the full design ASAP so we have a proper place to discuss it but it covers anti-amplification, dispatch order, frontend build pipeline, and explains why this intermediate module shape is the right next step.

Why it matters specifically here, beyond cleanup IMO:

  • Anti-amplification is baked into the framework: each interaction = one fetch = at most one upstream RPC. Today's GetForkView doing N serial RPCs is exactly the kind of thing the framework rules out. Better to move toward that shape now than retrofit later.
  • Per-feature middleware is where rate limiting / body caps / pkg_path validation naturally live: instead of handler_playground.go reimplementing security primitives.
  • Eval method stays out of the root ClientAdapter (today added at client.go): local subset interface avoids growing the root interface per feature.
  • Templates colocated with handler: easier to grep, to test and to scope, easier to check and delete the whole feature as a unit.

@jeronimoalbi jeronimoalbi Jun 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Moved playground and run functionality into features in PR #5774 using patterns from #5649. This is a first step, before the final form you mention is defined.

Maybe other pending review changes could be addressed after merging it, because some file had to be splitted into new files. Addressing review changed could be simpler that way 🤔

@alexiscolin alexiscolin assigned jeronimoalbi and unassigned moul May 18, 2026
…ns (#5666)

Changes playground output to optionally support copying single lines
from the output, which can be useful when multiple commands are printed
between text and links.

Related to #5421
…5674)

The new built-in Playground changes include a run view where Gno code
for run TXs can be written. This PR changes the textarea for a
CodeMirror editor, the same used by the Playground, same configuration.

The `gnokey` commands copy buttons were moved into "inline" copy icons
to follow Gnoweb patterns.

Related to #5421
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a/ux User experience, product, marketing community, developer experience team 🌍 gnoweb Issues & PRs related to gnoweb and render 📦 ⛰️ gno.land Issues or PRs gno.land package related

Projects

Status: 📥 Inbox
Status: In Progress

Development

Successfully merging this pull request may close these issues.

6 participants