Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/dev/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,31 @@ async fn create_local_build(

const DEFAULT_CONFIG: &str = "./wavedash.toml";

fn resolve_sdk_js(path: PathBuf) -> Result<PathBuf> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve_sdk_js is the one piece of new logic here with real branching — dir → dist/inject.global.js<dir>/inject.global.js → direct-file passthrough → error — and it's the piece a user hits first and most often (--sdk-js ~/code/sdk-js vs --sdk-js ~/code/sdk-js/dist vs a direct path). It has no test, while the much thinner inject_url switch got one (a_local_bundle_is_served_from_this_origin).

tempfile is already a dev-dependency and src/config.rs:1026 has the tempfile::tempdir() pattern to copy, so this is cheap:

#[test]
fn a_local_sdk_js_resolves_from_a_repo_root_a_dist_dir_or_a_direct_file() {
    let dir = tempfile::tempdir().expect("temp dir");
    let root = dir.path();
    let dist = root.join("dist");
    std::fs::create_dir(&dist).unwrap();
    let bundle = dist.join("inject.global.js");
    std::fs::write(&bundle, "//").unwrap();

    // The repo root a developer actually types.
    assert_eq!(resolve_sdk_js(root.to_path_buf()).unwrap(), bundle);
    // The dist dir — must not become dist/dist/inject.global.js.
    assert_eq!(resolve_sdk_js(dist.clone()).unwrap(), bundle);
    // A direct path is taken as-is.
    assert_eq!(resolve_sdk_js(bundle.clone()).unwrap(), bundle);
    // Nothing to serve is an error, not a 500 at request time.
    assert!(resolve_sdk_js(root.join("nope")).is_err());
}

The dist case is the one worth locking down: the candidate order is what keeps it from resolving dist/dist/inject.global.js, and reordering the vec! would silently break it.

const BUNDLE: &str = "inject.global.js";
let candidates = if path.is_dir() {
vec![path.join("dist").join(BUNDLE), path.join(BUNDLE)]
} else {
vec![path.clone()]
};
candidates
.into_iter()
.find(|candidate| candidate.is_file())
.ok_or_else(|| anyhow::anyhow!("no {BUNDLE} found at {}", path.display()))
}

pub async fn handle_dev(
config_path: Option<PathBuf>,
verbose: bool,
no_open: bool,
upload_source: UploadSource,
sdk_js: Option<PathBuf>,
) -> Result<()> {
let sdk_js = sdk_js.map(resolve_sdk_js).transpose()?;
if let Some(path) = &sdk_js {
println!(" Serving sdk-js from {}", path.display());
}

let auth_manager = AuthManager::new()?;
let api_key = auth_manager
.get_api_key()
Expand Down Expand Up @@ -205,6 +224,7 @@ pub async fn handle_dev(
client,
engine_entry,
jwks: tokio::sync::OnceCell::new(),
sdk_js,
},
)
.await
Expand Down
67 changes: 59 additions & 8 deletions src/dev/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,23 @@ const SDK_JS_VERSION: &str = include_str!("sdk-js-version");
/// Classic parser-blocking IIFE (auto-runs setupWavedashSDK): the only way
/// `window.Wavedash` exists before game scripts parse — module scripts are
/// always deferred. jsdelivr sends ACAO * + CORP, satisfying COEP.
fn inject_url() -> String {
fn cdn_inject_url() -> String {
format!(
"https://cdn.jsdelivr.net/npm/@wvdsh/sdk-js@{}/dist/inject.global.js",
SDK_JS_VERSION.trim()
)
}

/// Same-origin, which COEP allows without the CORP a cross-origin build needs.
const LOCAL_SDK_URL: &str = "/__wavedash/sdk.js";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Serving the bundle under a renamed URL breaks its sourcemap, which is exactly what you want when debugging a local sdk-js build. A dev dist/inject.global.js ends with //# sourceMappingURL=inject.global.js.map; the browser resolves that relative to /__wavedash/sdk.js, requests /__wavedash/inject.global.js.map, which falls through to handle_static and 404s — so SDK frames stay minified/unmapped in devtools.

Cheapest fix: serve any sibling *.map next to cfg.sdk_js from /__wavedash/<name> (or route the bundle at /__wavedash/inject.global.js and serve the map alongside it).


fn inject_url(sdk_js: Option<&Path>) -> String {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inject_url never reads the path — only whether it's Some. That makes Option<&Path> a boolean in a path's clothing, and it's threaded through shell and inject_sdk purely to reach this match, which is what forced three signature changes and four call-site edits in this diff.

Since ServeConfig is built once and the URL is a pure function of sdk_js, resolving it at construction collapses all of that:

// in ServeConfig
/// The SDK bundle URL injected into every page — local when `sdk_js` is set.
pub inject_url: String,

Then shell(&cfg.inject_url, ...) / inject_sdk(&cfg.inject_url, ...) take a &str, inject_url(sdk_js) disappears, and the "is there a local build" decision happens once next to resolve_sdk_js instead of on every request. It also removes the mild misdirection for the next reader, who currently has to follow Option<&Path> two frames down to learn the path never lands in the URL.

Not worth blocking on — but if you'd rather keep the current shape, fn inject_url(local: bool) at least says what the parameter is doing.

match sdk_js {
Some(_) => LOCAL_SDK_URL.to_string(),
None => cdn_inject_url(),
}
}

/// `{{NAME}}` placeholders substitute data only — logic stays in the template.
const SHELL_TEMPLATE: &str = include_str!("shell.html");
const DEV_JS: &str = include_str!("dev.js");
Expand Down Expand Up @@ -64,6 +74,8 @@ pub struct ServeConfig {
pub client: reqwest::Client,
/// Engine builds boot via play's real default entrypoint — the prod path.
pub engine_entry: Option<EngineEntry>,
/// Local sdk-js bundle to serve in place of the pinned CDN build.
pub sdk_js: Option<PathBuf>,
/// Backend public keys (/.well-known/jwks.json), fetched once on demand.
pub jwks: tokio::sync::OnceCell<jsonwebtoken::jwk::JwkSet>,
}
Expand Down Expand Up @@ -100,6 +112,7 @@ pub async fn run(listener: TcpListener, cfg: ServeConfig) -> Result<()> {
let mut app = Router::new()
.route("/__wavedash/callback", get(handle_callback))
.route("/__wavedash/dev.js", get(handle_dev_js))
.route(LOCAL_SDK_URL, get(handle_sdk_js))
.route("/auth/refresh", post(handle_auth_refresh))
.route("/", get(handle_index))
.fallback(get(handle_static))
Expand Down Expand Up @@ -490,14 +503,20 @@ async fn handle_index(

if let Some(engine_entry) = &cfg.engine_entry {
let html = shell(
cfg.sdk_js.as_deref(),
&engine_entry.entrypoint_url,
Some(&engine_entry.params_json),
&config,
);
return respond(StatusCode::OK, HTML, None, html);
}
if cfg.entry.ends_with(".js") {
let html = shell(&format!("/{}", cfg.entry), None, &config);
let html = shell(
cfg.sdk_js.as_deref(),
&format!("/{}", cfg.entry),
None,
&config,
);
return respond(StatusCode::OK, HTML, None, html);
}
// The HTML entry serves at its real path so relative assets resolve.
Expand Down Expand Up @@ -529,6 +548,22 @@ async fn handle_dev_js() -> Response {
respond(StatusCode::OK, "text/javascript; charset=utf-8", None, DEV_JS)
}

/// Read per request, so rebuilding sdk-js only needs a page reload.
async fn handle_sdk_js(State(cfg): State<Arc<ServeConfig>>) -> Response {
let Some(path) = cfg.sdk_js.as_deref() else {
return respond(StatusCode::NOT_FOUND, TEXT, None, "Not Found");
};
match std::fs::read(path) {
Ok(bytes) => respond(StatusCode::OK, "text/javascript; charset=utf-8", None, bytes),
Err(e) => respond(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read-failure path fails silently from the user's point of view. The doc comment invites the workflow "rebuild sdk-js, reload the page", so reloading mid-rebuild (file momentarily missing, or read back truncated) is a realistic race. In either case window.Wavedash never gets defined, and dev.js's initGate starts with if (window.Wavedash.gameLoaded) — it throws TypeError inside the DOMContentLoaded listener, so the boot overlay never mounts and no message is shown. The developer gets a black page and has to find the 500 in the network tab.

Consider responding with JS that surfaces the reason through the existing gate, e.g. body window.__wavedashBootError = "could not read <path>: <err>"; with Content-Type: text/javascript (status 200 or 500 — the script body is what the gate can act on).

StatusCode::INTERNAL_SERVER_ERROR,
TEXT,
None,
format!("could not read {}: {e}", path.display()),
),
}
}

/// Authed HTML navigations get the config global + SDK tags injected (prod's
/// embed.js mechanism); everything else streams as-is.
fn serve_file(cfg: &ServeConfig, url_path: &str, config: Option<&str>) -> Response {
Expand All @@ -542,7 +577,11 @@ fn serve_file(cfg: &ServeConfig, url_path: &str, config: Option<&str>) -> Respon
let (content_type, encoding) = content_type_and_encoding(url_path);
if let Some(config) = config {
if encoding.is_none() && content_type.starts_with("text/html") {
let injected = inject_sdk(&String::from_utf8_lossy(&bytes), config);
let injected = inject_sdk(
cfg.sdk_js.as_deref(),
&String::from_utf8_lossy(&bytes),
config,
);
return respond(StatusCode::OK, HTML, None, injected);
}
}
Expand Down Expand Up @@ -572,9 +611,14 @@ fn respond(
/// Boot shell: play's real default entrypoint for engine builds (the prod
/// path), the game's own script with null params for `.js` entries. The `<`
/// escape keeps params values from closing the script tag.
fn shell(script_src: &str, params_json: Option<&str>, config: &str) -> String {
fn shell(
sdk_js: Option<&Path>,
script_src: &str,
params_json: Option<&str>,
config: &str,
) -> String {
SHELL_TEMPLATE
.replace("{{INJECT_URL}}", &inject_url())
.replace("{{INJECT_URL}}", &inject_url(sdk_js))
.replace(
"{{ENTRYPOINT_PARAMS}}",
&params_json.unwrap_or("null").replace('<', "\\u003c"),
Expand All @@ -593,13 +637,13 @@ fn js_string_literal(s: &str) -> String {

/// Inject the config global + SDK bundle + init gate right after
/// `<head ...>`, or prepended if there's no head.
fn inject_sdk(html: &str, config: &str) -> String {
fn inject_sdk(sdk_js: Option<&Path>, html: &str, config: &str) -> String {
let tags = format!(
"<script>window.__wavedashSdkConfig = {};</script>\
<script src=\"{}\" crossorigin=\"anonymous\"></script>\
<script src=\"/__wavedash/dev.js\"></script>",
js_string_literal(config),
inject_url()
inject_url(sdk_js)
);
match head_insert_pos(&html.to_ascii_lowercase()) {
Some(pos) => format!("{}{}{}", &html[..pos], tags, &html[pos..]),
Expand Down Expand Up @@ -691,11 +735,18 @@ mod tests {

#[test]
fn the_inject_url_never_carries_the_version_files_trailing_newline() {
let url = inject_url();
let url = inject_url(None);
assert!(!url.contains(char::is_whitespace), "{url:?}");
assert!(
url.contains(&format!("@wvdsh/sdk-js@{}/", SDK_JS_VERSION.trim())),
"{url:?}"
);
}

#[test]
fn a_local_bundle_is_served_from_this_origin() {
let url = inject_url(Some(Path::new("/tmp/sdk-js/dist/inject.global.js")));
assert_eq!(url, LOCAL_SDK_URL);
assert!(!url.contains("jsdelivr"), "{url:?}");
}
}
54 changes: 9 additions & 45 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ enum Commands {
help = "Attribute the build to the tool running the CLI instead of the CLI itself"
)]
upload_source: Option<UploadSource>,
#[arg(
long = "sdk-js",
value_name = "PATH",
hide = true,
help = "Serve a local sdk-js build instead of the pinned CDN one — an inject.global.js, or a directory holding it"
)]
sdk_js: Option<PathBuf>,
},
#[command(
about = "Publish an uploaded build to wavedash.com",
Expand Down Expand Up @@ -645,12 +652,14 @@ async fn run() -> Result<()> {
config,
no_open,
upload_source,
sdk_js,
} => {
handle_dev(
config,
cli.verbose,
no_open,
upload_source.unwrap_or_default(),
sdk_js,
)
.await?;
}
Expand Down Expand Up @@ -930,51 +939,6 @@ mod tests {
}
}

#[test]
fn upload_source_is_hidden_and_only_offers_the_godot_plugin() {
fn walk(cmd: &clap::Command, path: &[String], found: &mut Vec<String>) {
if let Some(arg) = cmd
.get_arguments()
.find(|arg| arg.get_long() == Some("upload-source"))
{
let command = path.join(" ");
assert!(
arg.is_hide_set(),
"`{}` lists --upload-source in its help",
command
);
let values: Vec<String> = arg
.get_possible_values()
.into_iter()
.map(|value| value.get_name().to_string())
.collect();
assert_eq!(
values,
["godot-plugin"],
"`{}` offers a source other than the Godot plugin's",
command
);
found.push(command);
}

for sub in cmd.get_subcommands() {
let mut sub_path = path.to_vec();
sub_path.push(sub.get_name().to_string());
walk(sub, &sub_path, found);
}
}

let mut found = Vec::new();
walk(&Cli::command(), &["wavedash".to_string()], &mut found);
found.sort();

assert_eq!(
found,
["wavedash build push", "wavedash dev"],
"every command that creates a build row should be able to name its source"
);
}

#[test]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR deletes upload_source_is_hidden_and_only_offers_the_godot_plugin, which is unrelated to --sdk-js and leaves a coverage hole: nothing now asserts that --upload-source is hide-set, nor that wavedash dev's copy restricts values to godot-plugin (the surviving upload_source_parses_the_plugin_and_defaults_to_the_cli only exercises build push). Concretely, dropping hide = true on either arg, or adding a new UploadSource variant, now passes CI silently.

If the concern is the test name advertising a hidden flag, renaming it (e.g. every_build_creating_command_agrees_on_its_source_arg) keeps the guard without the signal.

fn upload_source_parses_the_plugin_and_defaults_to_the_cli() {
fn push_source(argv: &[&str]) -> Option<UploadSource> {
Expand Down
Loading