Compare commits

...

19 Commits

Author SHA1 Message Date
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 325 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,51 @@ 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"\/")
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,
)
text = re.sub(
r'"(/(?:[a-zA-Z0-9_-]+/)*[a-zA-Z0-9_-]+\.json)"',
f'"{webapp_base_path}\\1"',
@@ -261,11 +298,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 +397,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 +451,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 +459,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,108 @@
(function () {
var B = "__WEBAPP_BASE__";
function h(u) {
if (!u) return false;
try {
var x = new URL(String(u), window.location.href);
return (
x.pathname.indexOf("/_next/webpack-hmr") === 0 ||
x.pathname.indexOf("/_next/hmr") === 0
);
} catch (e) {}
if (typeof u === "string") {
return (
u.indexOf("/_next/webpack-hmr") === 0 || u.indexOf("/_next/hmr") === 0
);
}
return false;
}
function r(u) {
if (!u) return u;
try {
var x = new URL(String(u), window.location.href);
if (x.pathname.indexOf("/_next/") === 0) {
return B + x.pathname + x.search + x.hash;
}
} catch (e) {}
if (typeof u === "string" && u.indexOf("/_next/") === 0) {
return B + u;
}
return u;
}
function e(t) {
return typeof Event === "function" ? new Event(t) : { type: t };
}
function H(u) {
this.url = String(u);
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 s = this;
setTimeout(function () {
s._d("open", e("open"));
}, 0);
}
H.CONNECTING = 0;
H.OPEN = 1;
H.CLOSING = 2;
H.CLOSED = 3;
H.prototype.addEventListener = function (t, c) {
(this._l[t] || (this._l[t] = [])).push(c);
};
H.prototype.removeEventListener = function (t, c) {
var a = this._l[t] || [];
this._l[t] = a.filter(function (f) {
return f !== c;
});
};
H.prototype._d = function (t, v) {
var a = this._l[t] || [];
for (var i = 0; i < a.length; i++) {
a[i].call(this, v);
}
var n = this["on" + t];
if (typeof n === "function") {
n.call(this, v);
}
};
H.prototype.send = function () {};
H.prototype.close = function (c, reason) {
if (this.readyState >= 2) return;
this.readyState = 3;
var v = e("close");
v.code = c === undefined ? 1000 : c;
v.reason = reason || "";
v.wasClean = true;
this._d("close", v);
};
if (window.WebSocket) {
var O = window.WebSocket;
window.WebSocket = function (u, p) {
if (h(u)) return new H(r(u));
return p === undefined ? new O(u) : new O(u, p);
};
window.WebSocket.prototype = O.prototype;
Object.setPrototypeOf(window.WebSocket, O);
["CONNECTING", "OPEN", "CLOSING", "CLOSED"].forEach(function (k) {
window.WebSocket[k] = O[k];
});
}
})();

View File

@@ -0,0 +1,147 @@
"""Unit tests for webapp proxy path rewriting/injection."""
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):
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):
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):
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):
"""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):
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):
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):
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):
"""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):
"""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):
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):
html = 'new WebSocket("wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc")'
result = rewrite(html)
assert f'"{BASE}/_next/webpack-hmr?id=abc"' in result
def test_rewrites_escaped_absolute_next_font_url(self):
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):
html = "<html><head></head><body></body></html>"
result = inject(html)
assert "window.WebSocket = function (u, p)" in result
assert f'var B = "{BASE}"' in result
def test_injects_hmr_websocket_stub(self):
html = "<html><head></head><body></body></html>"
result = inject(html)
assert "function H(u)" in result
assert "if (h(u)) return new H(r(u));" in result
def test_injects_before_head_contents(self):
html = "<html><head><title>x</title></head><body></body></html>"
result = inject(html)
assert result.index("window.WebSocket = function (u, p)") < result.index(
"<title>x</title>"
)
class TestProxyHeaderRewriting:
def test_rewrites_link_header_font_preload_paths(self):
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"]
assert f"<{BASE}/_next/static/media/font2.woff2>" in result["link"]
def test_rewrites_absolute_link_header_font_preload_paths(self):
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"]