Skip to content

chore(dev): add spa fallback plugin and improve grpc stream reconnection logic#2173

Merged
ra3orblade merged 3 commits into
anyproto:developfrom
barissalihbabacan:feat/web-dev-spa-fallback
Apr 29, 2026
Merged

chore(dev): add spa fallback plugin and improve grpc stream reconnection logic#2173
ra3orblade merged 3 commits into
anyproto:developfrom
barissalihbabacan:feat/web-dev-spa-fallback

Conversation

@barissalihbabacan

@barissalihbabacan barissalihbabacan commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR introduces essential developer experience (DX) and stability improvements for the Anytype web environment, specifically addressing routing issues and gRPC stream race conditions.

What's Changed

SPA Fallback Plugin (vite.config.ts):
Implemented a robust spaFallbackPlugin to handle client-side routing and deep links. It uses a "try-disk-then-SPA" approach: it first attempts to serve static assets from the dist/ directory (ensuring /tabs.html, PDF workers, and fonts continue to work) before falling back to index.html for SPA routes.

Web Build Exports (vite.web.config.ts):
Added service_grpc_web_pb.js to the external exports list and improved the move-html plugin's cleanup logic. This prevents chunking errors during protobuf resolution in web builds and ensures a cleaner build directory.

GRPC Stream Connection Logic (dispatcher.ts & data.ts):
Refactored dispatcher.startStream() to return a Promise<void> that resolves when the gRPC event stream is established (or hits a safe fallback timeout). This replaces previous arbitrary delays with a structured readiness signal, ensuring session callbacks fire only when the stream is ready to receive events.

Impact

These changes make the web-dev server much more reliable, fix broken static assets in dev mode, and eliminate intermittent race conditions during login/session recovery.

@github-actions

github-actions Bot commented Apr 24, 2026

Copy link
Copy Markdown

CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅

@barissalihbabacan

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@barissalihbabacan

Copy link
Copy Markdown
Contributor Author

recheck

@ra3orblade

Copy link
Copy Markdown
Contributor

Adding 500ms timeout to the create session logic is definitely not the right way to do it, it should be returned with dispatcher.start logic, it can return a promise for example. Delaying session callback by 500ms is not stable and would take very long. And second thing is that you do not need to wait for AccountRecover events as there is no AccountRecover in createSession.

@barissalihbabacan

Copy link
Copy Markdown
Contributor Author

Adding 500ms timeout to the create session logic is definitely not the right way to do it, it should be returned with dispatcher.start logic, it can return a promise for example. Delaying session callback by 500ms is not stable and would take very long. And second thing is that you do not need to wait for AccountRecover events as there is no AccountRecover in createSession.

@ra3orblade,

You are absolutely right. A setTimeout of 500ms is a "magic number" hack, and dispatcher.start should definitely return a Promise to handle this cleanly. Also, thanks for the correction regarding AccountRecover in createSession—I was definitely tracing the wrong event flow there.

To give you some context on why this "dirty" workaround was added:
When running the web-dev environment (without having the desktop-specific files built), the app falls into a severe race condition. The login screen buttons become unresponsive or get stuck in a loop because the listeners are firing before the stream is fully established.

The 500ms timeout wasn't meant to be the final architectural fix; it was a diagnostic probe. It forced the system to take a breath, bypassed the race condition, and finally allowed me to log in and access my Space in the browser. Without it, I was completely locked out of the web environment.

Moving forward:
I agree the proper fix is a Promise-based refactor of dispatcher.start to resolve the root race condition cleanly.

I will revert the data.ts (500ms) commit from this PR right now so we can move forward with merging the Vite & SPA Fallback improvements. (Those are solid DX upgrades and shouldn't be blocked by this).

Are you guys planning to tackle the dispatcher Promise refactor soon to fix this web-dev login lock-out, or should I attempt it in a separate PR?

Let me know.

@ra3orblade

Copy link
Copy Markdown
Contributor

You can do it by yourself if you want, but i still did not get which race condition you are referring to, I'm using dev mode all the time and the only problem now with Vite HMR is that it sometimes reloads incorrectly when agent is doing a lot of edits at the same time.

@barissalihbabacan

Copy link
Copy Markdown
Contributor Author

You can do it by yourself if you want, but i still did not get which race condition you are referring to, I'm using dev mode all the time and the only problem now with Vite HMR is that it sometimes reloads incorrectly when agent is doing a lot of edits at the same time.

@ra3orblade
Thanks for following up! You are absolutely right. The race condition I was encountering was definitely due to the stream not being fully established before the UI tried to fetch data, specifically in my local dev-web environment.

I took your advice and pushed an update to address this properly:

  1. Reverted the 500ms setTimeout hack.
  2. Refactored dispatcher.startStream() to return a Promise<void> that resolves cleanly as soon as the gRPC stream fires its first metadata or data event (indicating it's fully OPEN).
  3. Used await dispatcher.startStream() inside the createSession callback.

This completely eliminates the arbitrary delay while guaranteeing the stream is ready before the session continues. Let me know if the Promise implementation looks solid to you!

@ra3orblade ra3orblade left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review

The core idea is solid — converting dispatcher.startStream() to a Promise is much better than the 500 ms delay mentioned in the description (which is now stale). A few issues to address before merging.


🔴 Blockers

1. vite.web.config.ts:339 — broken catch handler

try { fs.rmdirSync(path.resolve(__dirname, 'dist-web/dist')); }
catch (e) { console.error("[ProtobufCjsPlugin] Error evaluating exports for " + id + ":", e);}
  • This is in the move-html plugin, not ProtobufCjsPlugin — the log prefix is wrong.
  • id is not in scope in closeBundle(). If rmdirSync ever throws, the catch itself throws ReferenceError, defeating the try/catch.

Looks like a stray copy-paste. Restore the original silent catch {} or write a proper message:

catch (e) { console.error('[move-html] Failed to remove dist-web/dist:', e); }

2. vite.config.tsspaFallbackPlugin regresses static asset serving from dist/

The old devServerPlugin served arbitrary files from dist/ (e.g. dist/tabs.html, dist/workers/pdf.worker.mjs, dist/font/...) when requested at root URLs.

The new plugin only bypasses /@, /node_modules/, /src/, /dist/, /api/. Everything else returns index.html. Since:

  • electron.js:1656 loads tabs in dev via http://localhost:${port}/tabs.html
  • src/ts/component/util/media/pdf.tsx:7 sets pdfjs.GlobalWorkerOptions.workerSrc = './workers/pdf.worker.mjs' (resolves to /workers/... from /)
  • font files load from /font/...

…and vite.config.ts:21 has publicDir: false, so Vite's built-in static handler doesn't catch them either. Result: requests to /tabs.html or /workers/pdf.worker.mjs now return the SPA index.html instead of the actual file. Tabs window and PDF rendering will break in dev.

Fix options:

  • Keep the old dist/ fallback logic in addition to the SPA fallback (try-disk-then-SPA), or
  • Add explicit bypasses for /tabs.html, /workers/, /font/, plus static-serve for those paths.

🟡 Smaller issues

3. vite.config.ts — stale JSDoc. The comment above spaFallbackPlugin still describes the old devServerPlugin behavior.

4. dispatcher.ts:79–129 — Promise can never reject. Every event path (metadata, data, status, end, missing token) resolves. Callers using await can't distinguish "stream established" from "stream failed" or "no token". Either reject on !S.Auth.token / status.code != 0, or document that the promise always resolves.

**5. dispatcher.ts:159 (in reconnect()) — this.startStream() returned Promise is silently discarded. Add void this.startStream(); or a brief comment to make it intentional.

6. data.ts:434–443 — duplicated callBack?.(message). Cleaner as a single trailing call:

C.WalletCreateSession(phrase, key, token, async (message: any) => {
    if (!message.error.code) {
        S.Auth.tokenSet(message.token);
        S.Auth.appTokenSet(message.appToken);
        await dispatcher.startStream();
    };
    callBack?.(message);
});

7. PR description is outdated. Still claims a "500 ms delay" — please update to reflect the Promise-based fix actually shipped in 518fb52.


✅ Looks good

  • Adding service_grpc_web_pb.js to optimizeDeps.include — clean, targeted fix.
  • Promise-based replacement for the startup race is the right approach; resolving on metadata is the correct readiness signal for grpc-web.

@barissalihbabacan

Copy link
Copy Markdown
Contributor Author

Review

The core idea is solid — converting dispatcher.startStream() to a Promise is much better than the 500 ms delay mentioned in the description (which is now stale). A few issues to address before merging.

🔴 Blockers

1. vite.web.config.ts:339 — broken catch handler

try { fs.rmdirSync(path.resolve(__dirname, 'dist-web/dist')); }
catch (e) { console.error("[ProtobufCjsPlugin] Error evaluating exports for " + id + ":", e);}
  • This is in the move-html plugin, not ProtobufCjsPlugin — the log prefix is wrong.
  • id is not in scope in closeBundle(). If rmdirSync ever throws, the catch itself throws ReferenceError, defeating the try/catch.

Looks like a stray copy-paste. Restore the original silent catch {} or write a proper message:

catch (e) { console.error('[move-html] Failed to remove dist-web/dist:', e); }

2. vite.config.tsspaFallbackPlugin regresses static asset serving from dist/

The old devServerPlugin served arbitrary files from dist/ (e.g. dist/tabs.html, dist/workers/pdf.worker.mjs, dist/font/...) when requested at root URLs.

The new plugin only bypasses /@, /node_modules/, /src/, /dist/, /api/. Everything else returns index.html. Since:

  • electron.js:1656 loads tabs in dev via http://localhost:${port}/tabs.html
  • src/ts/component/util/media/pdf.tsx:7 sets pdfjs.GlobalWorkerOptions.workerSrc = './workers/pdf.worker.mjs' (resolves to /workers/... from /)
  • font files load from /font/...

…and vite.config.ts:21 has publicDir: false, so Vite's built-in static handler doesn't catch them either. Result: requests to /tabs.html or /workers/pdf.worker.mjs now return the SPA index.html instead of the actual file. Tabs window and PDF rendering will break in dev.

Fix options:

  • Keep the old dist/ fallback logic in addition to the SPA fallback (try-disk-then-SPA), or
  • Add explicit bypasses for /tabs.html, /workers/, /font/, plus static-serve for those paths.

🟡 Smaller issues

3. vite.config.ts — stale JSDoc. The comment above spaFallbackPlugin still describes the old devServerPlugin behavior.

4. dispatcher.ts:79–129 — Promise can never reject. Every event path (metadata, data, status, end, missing token) resolves. Callers using await can't distinguish "stream established" from "stream failed" or "no token". Either reject on !S.Auth.token / status.code != 0, or document that the promise always resolves.

**5. dispatcher.ts:159 (in reconnect()) — this.startStream() returned Promise is silently discarded. Add void this.startStream(); or a brief comment to make it intentional.

6. data.ts:434–443 — duplicated callBack?.(message). Cleaner as a single trailing call:

C.WalletCreateSession(phrase, key, token, async (message: any) => {
    if (!message.error.code) {
        S.Auth.tokenSet(message.token);
        S.Auth.appTokenSet(message.appToken);
        await dispatcher.startStream();
    };
    callBack?.(message);
});

7. PR description is outdated. Still claims a "500 ms delay" — please update to reflect the Promise-based fix actually shipped in 518fb52.

✅ Looks good

  • Adding service_grpc_web_pb.js to optimizeDeps.include — clean, targeted fix.
  • Promise-based replacement for the startup race is the right approach; resolving on metadata is the correct readiness signal for grpc-web.

@ra3orblade

Thanks for the thorough review! You caught some critical regressions, especially with the static asset serving in the SPA fallback. I've addressed all your points in the latest commit:

  1. Vite Config Fixes:

    • Fixed the broken catch handler and log prefix in vite.web.config.ts.
    • Updated spaFallbackPlugin to implement a "try-disk-then-SPA" logic. It now correctly checks dist/ for static assets (tabs, workers, fonts) before falling back to index.html.
    • Updated the stale JSDoc comments.
  2. Dispatcher & Promise Logic:

    • startStream() now properly rejects on missing tokens or non-zero status codes, allowing for better error handling in callers.
    • Added void to the unawaited startStream() call in reconnect().
    • Re-added a small internal fallback timeout within the Promise. In my local dev-web environment, metadata events from the grpc-web proxy are inconsistent; this ensures the UI doesn't hang on the loading screen while still maintaining the Promise-based architecture you requested.
  3. Cleanup:

    • Refactored createSession in data.ts to deduplicate the callback and added a try/catch block for the stream initialization.

I've also updated the PR description to remove the stale "500ms delay" mentions. Ready for another look!

@ra3orblade

Copy link
Copy Markdown
Contributor

Good, thanks, I will merge after next release.

@ra3orblade ra3orblade merged commit 2b8ea85 into anyproto:develop Apr 29, 2026
3 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 29, 2026
@ra3orblade

Copy link
Copy Markdown
Contributor

Reverted in 64fd938 — sorry, this broke bun run start:dev in two ways. Happy to re-merge if these are fixed.

1. Stale dist/index.html shadows the dev HTML

Electron loads http://localhost:8080/index.html (via getUrlForNewTab() in electron.js:1659). The new dist-static block in spaFallbackPlugin matches /index.html against dist/index.html before the rewrite to src/html/index.html runs:

const distPath = path.resolve(__dirname, 'dist', pathname.slice(1));
if (fs.existsSync(distPath) && fs.statSync(distPath).isFile()) {
    ...
    return res.end(fs.readFileSync(distPath));
}

So in dev the renderer gets the production-built HTML, whose <link rel="modulepreload"> tags point at ./js/chunks/vendor-react.js, vendor-d3.js, etc. — chunks that only exist after a Rollup production build, not in dev.

Fix: skip the dist-static lookup when pathname is / or /index.html.

2. SPA fallback returns HTML for missing assets

Once those vendor-chunk URLs 404 in dist/, the request falls through to the SPA fallback, which returns src/html/index.html with Content-Type: text/html. The browser then logs:

Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/html". Strict MIME type
checking is enforced for module scripts per HTML spec.

…for vendor-react.js, vendor-d3.js, vendor.js, vendor-mermaid.js, vendor-sentry.js, protobuf.js. Emoji PNGs (/img/emoji/*.png) hit the same path and also come back as text/html.

Fix: only fall back to index.html for navigation requests — i.e. when there is no extension, the extension is .html, or Accept includes text/html. Asset requests (.js, .css, .wasm, .png, …) should next() so the browser sees a clean 404 instead of a misleading HTML body.

Suggested shape

const isIndex = (pathname == '/') || (pathname == '/index.html');

if (!isIndex) {
    // dist-static lookup here
}

const ext = path.extname(pathname).toLowerCase();
const accept = String(req.headers.accept || '');
const isHtmlRequest = isIndex || (!ext) || (ext == '.html') || accept.includes('text/html');
if (!isHtmlRequest) {
    return next();
}

// serve src/html/index.html

The dispatcher / Promise refactor in this PR looked fine on its own — feel free to split it out into a separate PR if you'd like to land that piece independently while the dev-server plugin is reworked. Thanks for the contribution!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants