Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
@@ -1,95 +1,54 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.JSInterop;

namespace Company.WebWorker1;

/// <summary>
/// Client for communicating with a Web Worker running .NET code.
/// </summary>
/// <remarks>
/// <para>
/// Worker methods are static methods marked with <c>[JSExport]</c> in a <c>static partial class</c>.
/// By default, they should be defined in the main application. The assembly name in
/// <c>dotnet-web-worker.js</c> must match the assembly containing the worker methods.
/// The project requires <c>&lt;AllowUnsafeBlocks&gt;true&lt;/AllowUnsafeBlocks&gt;</c> in the .csproj file.
/// </para>
/// <para>
/// Due to <c>[JSExport]</c> limitations, worker methods can only return primitives or strings.
/// For complex types, serialize to JSON before returning—it will be automatically deserialized.
/// </para>
/// <para>
/// Example worker class (add this to your main app):
/// <code>
/// [SupportedOSPlatform("browser")]
/// public static partial class MyWorker
/// {
/// [JSExport]
/// public static string Process(string input) => $"Processed: {input}";
/// }
/// </code>
/// </para>
/// <para>
/// Example usage:
/// <code>
/// @inject IJSRuntime JSRuntime
///
/// private WebWorkerClient? _worker;
///
/// protected override async Task OnAfterRenderAsync(bool firstRender)
/// {
/// if (firstRender)
/// {
/// _worker = await WebWorkerClient.CreateAsync(JSRuntime);
/// }
/// }
///
/// async Task CallWorker()
/// {
/// var result = await _worker!.InvokeAsync&lt;string&gt;("MyApp.MyWorker.Process", ["Hello"]);
/// }
///
/// public async ValueTask DisposeAsync() => await (_worker?.DisposeAsync() ?? ValueTask.CompletedTask);
/// </code>
/// </para>
/// </remarks>
// This class provides a client for communicating with a Web Worker running
// .NET code. The associated JavaScript module is loaded on demand when the
// worker is created.
//
// Worker methods are static methods marked with [JSExport] in a static partial
// class. Due to [JSExport] limitations, worker methods can only return primitives
// or strings. For complex types, serialize to JSON before returning — it will be
// automatically deserialized.
//
// Example worker class:
//
// [SupportedOSPlatform("browser")]
// public static partial class MyWorker
// {
// [JSExport]
// public static string Process(string input) => $"Processed: {input}";
// }
//
// Example usage:
//
// var worker = await WebWorkerClient.CreateAsync(JSRuntime);
// var result = await worker.InvokeAsync<string>("MyApp.MyWorker.Process", ["Hello"]);

Comment thread
ilonatommy marked this conversation as resolved.
Outdated
public sealed class WebWorkerClient(IJSObjectReference worker) : IAsyncDisposable
{
/// <summary>
/// Creates and initializes a new .NET Web Worker client instance.
/// </summary>
/// <param name="jsRuntime">The JS runtime instance.</param>
/// <returns>A ready-to-use WebWorkerClient instance.</returns>
/// <exception cref="JSException">Thrown if the worker fails to initialize.</exception>
public static async Task<WebWorkerClient> CreateAsync(IJSRuntime jsRuntime)
private const int DefaultTimeoutMs = 60000;

public static async Task<WebWorkerClient> CreateAsync(IJSRuntime jsRuntime, int timeoutMs = DefaultTimeoutMs, CancellationToken cancellationToken = default)
{
await using var module = await jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./_content/Company.WebWorker1/dotnet-web-worker-client.js");
"import", cancellationToken, "./_content/Company.WebWorker1/dotnet-web-worker-client.js");

var workerRef = await module.InvokeAsync<IJSObjectReference>("create");
var workerRef = await module.InvokeAsync<IJSObjectReference>("create", cancellationToken, timeoutMs);

return new WebWorkerClient(workerRef);
}

/// <summary>
/// Invokes a method on the worker and returns the result.
/// </summary>
/// <typeparam name="TResult">The type of the result.</typeparam>
/// <param name="method">Full method path: "Namespace.ClassName.MethodName"</param>
/// <param name="args">Arguments to pass to the method.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The result from the worker method.</returns>
/// <exception cref="OperationCanceledException">Thrown if the operation is canceled.</exception>
/// <exception cref="JSException">Thrown if the worker method throws an exception.</exception>
public async Task<TResult> InvokeAsync<TResult>(string method, object[] args, CancellationToken cancellationToken = default)
public async Task<TResult> InvokeAsync<TResult>(string method, object[] args, int timeoutMs = DefaultTimeoutMs, CancellationToken cancellationToken = default)
{
return await worker.InvokeAsync<TResult>("invoke", cancellationToken, [method, args, timeoutMs]);
}

public async Task InvokeVoidAsync(string method, object[] args, int timeoutMs = DefaultTimeoutMs, CancellationToken cancellationToken = default)
{
return await worker.InvokeAsync<TResult>("invoke", cancellationToken, [method, args]);
await worker.InvokeVoidAsync("invoke", cancellationToken, [method, args, timeoutMs]);
}

/// <summary>
/// Terminates the worker and releases resources.
/// </summary>
public async ValueTask DisposeAsync()
{
try
Expand All @@ -98,7 +57,7 @@ public async ValueTask DisposeAsync()
}
catch (JSDisconnectedException)
{
// Circuit disconnected, worker is already gone
// JS interop disconnected, worker is already gone
}

await worker.DisposeAsync();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
function withTimeout(promise, timeoutMs, timeoutMessage) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs));
return Promise.race([promise, timeout]);
}

class DotnetWebWorkerClient {
#worker;
Expand All @@ -10,29 +13,30 @@ class DotnetWebWorkerClient {
this.#worker = worker;
}

static create() {
return new Promise((resolve, reject) => {
const worker = new Worker('_content/Company.WebWorker1/dotnet-web-worker.js', { type: "module" });

worker.addEventListener('error', (e) => {
reject(new Error(e.message || 'Worker encountered an error'));
});
static create(initTimeoutMs) {
const worker = new Worker('_content/Company.WebWorker1/dotnet-web-worker.js', { type: "module" });

const initWorker = new Promise((resolve, reject) => {
worker.addEventListener('error', (e) =>
reject(new Error(e.message || 'Worker encountered an error')));
worker.addEventListener('message', function onMessage(e) {
if (e.data.type === "ready") {
worker.removeEventListener('message', onMessage);
if (e.data.error) {
reject(new Error(e.data.error));
} else {
const client = new DotnetWebWorkerClient(worker);
client.#setupMessageHandler();
resolve(client);
}
e.data.error ? reject(new Error(e.data.error)) : resolve();
}
});
});

const dotnetJsUrl = DotnetWebWorkerClient.#resolveDotnetJsUrl();
worker.postMessage({ type: 'init', dotnetJsUrl });

const dotnetJsUrl = DotnetWebWorkerClient.#resolveDotnetJsUrl();
worker.postMessage({ type: 'init', dotnetJsUrl });
return withTimeout(initWorker, initTimeoutMs, 'Worker initialization timed out').then(() => {
const client = new DotnetWebWorkerClient(worker);
client.#setupMessageHandler();
return client;
}, err => {
worker.terminate();
throw err;
});
}

Expand All @@ -43,18 +47,29 @@ class DotnetWebWorkerClient {
return import.meta.resolve?.(dotnetJsUrl) ?? dotnetJsUrl;
}

invoke(method, args) {
return new Promise((resolve, reject) => {
invoke(method, args, timeoutMs) {
const invoke = new Promise((resolve, reject) => {
const id = ++this.#requestId;
this.#pendingRequests[id] = { resolve: r => resolve(this.#parseIfJson(r)), reject };
this.#worker.postMessage({ method, args, requestId: id });
});

return withTimeout(invoke, timeoutMs, `Worker method '${method}' timed out`).catch(err => {
const id = this.#requestId;
if (this.#pendingRequests[id]) {
delete this.#pendingRequests[id];
}
throw err;
});
}

#parseIfJson(value) {
Comment thread
ilonatommy marked this conversation as resolved.
Outdated
if (typeof value === 'string') {
try {
return JSON.parse(value);
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
} catch {
// not JSON, return as-is
}
Expand Down Expand Up @@ -96,6 +111,6 @@ class DotnetWebWorkerClient {
}
}

export function create() {
return DotnetWebWorkerClient.create();
export function create(initTimeoutMs) {
return DotnetWebWorkerClient.create(initTimeoutMs);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

let workerExports = null;
let startupError = null;

Expand Down
Loading