Rewrite wavedash dev as a plain local static server#35
Conversation
Drops the Electron dev-app entirely (dev-app/, its release workflow, self-signed certs, --host-rules subdomain MITM) in favor of a plain axum static server: the game runs top-level at http://localhost:PORT. - Per-browser auth: requests without credentials 303 to the mainsite /auth/dev handoff, which mints a 1-day gameplay session per browser (signed-in user or anonymous) and bounces back to /__wavedash/callback; the session token lives in an HttpOnly wd_session cookie. /auth/refresh proxies the cookie to /api/dev/refresh-gameplay for fresh 1h gameplay JWTs, so sessions renew silently. A 401 clears the dead cookie and the handoff re-runs on reload; navigations carrying ?sdkconfig without the cookie are redirected back through the handoff (cookie is the credential, sdkconfig is not). - SDK injection at serve time, prod-identical mechanism: a parser- blocking classic script tag for @wvdsh/sdk-js dist/inject.global.js (jsdelivr @latest) right after <head>, followed by /__wavedash/dev.js (boot-error surfacing + an init gate that stays up until the game calls Wavedash.init()). - Engine builds (unity/godot/jsdos/ruffle) boot through play's real default entrypoints: engine_shell.html sets window.entrypointParams (computed by the un-deprecated /cli/entrypoint-params for unity/godot, or the toml for jsdos/ruffle) and loads {PLAY_STATICS_HOST}/default-entrypoint.js?engine&engineVersion. The CLI has zero version-resolution logic. .js entrypoints get a synthetic shell; HTML entrypoints are served with the tags injected. - New required build env PLAY_STATICS_HOST (bare play domain). Removed PLAYSITE_HOST. Dropped deps: zip, rcgen, axum-server, tower, tower-http; added mime_guess, chrono (clock). - --verbose: timestamped request log with status, method, path, and decoded query params as indented aligned key/value lines. - release.yml regenerated via dist generate after removing the dev-app local-artifacts jobs from dist-workspace.toml.
|
Claude finished @franzwarning's task in 3m 24s —— View job Code Review — Rewrite
|
| document.body.append(overlay); | ||
| var started = Date.now(); | ||
| var tick = setInterval(function () { | ||
| if (window.Wavedash.initialized) { |
There was a problem hiding this comment.
Gate assumes Wavedash exists
Medium Severity
The initGate function accesses window.Wavedash.initialized without checking if window.Wavedash is defined. If the SDK script fails to load, this throws an error, which prevents the loading gate and any boot error messages from displaying.
Reviewed by Cursor Bugbot for commit 4b6a9ea. Configure here.
synth_shell.html and engine_shell.html merge into shell.html:
window.entrypointParams is always set (null for plain .js entries),
{{SCRIPT_SRC}} is either play's default entrypoint or the game's own
script, and both get the onerror -> gate error surfacing that was
engine-only before.
The engine shell's #wavedash-target is fixed inset-0, which would
swallow every click in a .js game that doesn't mount into it — so it's
visibility:hidden while :empty: no paint, no hit-testing, but its
viewport dimensions stay measurable for entrypoints that size the
canvas before mounting, and it shows itself as soon as an entrypoint
(or the game) appends content.
dev.js falls back to a generic message when the failed entry's error
body is empty, since the local static 404s are plain text.
|
Claude finished @franzwarning's task in 3m 8s —— View job Code Review — Rewrite
|
| function initGate() { | ||
| if (window.Wavedash.initialized) return; |
There was a problem hiding this comment.
Bug: the init gate crashes (silently) when the SDK bundle fails to load — exactly the failure this file exists to surface.
initGate() opens with if (window.Wavedash.initialized) return;. The injected inject.global.js tag (jsdelivr @latest) is parser-blocking, so if it fails to load — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined. This line then throws TypeError: Cannot read properties of undefined (reading 'initialized'), the overlay never mounts, and the user gets a blank page with no error shown. The unhandledrejection handler doesn't help, since no SDK promise is ever created.
The same unguarded deref repeats inside the tick at dev.js:55, so even guarding line 37 alone would leave the interval throwing before it ever reaches the boot-error display at lines 62–68.
Suggested fix (both spots): read var wd = window.Wavedash; and guard wd && wd.initialized, and when wd is still undefined after a short delay, surface a "Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload." message in msg instead of letting the gate throw.
| // Expired/revoked session. Clear the dead cookie so the next page | ||
| // load re-runs the handoff instead of serving a game that 401s again. | ||
| Ok(_) => Response::builder() | ||
| .status(StatusCode::UNAUTHORIZED) | ||
| .header(header::CONTENT_TYPE, TEXT) | ||
| .header(header::CACHE_CONTROL, "no-store") | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"), | ||
| ) | ||
| .body(Body::from("Session expired")) | ||
| .unwrap(), |
There was a problem hiding this comment.
A transient upstream error drops a still-valid dev session.
This arm treats any non-success response from /api/dev/refresh-gameplay as "expired/revoked" and clears wd_session. But Ok(_) also catches transient upstream failures — a 502/503 from the backend, a brief blip — and those will silently delete a perfectly valid 1-day session, forcing the browser back through the full /auth/dev handoff mid-play.
Only a genuine auth rejection (401/403) should clear the cookie. Suggest matching the status explicitly:
// Truly dead session — clear the cookie so the next load re-runs the handoff.
Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
// ... existing Set-Cookie Max-Age=0 + "Session expired" body ...
.unwrap(),
// Transient upstream error — surface as 502 WITHOUT clearing a valid cookie.
Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),(Matches the Err(_) arm's 502 behavior, which already correctly leaves the cookie intact — see server.rs:224-227.)
The mainsite handoff now redirects back with a short-lived playKey (prod's play-iframe credential) rather than the 1-day session token. The callback exchanges it server-side via the api-key-gated /api/dev/exchange-playkey and stores the returned session token in the wd_session cookie — the long-lived credential never rides in a URL. An expired playKey (redirect restored past its 2-minute TTL) gets a clear 401 telling the dev to reopen the game URL.
|
Claude finished @franzwarning's task in 2m 18s —— View job Code Review — Rewrite
|
| // during parse never see a frame of it. Translucent on purpose, so engine | ||
| // loading UI stays visible underneath. | ||
| function initGate() { | ||
| if (window.Wavedash.initialized) return; |
There was a problem hiding this comment.
Still unaddressed in b6e18f4: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.
initGate() opens with if (window.Wavedash.initialized) return;. The injected inject.global.js tag (jsdelivr @latest) is parser-blocking, so if it fails to load — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and this line throws TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts and the user gets a blank page with no error. The unhandledrejection handler doesn't help, since no SDK promise was ever created.
The same unguarded deref repeats in the tick at dev.js:55, so guarding line 37 alone still leaves the interval throwing before it can reach the boot-error display (lines 62–68).
Suggested fix for both spots: read var wd = window.Wavedash; and guard wd && wd.initialized, and when wd is still undefined after a short delay, surface a "Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload." message in msg instead of letting the gate throw.
| Ok(_) => Response::builder() | ||
| .status(StatusCode::UNAUTHORIZED) | ||
| .header(header::CONTENT_TYPE, TEXT) | ||
| .header(header::CACHE_CONTROL, "no-store") | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"), | ||
| ) | ||
| .body(Body::from("Session expired")) | ||
| .unwrap(), |
There was a problem hiding this comment.
Still unaddressed in b6e18f4: a transient upstream error drops a still-valid 1-day dev session.
This Ok(_) arm treats any non-success response from /api/dev/refresh-gameplay as "expired/revoked" and clears wd_session. But it also catches transient upstream failures — a 502/503, a brief backend blip — and silently deletes a perfectly valid session, forcing the browser back through the full /auth/dev handoff mid-play.
Only a genuine auth rejection (401/403) should clear the cookie; transient errors should surface as 502 and leave the cookie intact — matching the Err(_) arm just below (server.rs:266), which already gets this right.
// Truly dead session — clear the cookie so the next load re-runs the handoff.
Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
)
.body(Body::from("Session expired"))
.unwrap(),
// Transient upstream error — don't clear a still-valid cookie.
Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),(The same any-non-success pattern is in the new handle_callback exchange arm at server.rs:198, but it's far less harmful there — the playKey handoff is one-shot and reopening the URL recovers cleanly.)
|
Claude finished @franzwarning's task in 1m 50s —— View job Code Review — Rewrite
|
The callback now sets three cookies and redirects to a bare '/':
- wd_session_{build} (HttpOnly): the gameplay session, as before.
- wd_jwt_{build} (HttpOnly): the last issued gameplay JWT. /auth/refresh
verifies it locally (RS256 against the backend's public
/.well-known/jwks.json, cached; 5-min freshness margin) and serves it
back without a backend round-trip; tampered/expired falls back to a
real refresh that re-cookies.
- wd_sdkconfig_{port} (JS-readable): setupWavedashSDK >=1.3.23 falls
back to it when the URL has no ?sdkconfig=, so the game URL is just
http://localhost:PORT/.
Session/JWT cookies are build-scoped (a relaunched server can reuse a
port but never a build uuid, so stale cookies can't resurrect an old
build's session); the sdkconfig cookie is port-scoped because page JS
can only derive the port from location — a stale port collision fails
the session check and re-runs the handoff.
Requires @wvdsh/sdk-js 1.3.23 (cookie fallback) on jsdelivr @latest.
|
Claude finished @franzwarning's task in 2m 53s —— View job Code Review — Rewrite
|
| // Covers the page until the game calls Wavedash.init() — stricter than | ||
| // prod's gate, deliberately. Translucent so engine loading UI shows through. | ||
| function initGate() { | ||
| if (window.Wavedash.initialized) return; |
There was a problem hiding this comment.
Still unaddressed at f45f884: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.
initGate() opens with if (window.Wavedash.initialized) return; (and the tick derefs it again at dev.js:48). The injected inject.global.js (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and both lines throw TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts → blank page with no error. The unhandledrejection handler can't help: no SDK promise was ever created.
Guard both spots and surface the failure once wd stays undefined:
function initGate() {
var wd = window.Wavedash;
if (wd && wd.initialized) return;
// ...build overlay...
var tick = setInterval(function () {
wd = window.Wavedash;
if (wd && wd.initialized) { overlay.remove(); clearInterval(tick); return; }
if (!overlay.isConnected) document.body.append(overlay);
if (window.__wavedashBootError) {
msg.textContent = window.__wavedashBootError;
} else if (!wd && Date.now() - started > 3000) {
msg.textContent = 'Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload.';
} else if (Date.now() - started > 10000) {
msg.textContent = "Your game hasn't called Wavedash.init() — on wavedash.com the loading screen hangs exactly like this.";
}
}, 150);
}| // Clear the dead cookies so the next reload re-runs the handoff. | ||
| Ok(_) => Response::builder() | ||
| .status(StatusCode::UNAUTHORIZED) | ||
| .header(header::CONTENT_TYPE, TEXT) | ||
| .header(header::CACHE_CONTROL, "no-store") | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!( | ||
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| cfg.session_cookie_name() | ||
| ), | ||
| ) | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!( | ||
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| cfg.jwt_cookie_name() | ||
| ), | ||
| ) | ||
| .body(Body::from("Session expired")) | ||
| .unwrap(), |
There was a problem hiding this comment.
Still unaddressed at f45f884: a transient upstream error drops a still-valid 1-day session. This commit added the JWT cookie to the clear list, but the underlying arm is unchanged.
This Ok(_) arm treats any non-success from /api/dev/refresh-gameplay as expired/revoked and clears both wd_session and wd_jwt, returning 401. A transient 502/503 blip therefore silently kills a valid session and forces the browser back through the full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookies; transient errors should 502 and leave them intact — matching the Err(_) arm just below (server.rs:319-322), which already gets this right.
| // Clear the dead cookies so the next reload re-runs the handoff. | |
| Ok(_) => Response::builder() | |
| .status(StatusCode::UNAUTHORIZED) | |
| .header(header::CONTENT_TYPE, TEXT) | |
| .header(header::CACHE_CONTROL, "no-store") | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.session_cookie_name() | |
| ), | |
| ) | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.jwt_cookie_name() | |
| ), | |
| ) | |
| .body(Body::from("Session expired")) | |
| .unwrap(), | |
| // A genuine auth rejection — clear the dead cookies so the next reload | |
| // re-runs the handoff. | |
| Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder() | |
| .status(StatusCode::UNAUTHORIZED) | |
| .header(header::CONTENT_TYPE, TEXT) | |
| .header(header::CACHE_CONTROL, "no-store") | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.session_cookie_name() | |
| ), | |
| ) | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.jwt_cookie_name() | |
| ), | |
| ) | |
| .body(Body::from("Session expired")) | |
| .unwrap(), | |
| // Transient upstream error — don't clear a still-valid session. | |
| Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"), |
| cfg.sdkconfig_cookie_name(), | ||
| urlencoding::encode(&p.sdkconfig) | ||
| ), | ||
| ) |
There was a problem hiding this comment.
Session cookies omit Max-Age
Low Severity
After a successful auth callback, wd_session and gameplay JWT cookies are set without Max-Age or Expires, so they behave as browser session cookies. That ends the advertised one-day dev gameplay session when the browser closes, even if the backend session is still valid.
Reviewed by Cursor Bugbot for commit f45f884. Configure here.
The sdkconfig cookie becomes HttpOnly and build-scoped like the others (no port scoping anywhere); every served page gets a parser-ordered inline 'window.__wavedashSdkConfig = ...' before the SDK tag, which setupWavedashSDK >=1.3.23 falls back to. Classic scripts execute in document order, so the global always exists before the SDK runs — the same guarantee entrypointParams already relies on. The value is JSON-string-escaped with < as < so config can't close the tag.
|
Claude finished @franzwarning's task in 2m 3s —— View job Code Review — Rewrite
|
| // Covers the page until the game calls Wavedash.init() — stricter than | ||
| // prod's gate, deliberately. Translucent so engine loading UI shows through. | ||
| function initGate() { | ||
| if (window.Wavedash.initialized) return; |
There was a problem hiding this comment.
Still unaddressed at 9680bd6: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.
initGate() opens with if (window.Wavedash.initialized) return; (and the tick derefs it again at dev.js:48). The injected inject.global.js (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and both lines throw TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts → blank page with no error. The unhandledrejection handler can't help: no SDK promise was ever created.
Guard both reads and surface the failure once wd stays undefined:
function initGate() {
var wd = window.Wavedash;
if (wd && wd.initialized) return;
// ...build overlay...
var started = Date.now();
var tick = setInterval(function () {
wd = window.Wavedash;
if (wd && wd.initialized) {
overlay.remove();
clearInterval(tick);
return;
}
if (!overlay.isConnected) document.body.append(overlay);
if (window.__wavedashBootError) {
msg.textContent = window.__wavedashBootError;
} else if (!wd && Date.now() - started > 3000) {
msg.textContent =
'Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload.';
} else if (Date.now() - started > 10000) {
msg.textContent =
"Your game hasn't called Wavedash.init() — on wavedash.com " +
'the loading screen hangs exactly like this.';
}
}, 150);
}| // Clear the dead cookies so the next reload re-runs the handoff. | ||
| Ok(_) => Response::builder() | ||
| .status(StatusCode::UNAUTHORIZED) | ||
| .header(header::CONTENT_TYPE, TEXT) | ||
| .header(header::CACHE_CONTROL, "no-store") | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!( | ||
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| cfg.session_cookie_name() | ||
| ), | ||
| ) | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!( | ||
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| cfg.jwt_cookie_name() | ||
| ), | ||
| ) | ||
| .body(Body::from("Session expired")) | ||
| .unwrap(), |
There was a problem hiding this comment.
Still unaddressed at 9680bd6: a transient upstream error drops a still-valid 1-day session.
This Ok(_) arm treats any non-success from /api/dev/refresh-gameplay as expired/revoked and clears both wd_session and wd_jwt, returning 401. A transient 502/503 blip therefore silently kills a valid session and forces the browser back through the full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookies; transient errors should 502 and leave them intact — matching the Err(_) arm just below (server.rs:315-318), which already gets this right.
| // Clear the dead cookies so the next reload re-runs the handoff. | |
| Ok(_) => Response::builder() | |
| .status(StatusCode::UNAUTHORIZED) | |
| .header(header::CONTENT_TYPE, TEXT) | |
| .header(header::CACHE_CONTROL, "no-store") | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.session_cookie_name() | |
| ), | |
| ) | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.jwt_cookie_name() | |
| ), | |
| ) | |
| .body(Body::from("Session expired")) | |
| .unwrap(), | |
| // A genuine auth rejection — clear the dead cookies so the next reload | |
| // re-runs the handoff. | |
| Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder() | |
| .status(StatusCode::UNAUTHORIZED) | |
| .header(header::CONTENT_TYPE, TEXT) | |
| .header(header::CACHE_CONTROL, "no-store") | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.session_cookie_name() | |
| ), | |
| ) | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.jwt_cookie_name() | |
| ), | |
| ) | |
| .body(Body::from("Session expired")) | |
| .unwrap(), | |
| // Transient upstream error — don't clear a still-valid session. | |
| Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"), |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 6 total unresolved issues (including 4 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9680bd6. Configure here.
|
Claude finished @franzwarning's task in 2m 26s —— View job Code Review — Rewrite
|
| // Covers the page until the game calls Wavedash.init() — stricter than | ||
| // prod's gate, deliberately. Translucent so engine loading UI shows through. | ||
| function initGate() { | ||
| if (window.Wavedash.initialized) return; |
There was a problem hiding this comment.
Still unaddressed at ccef8e2: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.
initGate() opens with if (window.Wavedash.initialized) return;, and the tick derefs it again at dev.js:48. The injected inject.global.js (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and both lines throw TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts → blank page with no error shown. The unhandledrejection handler can't help: no SDK promise was ever created.
Guard both reads (var wd = window.Wavedash; if (wd && wd.initialized)) and surface the failure once wd stays undefined past a short delay:
| if (window.Wavedash.initialized) return; | |
| function initGate() { | |
| var wd = window.Wavedash; | |
| if (wd && wd.initialized) return; | |
| var style = document.createElement('style'); | |
| style.textContent = '@keyframes wd-spin{to{transform:rotate(360deg)}}'; | |
| var spinner = document.createElement('div'); | |
| spinner.style.cssText = | |
| 'width:28px;height:28px;border-radius:50%;border:3px solid rgba(226,232,240,0.25);' + | |
| 'border-top-color:#e2e8f0;animation:wd-spin 0.8s linear infinite'; | |
| var msg = document.createElement('p'); | |
| msg.style.cssText = 'margin:0;max-width:42ch;line-height:1.5'; | |
| var overlay = document.createElement('div'); | |
| overlay.style.cssText = | |
| 'position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;' + | |
| 'align-items:center;justify-content:center;gap:16px;background:rgba(8,10,18,0.65);' + | |
| 'color:#e2e8f0;font:14px ui-sans-serif,system-ui,sans-serif;text-align:center'; | |
| overlay.append(style, spinner, msg); | |
| document.body.append(overlay); | |
| var started = Date.now(); | |
| var tick = setInterval(function () { | |
| wd = window.Wavedash; | |
| if (wd && wd.initialized) { | |
| overlay.remove(); | |
| clearInterval(tick); | |
| return; | |
| } | |
| // Re-attach if the game replaced the body's contents. | |
| if (!overlay.isConnected) document.body.append(overlay); | |
| if (window.__wavedashBootError) { | |
| msg.textContent = window.__wavedashBootError; | |
| } else if (!wd && Date.now() - started > 3000) { | |
| msg.textContent = | |
| 'Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload.'; | |
| } else if (Date.now() - started > 10000) { | |
| msg.textContent = | |
| "Your game hasn't called Wavedash.init() — on wavedash.com " + | |
| 'the loading screen hangs exactly like this.'; | |
| } | |
| }, 150); | |
| } |
| // Clear the dead cookies so the next reload re-runs the handoff. | ||
| Ok(_) => Response::builder() | ||
| .status(StatusCode::UNAUTHORIZED) | ||
| .header(header::CONTENT_TYPE, TEXT) | ||
| .header(header::CACHE_CONTROL, "no-store") | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!( | ||
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| cfg.session_cookie_name() | ||
| ), | ||
| ) | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!( | ||
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| cfg.jwt_cookie_name() | ||
| ), | ||
| ) | ||
| .body(Body::from("Session expired")) | ||
| .unwrap(), |
There was a problem hiding this comment.
Still unaddressed at ccef8e2: a transient upstream error drops a still-valid 1-day session.
This Ok(_) arm treats any non-success from /api/dev/refresh-gameplay as expired/revoked and clears both wd_session and wd_jwt, returning 401. A transient 502/503 blip therefore silently kills a valid session and forces the browser back through the full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookies; transient errors should 502 and leave them intact — matching the Err(_) arm right below (server.rs:315-318), which already gets this right.
| // Clear the dead cookies so the next reload re-runs the handoff. | |
| Ok(_) => Response::builder() | |
| .status(StatusCode::UNAUTHORIZED) | |
| .header(header::CONTENT_TYPE, TEXT) | |
| .header(header::CACHE_CONTROL, "no-store") | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.session_cookie_name() | |
| ), | |
| ) | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.jwt_cookie_name() | |
| ), | |
| ) | |
| .body(Body::from("Session expired")) | |
| .unwrap(), | |
| // A genuine auth rejection — clear the dead cookies so the next reload | |
| // re-runs the handoff. | |
| Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder() | |
| .status(StatusCode::UNAUTHORIZED) | |
| .header(header::CONTENT_TYPE, TEXT) | |
| .header(header::CACHE_CONTROL, "no-store") | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.session_cookie_name() | |
| ), | |
| ) | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.jwt_cookie_name() | |
| ), | |
| ) | |
| .body(Body::from("Session expired")) | |
| .unwrap(), | |
| // Transient upstream error — don't clear a still-valid session. | |
| Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"), |
|
Claude finished @franzwarning's task in 1m 36s —— View job Code Review — Rewrite
|
| // Covers the page until the game calls Wavedash.init() — stricter than | ||
| // prod's gate, deliberately. Translucent so engine loading UI shows through. | ||
| function initGate() { | ||
| if (window.Wavedash.initialized) return; |
There was a problem hiding this comment.
Still unaddressed at 6253341: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.
initGate() opens with if (window.Wavedash.initialized) return;, and the tick derefs it again at dev.js:48. The injected inject.global.js (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and both lines throw TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts → blank page with no error. The unhandledrejection handler can't help: no SDK promise was ever created.
Guard both reads and surface the failure once wd stays undefined past a short delay:
| if (window.Wavedash.initialized) return; | |
| function initGate() { | |
| var wd = window.Wavedash; | |
| if (wd && wd.initialized) return; | |
| var style = document.createElement('style'); | |
| style.textContent = '@keyframes wd-spin{to{transform:rotate(360deg)}}'; | |
| var spinner = document.createElement('div'); | |
| spinner.style.cssText = | |
| 'width:28px;height:28px;border-radius:50%;border:3px solid rgba(226,232,240,0.25);' + | |
| 'border-top-color:#e2e8f0;animation:wd-spin 0.8s linear infinite'; | |
| var msg = document.createElement('p'); | |
| msg.style.cssText = 'margin:0;max-width:42ch;line-height:1.5'; | |
| var overlay = document.createElement('div'); | |
| overlay.style.cssText = | |
| 'position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;' + | |
| 'align-items:center;justify-content:center;gap:16px;background:rgba(8,10,18,0.65);' + | |
| 'color:#e2e8f0;font:14px ui-sans-serif,system-ui,sans-serif;text-align:center'; | |
| overlay.append(style, spinner, msg); | |
| document.body.append(overlay); | |
| var started = Date.now(); | |
| var tick = setInterval(function () { | |
| wd = window.Wavedash; | |
| if (wd && wd.initialized) { | |
| overlay.remove(); | |
| clearInterval(tick); | |
| return; | |
| } | |
| // Re-attach if the game replaced the body's contents. | |
| if (!overlay.isConnected) document.body.append(overlay); | |
| if (window.__wavedashBootError) { | |
| msg.textContent = window.__wavedashBootError; | |
| } else if (!wd && Date.now() - started > 3000) { | |
| msg.textContent = | |
| 'Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload.'; | |
| } else if (Date.now() - started > 10000) { | |
| msg.textContent = | |
| "Your game hasn't called Wavedash.init() — on wavedash.com " + | |
| 'the loading screen hangs exactly like this.'; | |
| } | |
| }, 150); | |
| } |
| Ok(_) => Response::builder() | ||
| .status(StatusCode::UNAUTHORIZED) | ||
| .header(header::CONTENT_TYPE, TEXT) | ||
| .header(header::CACHE_CONTROL, "no-store") | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!( | ||
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| cfg.session_cookie_name() | ||
| ), | ||
| ) | ||
| .header( | ||
| header::SET_COOKIE, | ||
| format!( | ||
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| cfg.jwt_cookie_name() | ||
| ), | ||
| ) | ||
| .body(Body::from("Session expired")) | ||
| .unwrap(), |
There was a problem hiding this comment.
Still unaddressed at 6253341: a transient upstream error drops a still-valid 1-day session.
This Ok(_) arm treats any non-success from /api/dev/refresh-gameplay as expired/revoked and clears both wd_session and wd_jwt, returning 401. A transient 502/503 blip therefore silently kills a valid session and forces the browser back through the full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookies; transient errors should 502 and leave them intact — matching the Err(_) arm right below (server.rs:315-318), which already gets this right.
| Ok(_) => Response::builder() | |
| .status(StatusCode::UNAUTHORIZED) | |
| .header(header::CONTENT_TYPE, TEXT) | |
| .header(header::CACHE_CONTROL, "no-store") | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.session_cookie_name() | |
| ), | |
| ) | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.jwt_cookie_name() | |
| ), | |
| ) | |
| .body(Body::from("Session expired")) | |
| .unwrap(), | |
| // A genuine auth rejection — clear the dead cookies so the next reload | |
| // re-runs the handoff. | |
| Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder() | |
| .status(StatusCode::UNAUTHORIZED) | |
| .header(header::CONTENT_TYPE, TEXT) | |
| .header(header::CACHE_CONTROL, "no-store") | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.session_cookie_name() | |
| ), | |
| ) | |
| .header( | |
| header::SET_COOKIE, | |
| format!( | |
| "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | |
| cfg.jwt_cookie_name() | |
| ), | |
| ) | |
| .body(Body::from("Session expired")) | |
| .unwrap(), | |
| // Transient upstream error — don't clear a still-valid session. | |
| Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"), |


What
wavedash devno longer downloads and launches the Electron dev-app — the game now runs top-level athttp://localhost:PORTfrom a plain axum static server.dev-app/, its release workflow, the self-signed certs, and the--host-rulessubdomain MITM are all deleted (−3,200 lines net).Auth (per-browser sessions)
Credential-less requests 303 to the mainsite
/auth/devhandoff, which mints a 1-day gameplay session for that browser (signed-in user, or anonymous — incognito gets a play-anonymously choice page) and bounces back to/__wavedash/callback; the token lives in an HttpOnlywd_sessioncookie./auth/refreshproxies the cookie to/api/dev/refresh-gameplayfor fresh 1h gameplay JWTs, so play sessions renew silently. A 401 refresh clears the dead cookies; HTML navigations without this browser's cookies are sent back through the handoff — so a reload self-heals cleared cookies and expired sessions.SDK injection (prod-identical mechanism)
At serve time the server injects a parser-blocking classic script tag for
@wvdsh/sdk-jsdist/inject.global.js(jsdelivr@latest) right after<head>— same approach as prod's embed.js injection, sowindow.Wavedashexists before any game script parses. A second tag loads/__wavedash/dev.js: boot-error surfacing plus an init gate that stays up until the game callsWavedash.init()(stricter than prod, deliberately — with a hint after 10s).Engine builds boot through play's real default entrypoints
engine_shell.htmlsetswindow.entrypointParams(computed by/cli/entrypoint-paramsfor Unity/Godot from the exported HTML — same parsers prod runs at upload — or from the toml for js-dos/Ruffle) and loads{PLAYSITE_HOST}/default-entrypoint.js?engine&engineVersion. The CLI has zero version-resolution logic; stale CLIs gain new engine versions on play deploys..jsentrypoints get a synthetic shell; HTML entrypoints are served with tags injected.Misc
PLAYSITE_HOSTbuild env (bare play domain, same value as mainsite'sPUBLIC_PLAYSITE_HOST; https-prefixed like SITE_HOST) — already present in every cli Doppler config, so no release prep needed.CF_R2_WAVEDASH_DEV_APP_HOSTremoved.--verbose: timestamped request log (status, method, path) with decoded query params printed as indented aligned key/value lines.release.ymlregenerated viadist generateafter dropping the dev-app jobs.Verification
Full E2E against the live local stack across multiple sessions: JS leaderboard game (read/write, achievements, stats), Godot 4.x fixture through play's real entrypoint, custom-path regression, session-expiry/cleared-cookie recovery, incognito anonymous flow — all green.
cargo check/clippyclean.Companion PRs: wvdsh/wavedash and wvdsh/play (
wavedash-devbranches).Note
Medium Risk
Large behavioral change to the primary local dev workflow (auth cookies, engine boot path) and removal of the packaged dev-app release pipeline; core CLI upload/auth paths are mostly unchanged.
Overview
Replaces the Electron dev-app with an in-process local dev server —
wavedash devnow serves the upload directory athttp://localhost:<port>via axum instead of downloading a platform zip, spawning Electron, and MITMing*.local.<playsite>with self-signed HTTPS.The
dev-app/tree,dev-app-release.yml, and R2 dev-app publishing are removed from CI (release.ymlno longer waits oncustom-dev-app-release). Build env dropsCF_R2_WAVEDASH_DEV_APP_HOST;PLAYSITE_HOSTis used for engine default-entrypoint URLs.src/devis rewritten aroundserver::run: per-browser auth (redirect to mainsite/auth/dev,/__wavedash/callback, HttpOnlywd_sessioncookies, proxied/auth/refresh), HTML injection of the SDK (inject.global.js) plus/__wavedash/dev.js(boot gate / errors), and engine boots through{playsite}/default-entrypoint.jswith params from/cli/entrypoint-paramsor toml. Old modules (dev_app,launcher, zip/CDN install) are deleted; first run sweeps stale~/.wavedash/dev-app*caches.Dependencies: drops
zip,axum-server,rcgen,tower/tower-http; addsjsonwebtoken,mime_guess,chrono. CLI version 0.1.84.Reviewed by Cursor Bugbot for commit 6253341. Bugbot is set up for automated code reviews on this repo. Configure here.