Dev.to · 6 min read

One Vite build, two runtimes: shipping a browser game to the open web and into an offline WebView

One Vite build, two runtimes: shipping a browser game to the open web and into an offline WebView

I shipped ApexDrawn as a plain static site: you draw a closed loop, it becomes a racetrack, and then you drive it. Later I needed a second build target - a mobile shell that loads the same game from local assets with no network at all. The interesting part wasn't the game logic. It was that the whole app assumed a server: accounts, /api/* for purchases and garage state, a WebSocket relay for races, analytics, payments. None of that can exist in an offline WebView. I didn't want a fork, so the split is one build-time flag and everything else follows from it. One flag, two bundles // src/appMode.ts export const OFFLINE_APP = import.meta.env.VITE_OFFLINE_APP === "true"; Because this is a compile-time constant, Rollup tree-shakes the unreachable branch. The offline bundle genuinely contains no auth SDK, no analytics and no checkout code - not "code that checks a boolean at runtime and returns early". That distinction matters when your CSP forbids all network egress: the easiest network call to forget is the one hidden behind a feature flag. The web build emits absolute /assets/... URLs. The offline build is emitted with relative paths and copied under assets/web/ in the Flutter project, so its HTML references ./i18n.js and assets/index-*.js instead. Replace the session at the boundary, not in every component Components ask a context for authLoaded, signedIn, getToken() and user. The web provider wires that to an auth SDK. The offline provider is deliberately boring: export function OfflineSessionProvider({ children }: { children: ReactNode }) { const session: AppSession = { authLoaded: true, signedIn: true, getToken: async () => "local-only", userLoaded: true, user: /* a local profile */ null, }; return {children}; } No component needs if (offline). That's the whole point: one boundary, two implementations. The same trick covers state. Web state lives behind /api/*; offline state is a localStorage record with a schema version in the key (apexdrawn.offline-profile.v1) holding the balance, a ledger, owned items and tuning. Every read is defensive: const raw = window.localStorage.getItem(STORAGE_KEY); const parsed = JSON.parse(raw); return { ...fallback, ...parsed, balance: Number.isFinite(Number(parsed.balance)) ? Math.max(0, Number(parsed.balance)) : 0, ledger: Array.isArray(parsed.ledger) ? parsed.ledger : [], }; If storage is unavailable or contains garbage, it falls back to an in-memory copy. A WebView can and will hand you a corrupted profile; treating local storage as a trusted typed object is how you get a crash on launch. Relative paths are the actual hard part The site uses directory-style routes (/garage/, /free-racing-games/, /es/...). On a normal host the server resolves those. Inside a WebView loading assets/web/garage/index.html, a relative asset URL resolves relative to that file, so the correct prefix depends on how deep the current page is. The fix is a small path helper that strips the assets/web/ prefix, counts the remaining segments, and builds the prefix from that depth: const marker = "/assets/web/"; const markerIndex = pathname.lastIndexOf(marker); const suffix = markerIndex >= 0 ? pathname.slice(markerIndex + marker.length) : pathname; const segments = suffix.split("/").filter(Boolean); if (segments.at(-1) === "index.html") segments.pop(); const rootPrefix = "../".repeat(segments.length); Extensionless routes get normalized to index.html, query strings are preserved, and publicAsset() passes through data:, blob: and https?: URLs untouched. Routing itself uses the same logic: appPathParts() removes the WebView mount prefix so the app sees /garage whether it's hosted at a domain root or six directories deep in an APK. Language selection had the same shape. On the web, the language is the first path segment: const pathLanguage = window.location.pathname.split("/").filter(Boolean)[0]?.toLowerCase(); if (pathLanguage in localeByLanguage) return pathLanguage; Offline there is no meaningful path segment, so it reads a stored preference (apexLang) and finally falls back to document.documentElement.lang. Same public API, different source of truth. Enforce offline with the platform, not with discipline The offline HTML injects a strict CSP before anything else: default-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; navigate-to 'self' It also short-circuits fetch, XMLHttpRequest and sendBeacon for cross-origin or /api|auth|oauth URLs, neuters window.open, and cancels clicks on external links in the capture phase. This is belt-and-braces: even if a component accidentally tries to phone home, it can't. Native purchases go through an explicit bridge instead. The JS side posts a message to the host, listens for a CustomEvent, and matches on a request id: window.addEventListener("apexdrawn:purchase-result", onResult); bridge.postMessage(JSON.stringify({ type: "purchase", requestId, packageId })); A 180-second timeout, a matching requestId, and validation of packageId plus transactionId before resolving. An async bridge without those checks is a race condition waiting for a referee. A third shape, almost free There are also ~49 static landing pages generated by a Node script from a template. For those, the script strips the main module bundle and modulepreload links and replaces them with a tiny /info-page.js plus a stylesheet: html = html.replace(/\s*/, ''); html = html.replace(/\s*]*src="\/assets\/[^\"]+\.js"[^>]*>/, ""); Those pages are content, not an app. Shipping a full SPA bundle to them would be pure cost. It's the same lesson as the offline build in miniature: decide what a page actually needs, then remove the rest. What this costs Every networked feature needs two implementations. That's a real ongoing tax, not a one-time setup. The build matrix is easy to get wrong. I once nearly deployed the offline dist/ to the live site; the analytics code had been tree-shaken out, so the site would have quietly stopped reporting. Build flags belong in CI, not in someone's memory. Offline state doesn't sync. Local coins, unlocks and tuning are device-local by definition. That's a product decision, not a technical bug. Store rules differ. An offline in-app purchase goes through the platform's billing, with its own review and receipt semantics. The upside is one set of game logic and one place to fix a bug. Keep the differences at a handful of explicit boundaries (session, state, asset paths, network), and the rest of the codebase never learns that there are two runtimes. The playable web build is at https://apexdrawn.org/ - no download or account needed. If you've shipped a Vite app inside a WebView, I'd like to hear which boundary caused you the most trouble.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Sports News