Skip to content
Merged
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
10 changes: 3 additions & 7 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@ fn main() {
// Required config vars - must be set either by Doppler or .env
let required = [
"SITE_HOST",
// Host that serves game iframes (e.g. wavedashcdn.com). Mirrors the
// mainsite's PUBLIC_PLAYSITE_HOST — must match, since the iframe
// origin is `<gameId>.local.<PLAYSITE_HOST>` and `wavedash dev` maps
// that subdomain to the local server via chromium --host-rules.
// MUST match mainsite's PUBLIC_PLAYSITE_HOST — `wavedash dev` MAPs
// `*.local.<PLAYSITE_HOST>` via chromium --host-rules; drift = silent.
"PLAYSITE_HOST",
"CONVEX_HTTP_URL",
// Public host of the wavedash-dev-app R2 bucket (e.g. r2.dev URL or
// a custom domain). Baked in here so `wavedash dev` always knows
// where to fetch the matching Wavedash Dev build.
// R2 host for the wavedash-dev-app zip downloaded by `wavedash dev`.
"CF_R2_WAVEDASH_DEV_APP_HOST",
];

Expand Down
46 changes: 29 additions & 17 deletions dev-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ and execs the binary directly.

## Layout

- `src/main.ts` — process entry. Reads JSON config from stdin, starts the
local HTTPS server, points Chromium at it via `--host-rules`, and
navigates to the playtest URL.
- `src/server.ts` — local HTTPS server. Handles requests for the game
subdomain: synthesizes the `/dev-app-embed` shell, serves files from the upload
dir with COEP/COOP/CORP, and reverse-proxies a fixed set of paths
(`/embed.js`, `/auth/`, `/gameplay/`, …) to the real network.
- `src/cert.ts` — generates a self-signed cert at startup. The cert is
whitelisted via `session.setCertificateVerifyProc` for the game
subdomain only, so DevTools network is unaffected.
- `src/main.ts` — process entry. Reads JSON config from the temp file at
`--config-path=<path>` (CLI also passes `--user-data-dir` and
`--parent-pid`), starts the local HTTPS server, points Chromium at it
via `--host-rules`, and navigates to the playtest URL.
- `src/server.ts` — local HTTPS server. Handles requests for any
`{gcid}-{userhash}.<localHostSuffix>` host: synthesizes the
`/dev-app-embed` shell, serves files from the upload dir with
COEP/COOP/CORP, and reverse-proxies a fixed set of paths (`/embed.js`,
`/sw-bootstrap`, `/auth/refresh`, …) to the real network.
- `src/cert.ts` — generates a self-signed cert at startup with a wildcard
SAN `*.<localHostSuffix>`. The cert is whitelisted via
`session.setCertificateVerifyProc` for any host under that suffix only,
so DevTools network is unaffected.
- `electron-builder.json5` — produces `<productName>-<version>-<os>-<arch>.zip`
per platform. The release workflow renames each to `<platform>.zip`.

Expand All @@ -24,9 +27,9 @@ and execs the binary directly.
Electron's `webContents.debugger` and bundled DevTools share a single CDP
slot. While the debugger is attached with `Fetch.enable`, the bundled
DevTools' Network tab is broken (and opening DevTools detaches our
debugger). Routing the game subdomain through a real local HTTPS server
keeps Chromium's network stack untouched, so right-click → Inspect Element
opens DevTools with full Network/Performance panels.
debugger). Routing the wildcard `*.<localHostSuffix>` through a real local
HTTPS server keeps Chromium's network stack untouched, so right-click →
Inspect Element opens DevTools with full Network/Performance panels.

## No code signing

Expand Down Expand Up @@ -81,17 +84,25 @@ bundler doesn't typecheck, it just transpiles.

## IPC contract

CLI → dev-app over **stdin** — one JSON line:
CLI → dev-app via the temp file at `--config-path=<path>` — one JSON
object. Stdin is intentionally unused (Windows GUI-subsystem .exes detach
from inherited stdin pipes):

```json
{
"uploadDir": "/abs/path",
"gameSubdomain": "<gameId>.local.wavedashcdn.com",
"localHostSuffix": "local.wavedashcdn.com",
"playtestUrl": "https://wavedash.com/playtest/<slug>/<uuid>",
"verbose": false
}
```

The dev-app reads it synchronously at startup and unlinks the file.
`localHostSuffix` is the **shared suffix** for every iframe origin the
mainsite may navigate to (`{gcid}-{userhash}.<localHostSuffix>`); the
dev-app uses a wildcard for cert SAN, `--host-rules` MAP, and the
cert-verify check.

Dev-app → CLI over **stdout** — one JSON object per line:

- `{"type":"ready"}` after first `did-finish-load`.
Expand All @@ -100,5 +111,6 @@ Dev-app → CLI over **stdout** — one JSON object per line:
stderr: server access log (one line per intercepted request) plus errors.
Always forwarded by the CLI; `--verbose` only adds CLI-side noise.

Closing stdin from the CLI side signals the dev-app to quit (used for
clean Ctrl+C shutdown).
Shutdown: on Ctrl+C the CLI kills the child directly. The dev-app also
polls `--parent-pid=<pid>` every second and quits if the CLI process is
gone, so a CLI crash doesn't leave an orphan.
10 changes: 6 additions & 4 deletions dev-app/src/cert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Generate a self-signed cert at startup.
*
* The cert is presented by the local HTTPS server and whitelisted via
* `session.setCertificateVerifyProc` for the game subdomain only — so we
* `session.setCertificateVerifyProc` for any host under the local suffix
* (every `{gcid}-{userhash}.{suffix}` the mainsite may navigate to) — so we
* don't need a real CA, and we don't weaken trust for any real origin.
*
* One key per process; rotates every dev-app launch.
Expand All @@ -16,9 +17,10 @@ export interface CertPair {
keyPem: string;
}

export function generateCert(commonName: string): CertPair {
export function generateCert(localHostSuffix: string): CertPair {
const wildcard = `*.${localHostSuffix}`;
const pems = selfsigned.generate(
[{ name: 'commonName', value: commonName }],
[{ name: 'commonName', value: wildcard }],
{
keySize: 2048,
days: 30,
Expand All @@ -34,7 +36,7 @@ export function generateCert(commonName: string): CertPair {
{
name: 'subjectAltName',
altNames: [
{ type: 2, value: commonName }, // DNS
{ type: 2, value: wildcard }, // DNS — wildcard SAN
{ type: 7, ip: '127.0.0.1' }, // IPv4 — for direct localhost probes
{ type: 7, ip: '::1' }, // IPv6
],
Expand Down
47 changes: 26 additions & 21 deletions dev-app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
* at the top of this module so every per-app path is pinned before any
* `app.whenReady` work runs.
* 3. Start a local HTTPS server (`server.ts`) on a free port and apply
* `--host-rules=MAP <gameSubdomain>:443 127.0.0.1:<port>` so chromium
* routes the game iframe to us. No CDP `Fetch.enable`, so the bundled
* DevTools' Network tab works normally.
* `--host-rules=MAP *.<localHostSuffix>:443 127.0.0.1:<port>` so chromium
* routes every {gcid}-{userhash}.local.<playsite> iframe to us. No CDP
* `Fetch.enable`, so the bundled DevTools' Network tab works normally.
* 4. Open a single BrowserWindow, install a cert-verify proc that accepts
* our self-signed cert for the game subdomain, and navigate to the
* playtest URL.
* our self-signed cert for any host under <localHostSuffix>, and
* navigate to the playtest URL.
* 5. Emit `{"type":"ready"}` on first did-finish-load.
* 6. On window close → emit `{"type":"closed"}` and exit 0.
* 7. Parent watchdog: poll `process.kill(parentPid, 0)` once a second and
Expand Down Expand Up @@ -54,7 +54,7 @@ import { startServer, type StartedServer } from "./server";

interface RawConfig {
uploadDir: string;
gameSubdomain: string;
localHostSuffix: string;
playtestUrl: string;
verbose?: boolean;
}
Expand Down Expand Up @@ -134,7 +134,7 @@ function readConfig(): RawConfig {
const parsed = JSON.parse(raw) as RawConfig;
if (
typeof parsed.uploadDir !== "string" ||
typeof parsed.gameSubdomain !== "string" ||
typeof parsed.localHostSuffix !== "string" ||
typeof parsed.playtestUrl !== "string"
) {
throw new Error("config missing required fields");
Expand All @@ -160,10 +160,13 @@ function watchParentProcess(): void {
interval.unref();
}

function applyChromeSwitches(gameSubdomain: string, serverPort: number): void {
function applyChromeSwitches(localHostSuffix: string, serverPort: number): void {
// Wildcard MAP covers every {gcid}-{userhash}.{suffix} the mainsite may
// navigate to in the same dev session (account switches, incognito with
// an anonymous id, etc.).
app.commandLine.appendSwitch(
"host-rules",
`MAP ${gameSubdomain}:443 127.0.0.1:${serverPort}`,
`MAP *.${localHostSuffix}:443 127.0.0.1:${serverPort}`,
);
// GPU: match chrome://flags/#{enable-unsafe-webgpu, enable-vulkan,
// force-high-performance-gpu}. WebGPU games often need these for
Expand All @@ -174,17 +177,19 @@ function applyChromeSwitches(gameSubdomain: string, serverPort: number): void {
}

/**
* Trust our self-signed cert for the game subdomain only. Every other
* hostname falls through to chromium's default verification — so HTTPS
* to wavedash.com / third-party CDNs is verified normally.
* Trust our self-signed cert for any host under the local suffix (every
* `{gcid}-{userhash}.{suffix}` the mainsite may navigate to). Every other
* hostname falls through to chromium's default verification — so HTTPS to
* wavedash.com / third-party CDNs is verified normally.
*
* Safe because `--host-rules` guarantees that `<gameSubdomain>:443` resolves
* to our 127.0.0.1 server inside this chromium instance. There is no path
* by which a remote origin could impersonate it.
* Safe because `--host-rules` guarantees those hosts resolve to our
* 127.0.0.1 server inside this chromium instance. There is no path by
* which a remote origin could impersonate them.
*/
function trustLocalCertFor(s: Session, gameSubdomain: string): void {
function trustLocalCertFor(s: Session, localHostSuffix: string): void {
const dotSuffix = `.${localHostSuffix}`;
s.setCertificateVerifyProc((request, callback) => {
if (request.hostname === gameSubdomain) {
if (request.hostname.endsWith(dotSuffix)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: endsWith(dotSuffix) accepts multi-level subdomains (a.b.local.wavedashcdn.com), but both the chromium MAP *.<suffix>:443 rule and the wildcard SAN cert only cover single-label (a.local.wavedashcdn.com). The asymmetry is benign today because the host-rules rule is what actually routes traffic here, so multi-level hosts never reach this proc — but pinning the check to single-label keeps the trust surface aligned with what host-rules can deliver, and reads as more intentional:

s.setCertificateVerifyProc((request, callback) => {
  if (request.hostname.endsWith(dotSuffix)) {
    const label = request.hostname.slice(0, -dotSuffix.length);
    if (label.length > 0 && !label.includes(".")) {
      callback(0);
      return;
    }
  }
  callback(-3);
});

Optional — happy to leave as-is if you'd rather not add the extra check.

callback(0); // 0 = accept
return;
}
Expand Down Expand Up @@ -268,7 +273,7 @@ async function bootstrap(): Promise<void> {
try {
server = await startServer({
uploadDir: config.uploadDir,
gameSubdomain: config.gameSubdomain,
localHostSuffix: config.localHostSuffix,
verbose: !!config.verbose,
});
} catch (err) {
Expand All @@ -278,15 +283,15 @@ async function bootstrap(): Promise<void> {
}
if (config.verbose) {
process.stderr.write(
`local server listening on https://127.0.0.1:${server.port} (proxy for ${config.gameSubdomain})\n`,
`local server listening on https://127.0.0.1:${server.port} (proxy for *.${config.localHostSuffix})\n`,
);
}

applyChromeSwitches(config.gameSubdomain, server.port);
applyChromeSwitches(config.localHostSuffix, server.port);

await app.whenReady();

trustLocalCertFor(session.defaultSession, config.gameSubdomain);
trustLocalCertFor(session.defaultSession, config.localHostSuffix);

// Packaged builds get their icon from build/icon.png via electron-builder,
// which runs Apple's icon template at .icns generation time so the bundled
Expand Down
43 changes: 23 additions & 20 deletions dev-app/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
*
* Replaces the CDP `Fetch.enable` interceptor that previously hijacked the
* game subdomain inside chromium itself. Chromium now reaches us through
* `--host-rules=MAP <gameSubdomain>:443 127.0.0.1:<port>`, so the network
* `--host-rules=MAP *.<localHostSuffix>:443 127.0.0.1:<port>`, so the network
* stack stays untouched and the bundled DevTools' Network tab works.
*
* Routing per request (matches the previous interceptor 1:1):
* 1. Path `/dev-app-embed` → synthesize the embed shell (or
* CUSTOM-HTML with the SDK bootstrap
* injected).
* 2. Path in PASSTHROUGH_PREFIXES → reverse-proxy to the real
* `https://<gameSubdomain>` (Node DNS,
* `https://<incoming host>` (Node DNS,
* no host-rules → real network).
* 3. Otherwise → serve a file from `uploadDir` with
* COEP/COOP/CORP and transparent
Expand All @@ -30,7 +30,7 @@ import { generateCert, type CertPair } from "./cert";

export interface ServerConfig {
uploadDir: string;
gameSubdomain: string;
localHostSuffix: string;
verbose: boolean;
}

Expand Down Expand Up @@ -71,7 +71,7 @@ function logServed(res: http.ServerResponse, url: string): void {
export async function startServer(
config: ServerConfig,
): Promise<StartedServer> {
const cert = generateCert(config.gameSubdomain);
const cert = generateCert(config.localHostSuffix);

const server = https.createServer(
{ cert: cert.certPem, key: cert.keyPem },
Expand Down Expand Up @@ -109,10 +109,20 @@ async function handle(
res: http.ServerResponse,
config: ServerConfig,
): Promise<void> {
// SNI is honored by https.createServer, so req.headers.host carries the
// browser-facing host (the game subdomain). We use that to build the
// proxy URL for passthrough paths.
const hostHeader = req.headers.host ?? config.gameSubdomain;
// Host header is the browser-facing `{gcid}-{userhash}.<localHostSuffix>`.
const hostHeader = req.headers.host;
if (!hostHeader) {
res.statusCode = 400;
res.end("Missing Host header");
return;
}
const originHost = hostHeader.split(":")[0];
// Defense-in-depth: pin the upstream `proxyToOrigin` sees to our suffix.
if (!originHost.endsWith(`.${config.localHostSuffix}`)) {
res.statusCode = 400;
res.end("Unexpected Host");
return;
}
const url = new URL(req.url ?? "/", `https://${hostHeader}`);
const urlPath = url.pathname;

Expand All @@ -122,15 +132,15 @@ async function handle(
log(
"passthrough",
req.method ?? "GET",
config.gameSubdomain + urlPath + url.search,
originHost + urlPath + url.search,
);
}
await proxyToOrigin(req, res, config.gameSubdomain);
await proxyToOrigin(req, res, originHost);
return;
}

serveLocalFile(res, config.uploadDir, urlPath);
logServed(res, config.gameSubdomain + urlPath);
logServed(res, originHost + urlPath);
} catch (err) {
process.stderr.write(
`handler error for ${req.url ?? ""}: ${String(err)}\n`,
Expand All @@ -152,15 +162,8 @@ function isPassthroughPath(p: string): boolean {


/**
* Headers every response on the game subdomain must carry. Mirrors the
* production play worker (`play/src/server/index.ts`).
*
* - `Origin-Agent-Cluster: ?1` is sticky per origin per BrowsingContextGroup,
* so a single response without it locks the origin into site-keyed mode for
* the rest of the chromium session and breaks `crossOriginIsolated`.
* - `Cross-Origin-Resource-Policy: cross-origin` is required because the
* mainsite (which sets COEP=require-corp) is on a different site than the
* playsite (wavedash.com vs wavedashcdn.com, and likewise in dev).
* Mirrors the play worker: OAC=?1 (sticky per BrowsingContextGroup, lock-in
* if ever absent), CORP=cross-origin for the mainsite/playsite split.
*/
Comment on lines 164 to 167
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 deletion here drops useful context that isn't actually local-vs-prod-specific — the explanation for why each header is set still applies. In particular the Origin-Agent-Cluster: ?1 note is load-bearing: it's a per-origin, per-BCG sticky flag, and one response missing it locks the origin into site-keyed mode for the rest of the chromium session. That's the kind of thing future-you (or someone else touching this) needs to know before removing or reordering a header.

Suggest restoring the body of the comment and just adjusting the lead sentence to drop the "game subdomain" framing:

Suggested change
/**
* Headers every response on the game subdomain must carry. Mirrors the
* production play worker (`play/src/server/index.ts`).
*
* - `Origin-Agent-Cluster: ?1` is sticky per origin per BrowsingContextGroup,
* so a single response without it locks the origin into site-keyed mode for
* the rest of the chromium session and breaks `crossOriginIsolated`.
* - `Cross-Origin-Resource-Policy: cross-origin` is required because the
* mainsite (which sets COEP=require-corp) is on a different site than the
* playsite (wavedash.com vs wavedashcdn.com, and likewise in dev).
* Headers every response on the game subdomain must carry.
*/
/**
* Headers every response on the local server must carry. Mirrors the
* production play worker (`play/src/server/index.ts`).
*
* - `Origin-Agent-Cluster: ?1` is sticky per origin per BrowsingContextGroup,
* so a single response without it locks the origin into site-keyed mode for
* the rest of the chromium session and breaks `crossOriginIsolated`.
* - `Cross-Origin-Resource-Policy: cross-origin` is required because the
* mainsite (which sets COEP=require-corp) is on a different site than the
* playsite (wavedash.com vs wavedashcdn.com, and likewise in dev).
*/

function setIframeOriginHeaders(res: http.ServerResponse): void {
res.setHeader("Origin-Agent-Cluster", "?1");
Expand Down
2 changes: 1 addition & 1 deletion src/dev/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use super::dev_app::DevAppLaunch;
#[serde(rename_all = "camelCase")]
pub struct DevAppConfig {
pub upload_dir: String,
pub game_subdomain: String,
pub local_host_suffix: String,
pub playtest_url: String,
pub verbose: bool,
}
Expand Down
9 changes: 2 additions & 7 deletions src/dev/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,7 @@ pub async fn handle_dev(config_path: Option<PathBuf>, verbose: bool) -> Result<(

let site_host = config::get("open_browser_website_host")?;
let playsite_host = config::get("playsite_host")?;
// Iframe origin is `<gameId>.local.<playsite_host>` — must match
// GameRunnerComponent's `gameplayOrigin`, which builds the same string
// from PUBLIC_PLAYSITE_HOST and the Convex game `_id` (i.e. wavedash.toml's
// `game_id`, not the slug). The mainsite and playsite TLDs are split
// (wavedash.com vs wavedashcdn.com), so this MUST be the playsite.
let game_subdomain = format!("{}.local.{}", wavedash_config.game_id, playsite_host);
let local_host_suffix = format!("local.{}", playsite_host);

let playtest_url = format!(
"{}/playtest/{}/{}",
Expand All @@ -159,7 +154,7 @@ pub async fn handle_dev(config_path: Option<PathBuf>, verbose: bool) -> Result<(

let dev_app_config = DevAppConfig {
upload_dir: upload_dir.to_string_lossy().to_string(),
game_subdomain,
local_host_suffix,
playtest_url,
verbose,
};
Expand Down
Loading