[v3/Windows] Creating a Worker from inside a Worker fails silently when the page is served by the asset server
Description
On Windows/WebView2, creating a Worker from inside another worker fails when the page is served by the Wails asset server (http://wails.localhost). It fails silently: the error event has message === undefined and the event is not an ErrorEvent.
The request does reach the asset server — the AssetFileServerFS log shows it being handled, with the correct Content-Type — but the nested worker never starts.
The same code works when the page is served from a plain http://127.0.0.1:<port> Go listener inside the same Wails window, and works in a stock Edge browser of the same Chromium version.
This breaks every Emscripten pthread build, because those spawn their worker pool from inside their own worker. In my case it was JASSUB (libass): its ready promise simply never settles — no error, no timeout.
Environment
- Wails
v3.0.0-beta.22, Go 1.27.0,CGO_ENABLED=0 - WebView2 Runtime
153.0.4234.32 - Windows 11 26200 (25H2), amd64
Reproduction
Four files, no frontend tooling:
main.go
package main
import (
"embed"
"fmt"
"io"
"net/http"
"os"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
files := application.AssetFileServerFS(assets)
app := application.New(application.Options{
Name: "nested-worker-repro",
Assets: application.AssetOptions{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/report" {
b, _ := io.ReadAll(r.Body)
fmt.Println("RESULT:", string(b))
w.WriteHeader(204)
os.Exit(0)
return
}
files.ServeHTTP(w, r)
}),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "nested worker repro", Width: 700, Height: 300, URL: "/index.html",
})
if err := app.Run(); err != nil {
fmt.Println(err)
}
}frontend/dist/index.html
<!doctype html><meta charset="utf-8"><title>nested worker repro</title>
<body style="font:14px monospace"><pre id="o">running…</pre><script type="module">
const w = new Worker('/parent.js', { type: 'module' })
w.onmessage = async (e) => {
document.getElementById('o').textContent = JSON.stringify(e.data, null, 2)
await fetch('/report', { method: 'POST', body: JSON.stringify(e.data) })
}
w.onerror = (e) => { document.getElementById('o').textContent = 'parent worker error: ' + e.message }
</script></body>frontend/dist/parent.js
const mk = (u, opts) => new Promise((res) => {
const w = new Worker(u, opts)
w.onmessage = () => { w.terminate(); res('ok') }
w.onerror = (e) => { w.terminate(); res('FAILED (message=' + JSON.stringify(e.message) + ', isErrorEvent=' + (e instanceof ErrorEvent) + ')') }
setTimeout(() => { w.terminate(); res('timeout') }, 3000)
})
;(async () => {
const abs = new URL('/child.js', self.location.href).href
const out = {}
const r = await fetch(abs)
out['fetch(child.js)'] = r.status + ' ' + r.headers.get('content-type')
out['new Worker(url, {type:module})'] = await mk(abs, { type: 'module' })
out['new Worker(url) classic'] = await mk(abs, {})
out['new Worker(blob -> import url)'] =
await mk(URL.createObjectURL(new Blob([`import ${JSON.stringify(abs)};`], { type: 'text/javascript' })), { type: 'module' })
out['new Worker(blob with inline source)'] =
await mk(URL.createObjectURL(new Blob(["postMessage('hi')"], { type: 'text/javascript' })), {})
postMessage(out)
})()frontend/dist/child.js
postMessage('child alive')Actual result
RESULT: {
"fetch(child.js)": "200 text/javascript; charset=utf-8",
"new Worker(url, {type:module})": "FAILED (message=undefined, isErrorEvent=false)",
"new Worker(url) classic": "FAILED (message=undefined, isErrorEvent=false)",
"new Worker(blob -> import url)": "ok",
"new Worker(blob with inline source)": "ok"
}Note the asset server log for the same run — /child.js is served, the request is not lost:
INF [AssetFileServerFS] Handling request url=/index.html file=index.html
INF [AssetFileServerFS] Handling request url=/parent.js file=parent.js
INF [AssetFileServerFS] Handling request url=/child.js file=child.js
INF [AssetFileServerFS] Handling request url=/child.js file=child.jsSo: a top-level worker created from the document loads fine (/parent.js), and fetch() of the child script from inside that worker returns 200 with the right MIME — only new Worker(url) from inside a worker fails.
Expected result
All five entries ok, as they are when the same page is served from a plain http://127.0.0.1:<port> listener (verified in the same Wails window by pointing WebviewWindowOptions.URL at a Go http.Server on loopback), and as they are in stock Edge 153 on a plain http origin.
Things I ruled out
- System proxy. I had a proxy configured whose bypass list does not cover
wails.localhost. TestedWEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--no-proxy-serverand--proxy-bypass-list=*.localhost;<local>;127.0.0.1— no change. - MIME type.
text/javascript; charset=utf-8, as shown above. WebResourceRequestedrequest source kinds.webview_window_windows.gocalls the deprecatedAddWebResourceRequestedFilter("*", COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL), which has noRequestSourceKindsparameter, so I assumed worker-sourced requests were simply not filtered. I patched a local Wails copy to callAddWebResourceRequestedFilterWithRequestSourceKinds("*", CONTEXT_ALL, SOURCE_KINDS_ALL)viaICoreWebView2_22instead (and also tried registering both filters). The call succeeds, but nested workers still fail. So that is not the cause — though switching to the non-deprecated overload may still be worth doing on its own.
Workaround
Replacing globalThis.Worker inside the parent worker so that a URL becomes a blob which merely imports the absolute URL:
const NativeWorker = globalThis.Worker
globalThis.Worker = class extends NativeWorker {
constructor(url, opts = {}) {
let u = String(url)
if (!/^(blob:|data:)/.test(u)) {
const abs = new URL(u, self.location.href).href
const src = opts.type === 'module'
? `import ${JSON.stringify(abs)};`
: `importScripts(${JSON.stringify(abs)});`
u = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }))
}
super(u, opts)
}
}The blob must only forward to the absolute URL, not inline the source — otherwise import.meta.url becomes the blob: URL and Emscripten's new Worker(new URL("...", import.meta.url)) throws Invalid URL.
With this shim injected into JASSUB's worker bundle, libass initialises and renders correctly on wails.localhost (8 libass threads, identical output to the loopback-origin run).
Note
This may well be a WebView2 limitation rather than something Wails does wrong — I have not been able to determine which side drops the response. But it is invisible to the developer (no error message at all) and it silently breaks any threaded wasm library, so it seems worth documenting even if the fix has to come from upstream.
Source: wailsapp/wails