Compare commits

...

23 Commits

Author SHA1 Message Date
rohoswagger
b9fa90cefd Fix escaped HMR path syntax error 2026-03-11 16:33:04 -07:00
rohoswagger
e2bb236351 Fix craft webapp HMR proxy rewrite handling 2026-03-11 16:28:56 -07:00
Wenxi
f0e26e1c35 Merge branch 'main' into fix/craft-webapp-offline-auto-refresh 2026-03-11 16:15:37 -07:00
rohoswagger
dadaa39cce Strengthen craft webapp rewrite wiring tests 2026-03-11 13:46:28 -07:00
rohoswagger
c41f278d7f Refactor craft HMR fixer into template 2026-03-11 13:08:28 -07:00
rohoswagger
889c85492a Restore craft HMR websocket injection 2026-03-11 11:51:49 -07:00
rohoswagger
80361c71ab Remove unused craft asset fixer 2026-03-11 11:13:14 -07:00
rohoswagger
656443d73a Disable craft runtime asset fixer injection 2026-03-11 10:57:20 -07:00
rohoswagger
8bb2bb3262 Drop leftover sandbox formatting diff 2026-03-11 10:47:11 -07:00
rohoswagger
abae84e4c4 Trim craft webapp refresh fix to minimal scope 2026-03-11 10:46:20 -07:00
rohoswagger
a92afb1483 Patch craft webapp HMR and preload leaks 2026-03-11 10:23:36 -07:00
rohoswagger
b367b1ffda Fix craft webapp proxy refresh handling 2026-03-11 09:50:04 -07:00
rohoswagger
4b6edc091f fix(craft): fix bad escape in re.sub replacement by using lambda 2026-03-11 08:43:09 -07:00
rohoswagger
11f3d41a67 fix(craft): inject script to rewrite next/font URLs injected client-side by React 2026-03-10 19:20:43 -07:00
rohoswagger
40cb943354 fix(craft): remove PROVISIONING from offline HTML auto-refresh condition 2026-03-10 19:01:05 -07:00
rohoswagger
1a0850d5d0 fix(craft): register HMR WebSocket paths in auth allowlist 2026-03-10 18:39:26 -07:00
rohoswagger
f8294c84d6 chore(craft): trim verbose comments added during HMR fix 2026-03-10 17:01:17 -07:00
rohoswagger
84eef6d9c1 fix(craft): add WebSocket sink for Next.js HMR to prevent periodic location.reload() 2026-03-10 16:48:07 -07:00
rohoswagger
6da3e1bae2 fix(craft): avoid double-prefixing _next/ paths when assetPrefix already applied 2026-03-10 16:46:17 -07:00
rohoswagger
474eab8fb9 fix(craft): pass CRAFT_ASSET_PREFIX env var to local Next.js dev server 2026-03-10 16:44:18 -07:00
rohoswagger
8b0ca9a66d fix(craft): pass CRAFT_ASSET_PREFIX env var to K8s Next.js dev server 2026-03-10 16:42:51 -07:00
rohoswagger
bc1f81c342 fix(craft): configure assetPrefix via env var so HMR WebSocket routes through proxy 2026-03-10 16:41:15 -07:00
rohoswagger
2c45123de5 fix(craft): only auto-refresh offline page when sandbox is genuinely asleep 2026-03-10 16:06:47 -07:00
3 changed files with 472 additions and 9 deletions

View File

@@ -1,3 +1,4 @@
import re
from collections.abc import Iterator
from pathlib import Path
from uuid import UUID
@@ -40,6 +41,9 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_WEBAPP_HMR_FIXER_TEMPLATE = (_TEMPLATES_DIR / "webapp_hmr_fixer.js").read_text()
def require_onyx_craft_enabled(user: User = Depends(current_user)) -> User:
"""
@@ -239,18 +243,62 @@ def _stream_response(response: httpx.Response) -> Iterator[bytes]:
yield chunk
def _inject_hmr_fixer(content: bytes, session_id: str) -> bytes:
"""Inject a script that stubs root-scoped Next HMR websocket connections."""
base = f"/api/build/sessions/{session_id}/webapp"
script = f"<script>{_WEBAPP_HMR_FIXER_TEMPLATE.replace('__WEBAPP_BASE__', base)}</script>"
text = content.decode("utf-8")
text = re.sub(
r"(<head\b[^>]*>)",
lambda m: m.group(0) + script,
text,
count=1,
flags=re.IGNORECASE,
)
return text.encode("utf-8")
def _rewrite_asset_paths(content: bytes, session_id: str) -> bytes:
"""Rewrite Next.js asset paths to go through the proxy."""
import re
# Base path includes session_id for routing
webapp_base_path = f"/api/build/sessions/{session_id}/webapp"
escaped_webapp_base_path = webapp_base_path.replace("/", r"\/")
hmr_paths = ("/_next/webpack-hmr", "/_next/hmr")
text = content.decode("utf-8")
# Rewrite /_next/ paths to go through our proxy
text = text.replace("/_next/", f"{webapp_base_path}/_next/")
# Rewrite JSON data file fetch paths (e.g., /data.json, /data/tickets.json)
# Matches paths like "/filename.json" or "/path/to/file.json"
# Anchor on delimiter so already-prefixed URLs (from assetPrefix) aren't double-rewritten.
for delim in ('"', "'", "("):
text = text.replace(f"{delim}/_next/", f"{delim}{webapp_base_path}/_next/")
text = re.sub(
rf"{re.escape(delim)}https?://[^/\"')]+/_next/",
f"{delim}{webapp_base_path}/_next/",
text,
)
text = re.sub(
rf"{re.escape(delim)}wss?://[^/\"')]+/_next/",
f"{delim}{webapp_base_path}/_next/",
text,
)
text = text.replace(r"\/_next\/", rf"{escaped_webapp_base_path}\/_next\/")
text = re.sub(
r"https?:\\\/\\\/[^\"']+?\\\/_next\\\/",
rf"{escaped_webapp_base_path}\/_next\/",
text,
)
text = re.sub(
r"wss?:\\\/\\\/[^\"']+?\\\/_next\\\/",
rf"{escaped_webapp_base_path}\/_next\/",
text,
)
for hmr_path in hmr_paths:
escaped_hmr_path = hmr_path.replace("/", r"\/")
text = text.replace(
f"{webapp_base_path}{hmr_path}",
hmr_path,
)
text = text.replace(
f"{escaped_webapp_base_path}{escaped_hmr_path}",
escaped_hmr_path,
)
text = re.sub(
r'"(/(?:[a-zA-Z0-9_-]+/)*[a-zA-Z0-9_-]+\.json)"',
f'"{webapp_base_path}\\1"',
@@ -261,11 +309,29 @@ def _rewrite_asset_paths(content: bytes, session_id: str) -> bytes:
f"'{webapp_base_path}\\1'",
text,
)
# Rewrite favicon
text = text.replace('"/favicon.ico', f'"{webapp_base_path}/favicon.ico')
return text.encode("utf-8")
def _rewrite_proxy_response_headers(
headers: dict[str, str], session_id: str
) -> dict[str, str]:
"""Rewrite response headers that can leak root-scoped asset URLs."""
link = headers.get("link")
if link:
webapp_base_path = f"/api/build/sessions/{session_id}/webapp"
rewritten_link = re.sub(
r"<https?://[^>]+/_next/",
f"<{webapp_base_path}/_next/",
link,
)
rewritten_link = rewritten_link.replace(
"</_next/", f"<{webapp_base_path}/_next/"
)
headers["link"] = rewritten_link
return headers
# Content types that may contain asset path references that need rewriting
REWRITABLE_CONTENT_TYPES = {
"text/html",
@@ -342,12 +408,17 @@ def _proxy_request(
for key, value in response.headers.items()
if key.lower() not in EXCLUDED_HEADERS
}
response_headers = _rewrite_proxy_response_headers(
response_headers, str(session_id)
)
content_type = response.headers.get("content-type", "")
# For HTML/CSS/JS responses, rewrite asset paths
if any(ct in content_type for ct in REWRITABLE_CONTENT_TYPES):
content = _rewrite_asset_paths(response.content, str(session_id))
if "text/html" in content_type:
content = _inject_hmr_fixer(content, str(session_id))
return Response(
content=content,
status_code=response.status_code,
@@ -391,7 +462,7 @@ def _check_webapp_access(
return session
_OFFLINE_HTML_PATH = Path(__file__).parent / "templates" / "webapp_offline.html"
_OFFLINE_HTML_PATH = _TEMPLATES_DIR / "webapp_offline.html"
def _offline_html_response() -> Response:
@@ -399,6 +470,7 @@ def _offline_html_response() -> Response:
Design mirrors the default Craft web template (outputs/web/app/page.tsx):
terminal window aesthetic with Minecraft-themed typing animation.
"""
html = _OFFLINE_HTML_PATH.read_text()
return Response(content=html, status_code=503, media_type="text/html")

View File

@@ -0,0 +1,135 @@
(function () {
var WEBAPP_BASE = "__WEBAPP_BASE__";
var PROXIED_NEXT_PREFIX = WEBAPP_BASE + "/_next/";
var PROXIED_HMR_PREFIX = WEBAPP_BASE + "/_next/webpack-hmr";
var PROXIED_ALT_HMR_PREFIX = WEBAPP_BASE + "/_next/hmr";
function isHmrWebSocketUrl(url) {
if (!url) return false;
try {
var parsedUrl = new URL(String(url), window.location.href);
return (
parsedUrl.pathname.indexOf("/_next/webpack-hmr") === 0 ||
parsedUrl.pathname.indexOf("/_next/hmr") === 0 ||
parsedUrl.pathname.indexOf(PROXIED_HMR_PREFIX) === 0 ||
parsedUrl.pathname.indexOf(PROXIED_ALT_HMR_PREFIX) === 0
);
} catch (e) {}
if (typeof url === "string") {
return (
url.indexOf("/_next/webpack-hmr") === 0 ||
url.indexOf("/_next/hmr") === 0 ||
url.indexOf(PROXIED_HMR_PREFIX) === 0 ||
url.indexOf(PROXIED_ALT_HMR_PREFIX) === 0
);
}
return false;
}
function rewriteNextAssetUrl(url) {
if (!url) return url;
try {
var parsedUrl = new URL(String(url), window.location.href);
if (parsedUrl.pathname.indexOf(PROXIED_NEXT_PREFIX) === 0) {
return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
}
if (parsedUrl.pathname.indexOf("/_next/") === 0) {
return (
WEBAPP_BASE + parsedUrl.pathname + parsedUrl.search + parsedUrl.hash
);
}
} catch (e) {}
if (typeof url === "string") {
if (url.indexOf(PROXIED_NEXT_PREFIX) === 0) {
return url;
}
if (url.indexOf("/_next/") === 0) {
return WEBAPP_BASE + url;
}
}
return url;
}
function createEvent(eventType) {
return typeof Event === "function"
? new Event(eventType)
: { type: eventType };
}
function MockHmrWebSocket(url) {
this.url = String(url);
this.readyState = 1;
this.bufferedAmount = 0;
this.extensions = "";
this.protocol = "";
this.binaryType = "blob";
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
this._l = {};
var socket = this;
setTimeout(function () {
socket._d("open", createEvent("open"));
}, 0);
}
MockHmrWebSocket.CONNECTING = 0;
MockHmrWebSocket.OPEN = 1;
MockHmrWebSocket.CLOSING = 2;
MockHmrWebSocket.CLOSED = 3;
MockHmrWebSocket.prototype.addEventListener = function (eventType, callback) {
(this._l[eventType] || (this._l[eventType] = [])).push(callback);
};
MockHmrWebSocket.prototype.removeEventListener = function (
eventType,
callback,
) {
var listeners = this._l[eventType] || [];
this._l[eventType] = listeners.filter(function (listener) {
return listener !== callback;
});
};
MockHmrWebSocket.prototype._d = function (eventType, eventValue) {
var listeners = this._l[eventType] || [];
for (var i = 0; i < listeners.length; i++) {
listeners[i].call(this, eventValue);
}
var handler = this["on" + eventType];
if (typeof handler === "function") {
handler.call(this, eventValue);
}
};
MockHmrWebSocket.prototype.send = function () {};
MockHmrWebSocket.prototype.close = function (code, reason) {
if (this.readyState >= 2) return;
this.readyState = 3;
var closeEvent = createEvent("close");
closeEvent.code = code === undefined ? 1000 : code;
closeEvent.reason = reason || "";
closeEvent.wasClean = true;
this._d("close", closeEvent);
};
if (window.WebSocket) {
var OriginalWebSocket = window.WebSocket;
window.WebSocket = function (url, protocols) {
if (isHmrWebSocketUrl(url)) {
return new MockHmrWebSocket(rewriteNextAssetUrl(url));
}
return protocols === undefined
? new OriginalWebSocket(url)
: new OriginalWebSocket(url, protocols);
};
window.WebSocket.prototype = OriginalWebSocket.prototype;
Object.setPrototypeOf(window.WebSocket, OriginalWebSocket);
["CONNECTING", "OPEN", "CLOSING", "CLOSED"].forEach(function (stateKey) {
window.WebSocket[stateKey] = OriginalWebSocket[stateKey];
});
}
})();

View File

@@ -0,0 +1,256 @@
"""Unit tests for webapp proxy path rewriting/injection."""
from types import SimpleNamespace
from typing import cast
from typing import Literal
from uuid import UUID
import httpx
import pytest
from fastapi import Request
from sqlalchemy.orm import Session
from onyx.server.features.build.api import api
from onyx.server.features.build.api.api import _inject_hmr_fixer
from onyx.server.features.build.api.api import _rewrite_asset_paths
from onyx.server.features.build.api.api import _rewrite_proxy_response_headers
SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
BASE = f"/api/build/sessions/{SESSION_ID}/webapp"
def rewrite(html: str) -> str:
return _rewrite_asset_paths(html.encode(), SESSION_ID).decode()
def inject(html: str) -> str:
return _inject_hmr_fixer(html.encode(), SESSION_ID).decode()
class TestNextjsPathRewriting:
def test_rewrites_bare_next_script_src(self) -> None:
html = '<script src="/_next/static/chunks/main.js">'
result = rewrite(html)
assert f'src="{BASE}/_next/static/chunks/main.js"' in result
assert '"/_next/' not in result
def test_rewrites_bare_next_in_single_quotes(self) -> None:
html = "<link href='/_next/static/css/app.css'>"
result = rewrite(html)
assert f"'{BASE}/_next/static/css/app.css'" in result
def test_rewrites_bare_next_in_url_parens(self) -> None:
html = "background: url(/_next/static/media/font.woff2)"
result = rewrite(html)
assert f"url({BASE}/_next/static/media/font.woff2)" in result
def test_no_double_prefix_when_already_proxied(self) -> None:
"""assetPrefix makes Next.js emit already-prefixed URLs — must not double-rewrite."""
already_prefixed = f'<script src="{BASE}/_next/static/chunks/main.js">'
result = rewrite(already_prefixed)
# Should be unchanged
assert result == already_prefixed
# Specifically, no double path
assert f"{BASE}/{BASE}" not in result
def test_rewrites_favicon(self) -> None:
html = '<link rel="icon" href="/favicon.ico">'
result = rewrite(html)
assert f'"{BASE}/favicon.ico"' in result
def test_rewrites_json_data_path_double_quoted(self) -> None:
html = 'fetch("/data/tickets.json")'
result = rewrite(html)
assert f'"{BASE}/data/tickets.json"' in result
def test_rewrites_json_data_path_single_quoted(self) -> None:
html = "fetch('/data/items.json')"
result = rewrite(html)
assert f"'{BASE}/data/items.json'" in result
def test_rewrites_escaped_next_font_path_in_json_script(self) -> None:
"""Next dev can embed font asset paths in JSON-escaped script payloads."""
html = r'{"src":"\/_next\/static\/media\/font.woff2"}'
result = rewrite(html)
assert (
r'{"src":"\/api\/build\/sessions\/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\/webapp\/_next\/static\/media\/font.woff2"}'
in result
)
def test_rewrites_escaped_next_font_path_in_style_payload(self) -> None:
"""Keep dynamically generated next/font URLs inside the session proxy."""
html = r'{"css":"@font-face{src:url(\"\/_next\/static\/media\/font.woff2\")"}'
result = rewrite(html)
assert (
r"\/api\/build\/sessions\/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\/webapp\/_next\/static\/media\/font.woff2"
in result
)
def test_rewrites_absolute_next_font_url(self) -> None:
html = (
'<link rel="preload" as="font" '
'href="https://craft-dev.onyx.app/_next/static/media/font.woff2">'
)
result = rewrite(html)
assert f'"{BASE}/_next/static/media/font.woff2"' in result
def test_rewrites_root_hmr_path(self) -> None:
html = 'new WebSocket("wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc")'
result = rewrite(html)
assert '"wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc"' not in result
assert '"/_next/webpack-hmr?id=abc"' in result
def test_rewrites_escaped_absolute_next_font_url(self) -> None:
html = (
r'{"href":"https:\/\/craft-dev.onyx.app\/_next\/static\/media\/font.woff2"}'
)
result = rewrite(html)
assert (
r'{"href":"\/api\/build\/sessions\/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\/webapp\/_next\/static\/media\/font.woff2"}'
in result
)
class TestRuntimeFixerInjection:
def test_injects_websocket_rewrite_shim(self) -> None:
html = "<html><head></head><body></body></html>"
result = inject(html)
assert "window.WebSocket = function (url, protocols)" in result
assert f'var WEBAPP_BASE = "{BASE}"' in result
def test_injects_hmr_websocket_stub(self) -> None:
html = "<html><head></head><body></body></html>"
result = inject(html)
assert "function MockHmrWebSocket(url)" in result
assert "return new MockHmrWebSocket(rewriteNextAssetUrl(url));" in result
def test_injects_before_head_contents(self) -> None:
html = "<html><head><title>x</title></head><body></body></html>"
result = inject(html)
assert result.index(
"window.WebSocket = function (url, protocols)"
) < result.index("<title>x</title>")
def test_rewritten_hmr_url_still_matches_shim_intercept_logic(self) -> None:
html = (
"<html><head></head><body>"
'new WebSocket("wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc")'
"</body></html>"
)
rewritten = rewrite(html)
assert '"wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc"' not in rewritten
assert 'new WebSocket("/_next/webpack-hmr?id=abc")' in rewritten
injected = inject(rewritten)
assert 'new WebSocket("/_next/webpack-hmr?id=abc")' in injected
assert 'parsedUrl.pathname.indexOf("/_next/webpack-hmr") === 0' in injected
class TestProxyHeaderRewriting:
def test_rewrites_link_header_font_preload_paths(self) -> None:
headers = {
"link": (
'</_next/static/media/font.woff2>; rel=preload; as="font"; crossorigin, '
'</_next/static/media/font2.woff2>; rel=preload; as="font"; crossorigin'
)
}
result = _rewrite_proxy_response_headers(headers, SESSION_ID)
assert f"<{BASE}/_next/static/media/font.woff2>" in result["link"]
class TestProxyRequestWiring:
def test_proxy_request_rewrites_link_header_on_html_response(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
html = b"<html><head></head><body>ok</body></html>"
upstream = httpx.Response(
200,
headers={
"content-type": "text/html; charset=utf-8",
"link": '</_next/static/media/font.woff2>; rel=preload; as="font"',
},
content=html,
)
monkeypatch.setattr(api, "_get_sandbox_url", lambda *_args: "http://sandbox")
class FakeClient:
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
def __enter__(self) -> "FakeClient":
return self
def __exit__(self, *_args: object) -> Literal[False]:
return False
def get(self, _url: str, headers: dict[str, str]) -> httpx.Response:
assert "host" not in {key.lower() for key in headers}
return upstream
monkeypatch.setattr(api.httpx, "Client", FakeClient)
request = cast(Request, SimpleNamespace(headers={}, query_params=""))
response = api._proxy_request(
"", request, UUID(SESSION_ID), cast(Session, SimpleNamespace())
)
assert response.headers["link"] == (
f'<{BASE}/_next/static/media/font.woff2>; rel=preload; as="font"'
)
def test_proxy_request_injects_hmr_fixer_for_html_response(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
upstream = httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"<html><head><title>x</title></head><body></body></html>",
)
monkeypatch.setattr(api, "_get_sandbox_url", lambda *_args: "http://sandbox")
class FakeClient:
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
def __enter__(self) -> "FakeClient":
return self
def __exit__(self, *_args: object) -> Literal[False]:
return False
def get(self, _url: str, headers: dict[str, str]) -> httpx.Response:
assert "host" not in {key.lower() for key in headers}
return upstream
monkeypatch.setattr(api.httpx, "Client", FakeClient)
request = cast(Request, SimpleNamespace(headers={}, query_params=""))
response = api._proxy_request(
"", request, UUID(SESSION_ID), cast(Session, SimpleNamespace())
)
body = cast(bytes, response.body).decode("utf-8")
assert "window.WebSocket = function (url, protocols)" in body
assert body.index("window.WebSocket = function (url, protocols)") < body.index(
"<title>x</title>"
)
def test_rewrites_absolute_link_header_font_preload_paths(self) -> None:
headers = {
"link": (
"<https://craft-dev.onyx.app/_next/static/media/font.woff2>; "
'rel=preload; as="font"; crossorigin'
)
}
result = _rewrite_proxy_response_headers(headers, SESSION_ID)
assert f"<{BASE}/_next/static/media/font.woff2>" in result["link"]