feat: auto-inject path rewrite script into dev proxy HTML responses

The reverse proxy now injects a <script> into text/html responses that
patches fetch() and XMLHttpRequest.open() to prefix absolute paths with
/miniapp/dev. This fixes CRUD apps where fetch("/api/items") would miss
the proxy mount point.

Also adds pitfalls documentation to the dev-preview skill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 21:23:03 +09:00
parent 358dcfd21e
commit 733579f735
3 changed files with 332 additions and 0 deletions

View file

@ -279,6 +279,23 @@ func (h *Handler) ActivateDevTarget(id string) error {
}
proxy := httputil.NewSingleHostReverseProxy(u)
proxy.ModifyResponse = func(resp *http.Response) error {
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "text/html") {
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
resp.Body.Close()
modified := injectDevProxyScript(body)
resp.Body = io.NopCloser(bytes.NewReader(modified))
resp.ContentLength = int64(len(modified))
resp.Header.Set("Content-Length", strconv.Itoa(len(modified)))
resp.Header.Del("Content-Encoding")
return nil
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
@ -339,6 +356,66 @@ func (h *Handler) ListDevTargets() []DevTarget {
return targets
}
// devProxyScript is the JavaScript injected into HTML responses from the dev proxy.
// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
const devProxyScript = `<script data-dev-proxy>
(function(){
var B='/miniapp/dev';
function rw(u){
if(typeof u==='string'&&u.startsWith('/')&&!u.startsWith('//')&&!u.startsWith(B))return B+u;
return u;
}
var _f=window.fetch;
window.fetch=function(r,i){
if(typeof r==='string')r=rw(r);
else if(r instanceof Request)r=new Request(rw(r.url),r);
return _f.call(this,r,i);
};
var _o=XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open=function(m,u){
arguments[1]=rw(u);
return _o.apply(this,arguments);
};
})();
</script>`
// injectDevProxyScript inserts the dev proxy rewrite script into an HTML document.
// Insertion priority: before </head>, after <body...>, or prepend to document.
func injectDevProxyScript(html []byte) []byte {
script := []byte(devProxyScript)
// Priority 1: before </head>
if idx := bytes.Index(bytes.ToLower(html), []byte("</head>")); idx >= 0 {
out := make([]byte, 0, len(html)+len(script))
out = append(out, html[:idx]...)
out = append(out, script...)
out = append(out, html[idx:]...)
return out
}
// Priority 2: after <body ...>
lower := bytes.ToLower(html)
if idx := bytes.Index(lower, []byte("<body")); idx >= 0 {
// Find the closing '>' of the <body> tag
closeIdx := bytes.IndexByte(lower[idx:], '>')
if closeIdx >= 0 {
insertAt := idx + closeIdx + 1
out := make([]byte, 0, len(html)+len(script))
out = append(out, html[:insertAt]...)
out = append(out, script...)
out = append(out, html[insertAt:]...)
return out
}
}
// Priority 3: prepend
out := make([]byte, 0, len(html)+len(script))
out = append(out, script...)
out = append(out, html...)
return out
}
// escapeHTMLString escapes HTML special characters in a string.
func escapeHTMLString(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")

View file

@ -13,6 +13,7 @@ import (
"net/url"
"runtime"
"sort"
"strconv"
"strings"
"sync/atomic"
"testing"
@ -1813,6 +1814,202 @@ func TestDevProxy_HostHeaderForwarded(t *testing.T) {
}
}
// ── injectDevProxyScript tests ──
func TestInjectDevProxyScript_HeadTag(t *testing.T) {
html := []byte(`<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Hi</h1></body></html>`)
result := injectDevProxyScript(html)
s := string(result)
if !strings.Contains(s, `<script data-dev-proxy>`) {
t.Error("expected script to be injected")
}
// Script should appear before </head>
scriptIdx := strings.Index(s, `<script data-dev-proxy>`)
headIdx := strings.Index(s, `</head>`)
if scriptIdx >= headIdx {
t.Errorf("script should be before </head>: scriptIdx=%d, headIdx=%d", scriptIdx, headIdx)
}
// Original content should be preserved
if !strings.Contains(s, `<title>Test</title>`) {
t.Error("original <title> content should be preserved")
}
if !strings.Contains(s, `<h1>Hi</h1>`) {
t.Error("original <body> content should be preserved")
}
}
func TestInjectDevProxyScript_NoHead(t *testing.T) {
html := []byte(`<!DOCTYPE html><html><body><p>Hello</p></body></html>`)
result := injectDevProxyScript(html)
s := string(result)
if !strings.Contains(s, `<script data-dev-proxy>`) {
t.Error("expected script to be injected")
}
// Script should appear right after <body>
bodyIdx := strings.Index(s, `<body>`)
scriptIdx := strings.Index(s, `<script data-dev-proxy>`)
if scriptIdx != bodyIdx+len(`<body>`) {
t.Errorf("script should be immediately after <body>: bodyIdx=%d, scriptIdx=%d", bodyIdx, scriptIdx)
}
}
func TestInjectDevProxyScript_Minimal(t *testing.T) {
html := []byte(`<div>just a div</div>`)
result := injectDevProxyScript(html)
s := string(result)
if !strings.Contains(s, `<script data-dev-proxy>`) {
t.Error("expected script to be injected")
}
// Script should be at the beginning
if !strings.HasPrefix(s, `<script data-dev-proxy>`) {
t.Error("script should be prepended when no head/body tags exist")
}
if !strings.Contains(s, `<div>just a div</div>`) {
t.Error("original content should be preserved")
}
}
func TestInjectDevProxyScript_BodyWithAttributes(t *testing.T) {
html := []byte(`<html><body class="dark" id="main"><p>Content</p></body></html>`)
result := injectDevProxyScript(html)
s := string(result)
// Script should be after the full <body ...> opening tag
bodyCloseIdx := strings.Index(s, `id="main">`) + len(`id="main">`)
scriptIdx := strings.Index(s, `<script data-dev-proxy>`)
if scriptIdx != bodyCloseIdx {
t.Errorf("script should follow <body> closing '>': bodyClose=%d, scriptIdx=%d", bodyCloseIdx, scriptIdx)
}
}
func TestInjectDevProxyScript_CaseInsensitive(t *testing.T) {
html := []byte(`<HTML><HEAD><TITLE>Upper</TITLE></HEAD><BODY>test</BODY></HTML>`)
result := injectDevProxyScript(html)
s := string(result)
if !strings.Contains(s, `<script data-dev-proxy>`) {
t.Error("expected script injection with uppercase tags")
}
// Should still inject before </HEAD>
scriptIdx := strings.Index(s, `<script data-dev-proxy>`)
headIdx := strings.Index(s, `</HEAD>`)
if scriptIdx >= headIdx {
t.Errorf("script should be before </HEAD>")
}
}
func TestInjectDevProxyScript_ScriptContent(t *testing.T) {
html := []byte(`<html><head></head><body></body></html>`)
result := injectDevProxyScript(html)
s := string(result)
// Verify the script rewrites fetch and XHR
if !strings.Contains(s, `/miniapp/dev`) {
t.Error("script should contain /miniapp/dev prefix")
}
if !strings.Contains(s, `window.fetch`) {
t.Error("script should patch window.fetch")
}
if !strings.Contains(s, `XMLHttpRequest.prototype.open`) {
t.Error("script should patch XMLHttpRequest.prototype.open")
}
}
func TestDevProxy_ResponseRewriting(t *testing.T) {
// Backend returns HTML with text/html content type
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!DOCTYPE html><html><head><title>App</title></head><body><h1>Hello</h1></body></html>`)
}))
defer backend.Close()
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
mux := http.NewServeMux()
h.RegisterRoutes(mux)
id, _ := h.RegisterDevTarget("app", backend.URL)
h.ActivateDevTarget(id)
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `<script data-dev-proxy>`) {
t.Error("expected dev proxy script to be injected into HTML response")
}
if !strings.Contains(body, `<title>App</title>`) {
t.Error("original HTML content should be preserved")
}
// Script should be before </head>
scriptIdx := strings.Index(body, `<script data-dev-proxy>`)
headIdx := strings.Index(body, `</head>`)
if scriptIdx >= headIdx {
t.Errorf("script should be injected before </head>")
}
}
func TestDevProxy_ResponseRewriting_NonHTML(t *testing.T) {
// Backend returns JSON — should NOT be modified
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"status":"ok"}`)
}))
defer backend.Close()
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
mux := http.NewServeMux()
h.RegisterRoutes(mux)
id, _ := h.RegisterDevTarget("api", backend.URL)
h.ActivateDevTarget(id)
req := httptest.NewRequest("GET", "/miniapp/dev/api/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if strings.Contains(body, `<script`) {
t.Error("script should NOT be injected into non-HTML response")
}
if body != `{"status":"ok"}` {
t.Errorf("JSON response should be unchanged, got %q", body)
}
}
func TestDevProxy_ResponseRewriting_ContentLength(t *testing.T) {
originalHTML := `<!DOCTYPE html><html><head></head><body>Test</body></html>`
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, originalHTML)
}))
defer backend.Close()
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
mux := http.NewServeMux()
h.RegisterRoutes(mux)
id, _ := h.RegisterDevTarget("app", backend.URL)
h.ActivateDevTarget(id)
req := httptest.NewRequest("GET", "/miniapp/dev/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
body := w.Body.String()
clHeader := w.Header().Get("Content-Length")
if clHeader != "" {
cl, _ := strconv.Atoi(clHeader)
if cl != len(body) {
t.Errorf("Content-Length mismatch: header=%d, actual=%d", cl, len(body))
}
}
}
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out.
func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {
t.Helper()

View file

@ -8,6 +8,38 @@ metadata: {"nanobot":{"emoji":"🌐"}}
Launch a local dev server as a background process, wait for it to become ready, and connect it to the Mini App dev preview proxy.
## Network Architecture
```
User's phone (Telegram)
│ HTTPS (internet)
Telegram Bot API server
│ Mini App WebView (iframe)
│ URL: https://BOT_DOMAIN/miniapp
picoclaw server (VPS / local machine)
│ /miniapp/dev/* → reverse proxy (httputil.ReverseProxy)
│ strips /miniapp/dev prefix, forwards all HTTP methods
localhost:PORT (dev server)
e.g. Vite on :5173, FastAPI on :8000, Go on :8080
```
**Request path**: User opens Dev tab in Mini App → iframe loads `/miniapp/dev/` → picoclaw reverse proxy → `localhost:PORT`
**What works**: All HTTP methods (GET/POST/PUT/DELETE/PATCH), JSON APIs, form submissions, static files, SSE
**What doesn't work**: WebSocket (reverse proxy limitation), non-HTTP protocols
**Key points**:
- The dev server only needs to bind to **localhost** — it is never exposed directly to the internet
- picoclaw's reverse proxy handles the internet-facing HTTPS
- The Mini App frontend sees API paths as `/miniapp/dev/api/...` — the `/miniapp/dev` prefix is stripped before forwarding
- **fetch/XHR are auto-rewritten**: The proxy injects a script into HTML responses that patches `fetch()` and `XMLHttpRequest.open()` to add the `/miniapp/dev` prefix to absolute paths — no manual base URL configuration needed
## Quickstart
```
@ -111,6 +143,32 @@ exec(bg_action="kill", bg_id="bg-1")
dev_preview(action="stop")
```
## Pitfalls / 落とし穴
### Path rewriting (パスリライト)
The dev server runs at `/` but is proxied under `/miniapp/dev/`. The reverse proxy **automatically injects a `<script>`** into HTML responses that patches `fetch()` and `XMLHttpRequest.open()` so that absolute paths like `/api/items` are rewritten to `/miniapp/dev/api/items`.
- **Covered automatically**: `fetch("/api/items")`, `xhr.open("GET", "/data")` — these are patched at runtime.
- **NOT rewritten automatically**: HTML attribute URLs such as `<img src="/img/logo.png">`, `<link href="/style.css">`, `<a href="/page">`. Use **relative paths** (`img/logo.png`, `./style.css`) in your frontend code.
- URLs that already start with `/miniapp/dev` or `//` (protocol-relative) are left untouched to prevent double-rewriting.
### WebSocket not supported
`httputil.ReverseProxy` does **not** transparently proxy WebSocket connections. If your dev server uses WebSocket (e.g., Vite HMR), it will not work through the proxy. Use polling or SSE as alternatives.
### Static asset absolute paths
Any `src="/..."` or `href="/..."` in the HTML will be resolved by the browser relative to the domain root, **not** `/miniapp/dev/`. The injected script only patches `fetch` and `XHR`, not DOM attribute resolution.
**Recommendation**: Use relative paths in all HTML attributes (e.g., `src="./assets/logo.png"` instead of `src="/assets/logo.png"`).
### SPA routing
If your SPA uses `history.pushState("/page")`, the browser URL becomes `/page` which is outside the `/miniapp/dev/` mount. Navigating to it will hit picoclaw's own routes instead of the dev server.
**Recommendation**: Use **hash routing** (`/#/page`) to keep all navigation within the iframe's current path.
## Important Notes
- Always use `bg_monitor(action="watch")` between starting a server and calling `dev_preview(action="start")`. Without it, the server may not be ready yet.