Compare commits

...

18 Commits

Author SHA1 Message Date
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
2 changed files with 245 additions and 8 deletions

View File

@@ -1,3 +1,4 @@
import re
from collections.abc import Iterator
from pathlib import Path
from uuid import UUID
@@ -239,18 +240,83 @@ 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>(function(){{var B='{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,r){if(this.readyState>=2)return;this.readyState=3;"
"var v=e('close');v.code=c===undefined?1000:c;v.reason=r||'';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];});}"
"})()</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 +327,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 +426,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,
@@ -399,6 +488,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,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" 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") < 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"]