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:
parent
358dcfd21e
commit
733579f735
3 changed files with 332 additions and 0 deletions
|
|
@ -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, "&", "&")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue