Skip to content

web: Add Standalone Mode for Web Backend - #815

Closed
Fristender wants to merge 17 commits into
david-vanderson:mainfrom
Fristender:feat/web-standalone
Closed

web: Add Standalone Mode for Web Backend#815
Fristender wants to merge 17 commits into
david-vanderson:mainfrom
Fristender:feat/web-standalone

Conversation

@Fristender

Copy link
Copy Markdown

Added support for writing standalone mode apps for the web backend.
Added web-standalone.zig example to demonstrate features.

Fristender added 4 commits April 16, 2026 22:25
Include CORS-related fixes for standalone web mode by falling back to main-thread web.js when SharedArrayBuffer/Atomics/crossOriginIsolated/OffscreenCanvas are unavailable.

Also install web.js for web-standalone builds and add standalone wasm compatibility imports (wasm_wait_event, wasm_canvas_info) in web.js for fallback execution.
@Fristender Fristender changed the title Feature: Add Standalone Mode for Web Backend web: Add Standalone Mode for Web Backend Apr 17, 2026
@david-vanderson

Copy link
Copy Markdown
Owner

I'm a bit confused. Can you say more about how this differs from the existing web stuff? "Standalone" to me implies the code owns the mainloop, which is not a thing in the browser right?

Is this more about web workers? Is it offloading running the dvui stuff into a separate web worker?

@Fristender

Copy link
Copy Markdown
Author

@david-vanderson I'm extremely sorry for this very late response as I have some things going on in my life.

Standalone in this PR is about making user code own the lifecycle, using web workers to bypass browser main loop limitations. This promotes feature parity with the other backends.

The key difference from existing web path is:

How worker loop works (concrete path):

  1. Main thread bootstrap calls dvuiStandalone(...) in src/backends/web-standalone.js#L29.
  2. It requires SAB/Atomics/cross-origin isolation; otherwise fallback to main-thread runtime (web.js) via lines L51, L59, L65, and L77 of src/backends/web-standalone.js.
  3. Input/resize events are written to shared ring buffer and worker is explicitly woken (postMessage({type:"wake"})) at src/backends/web-standalone.js#L137, L210, and L217.
  4. Worker starts async loop in src/backends/web-worker.js#L810:
  5. On Zig side, backend exports bridge into standalone hooks:

So to your direct question: yes, it is offloading DVUI execution to a worker when available, but the main goal is enabling standalone-style app ownership on web, with worker/fallback as implementation detail for browser constraints.

@david-vanderson

Copy link
Copy Markdown
Owner

@david-vanderson I'm extremely sorry for this very late response as I have some things going on in my life.

No problem at all.

Standalone in this PR is about making user code own the lifecycle, using web workers to bypass browser main loop limitations. This promotes feature parity with the other backends.

Thanks for the detailed layout. I was able to test it locally using a hacked python3 server:

#!/usr/bin/env python3

from http import server

class MyHTTPRequestHandler(server.SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header("Cross-Origin-Opener-Policy", "same-origin")
        self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
        super().end_headers()

server.test(HandlerClass = MyHTTPRequestHandler)

I'm trying to follow the code, but some things are not clear to me. Examples:

  • why are logs for renderGeometry using traceOnce and then debugLog? Maybe just debugging leftovers?
    • that would explain the pixel sample thing too
  • is there a way we can avoid duplicating the shaders?
    • for that matter it looks like a lot of the wasm functions got duplicated, is there a way to avoid that?
  • why are there two versions of runFrame?
  • the check for window.crossOriginIsolated should go first right?

I had to make these changes so it would compile in zig 0.16.0:

-var gpa_instance = std.heap.GeneralPurposeAllocator(.{}){};
-const gpa = gpa_instance.allocator();
+pub var gpa: std.mem.Allocator = std.heap.wasm_allocator;
-    allocator: std.mem.Allocator = gpa,
+    allocator: std.mem.Allocator = std.heap.wasm_allocator,

@Fristender
Fristender marked this pull request as draft May 24, 2026 10:01
@Fristender
Fristender marked this pull request as ready for review June 7, 2026 00:40
@Fristender

Fristender commented Jun 8, 2026

Copy link
Copy Markdown
Author

@david-vanderson
Thanks for the feedback.

Changes made:

  • ditch dvui_init and dvui_update, running the main() function directly in the worker. This requires the main function to be exported.
  • removed traceOnce and excess debugging including the pixel sample.
  • removed runFrame as it is no longer needed after running main() directly
  • refactored duplicate code in web.js, web-standalone.js, and web-worker.js into web-common.js. Important: in order to import web-common.js into web-worker.js, the worker as to be started as a module const worker = new Worker(workerUrl, { type: "module" });. This requires browser support for ES modules in workers.

Desktop Browser Compatibility

Browser Minimum Version Release Date
Google Chrome 80 February 2020
Microsoft Edge 80 February 2020
Apple Safari 15 September 2021
Mozilla Firefox 114 June 2023
Opera 67 March 2020

Mobile Browser Compatibility

Browser Minimum Version Release Date
Safari on iOS / iPadOS 15 September 2021
Chrome for Android 80 February 2020
Firefox for Android 114 June 2023
Samsung Internet 13.0 November 2020
  • Since the worker stays in the main loop after the init message is sent from the main thread, further worker.postMessage messages sent to it will not be processed. Hence, the main thread communicates to the worker via
Atomics.store(signalArray, SIGNAL_INDEX, 1);
Atomics.notify(signalArray, SIGNAL_INDEX);

Do note that this doesn't prevent the worker from using self.postMessage, which is the mechanism used to process input events and transfer the bitmap.

Unaddressed Feedback

The check for window.crossOriginIsolated can be moved up a couple of lines, but the error message shows up fine as is:

[dvui-standalone] script loaded {debugEnabled: true, href: 'http://localhost:8000/?dvui_debug=1&dvui_probe=1'}
web-standalone.js:53 SharedArrayBuffer is not available
web-standalone.js:59 crossOriginIsolated is false (missing COOP/COEP)
web-standalone.js:63 Uncaught ReferenceError: SharedArrayBuffer is not defined
at dvuiStandalone (web-standalone.js:63:26)
at ?dvui_debug=1&dvui_probe=1:21:7

Nitpicks

  • When resizing a browser window when running web-standalone, the UI requires events (like moving a cursor over the window) to properly rescale. Haven't found a fix for this because web browsers don't have gl.commit().

@david-vanderson

Copy link
Copy Markdown
Owner

Thanks for addressing the feedback!

In general I'm okay with adding this kind of web worker example, but realistically the amount of refactoring going on here is tough to review.

For example, just the small changes to build.zig around the raylib thing bring up the question of what is going on there, and why is it combined with unrelated web stuff?

To make progress here the refactoring stuff is going to have to be separated so it can be reviewed independently. Javascript stuff in particular takes a lot of time to test across different OS/browser combinations.

@Fristender

Fristender commented Jul 16, 2026

Copy link
Copy Markdown
Author

Hi.

  1. The build.zig modification was due to the project not building with the raylib doesn't have build.zig error. Maybe this is only a problem on my end, so I will remove it.

  2. To address the concern where there is too much to review, the only solution I can come up with is to have 2 PRs:
    PR 1: Pure refactor – introduce web-common.js and update web.js to use it. No behavior changes.
    PR 2: Feature – add web-standalone.js, web-worker.js, index-standalone.html, examples/web-standalone.zig, and the standalone build/Zig support on top of PR 1.

Is this good? Do you have a better solution? Thanks for your patience.

@david-vanderson

Copy link
Copy Markdown
Owner

Is this good? Do you have a better solution? Thanks for your patience.

The split PR approach sounds good. Thank you!

@nat3Github

Copy link
Copy Markdown
Collaborator

im curious about this but im not sure if i understand it, whats the usecase for it? and does it have any upsides to the current web approach?

@Fristender

Copy link
Copy Markdown
Author

im curious about this but im not sure if i understand it, whats the usecase for it? and does it have any upsides to the current web approach?

I'm not an experienced developer, but off the top of my head, a user-defined main function allows for more granular control over initialization/deinitialization, for example being able to fetch resources during a loading screen.

Another benefit is to promote feature parity between platforms, so that an existing SDL standalone codebase could be run on web without introducing a dvui_update pattern to run in app mode, which might be a great hassle if the codebase didn't have this in mind during development.

@Fristender

Copy link
Copy Markdown
Author

Closing this because now the effort is split into 2 PRs: #961 #962 .

@Fristender Fristender closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants