@effectionx/worker 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker.test.ts CHANGED
@@ -3,7 +3,15 @@ import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { beforeEach, describe, it } from "@effectionx/bdd";
5
5
  import { when } from "@effectionx/converge";
6
- import { scoped, spawn, suspend, until } from "effection";
6
+ import {
7
+ all,
8
+ scoped,
9
+ sleep,
10
+ spawn,
11
+ suspend,
12
+ until,
13
+ withResolvers,
14
+ } from "effection";
7
15
  import { expect } from "expect";
8
16
 
9
17
  import type { ShutdownWorkerParams } from "./test-assets/shutdown-worker.ts";
@@ -31,7 +39,7 @@ describe("worker", () => {
31
39
  yield* worker.send();
32
40
  } catch (e) {
33
41
  expect(e).toBeInstanceOf(Error);
34
- expect(e).toMatchObject({ message: "boom!" });
42
+ expect((e as Error).message).toContain("boom!");
35
43
  }
36
44
  });
37
45
  it("produces its return value", function* () {
@@ -55,7 +63,7 @@ describe("worker", () => {
55
63
  yield* worker;
56
64
  } catch (e) {
57
65
  expect(e).toBeInstanceOf(Error);
58
- expect(e).toMatchObject({ message: "boom!" });
66
+ expect((e as Error).message).toContain("boom!");
59
67
  }
60
68
  });
61
69
  describe("shutdown", () => {
@@ -130,7 +138,7 @@ describe("worker", () => {
130
138
  yield* worker;
131
139
  } catch (e) {
132
140
  expect(e).toBeInstanceOf(Error);
133
- expect(e).toMatchObject({ message: "worker terminated" });
141
+ expect((e as Error).message).toContain("worker terminated");
134
142
  }
135
143
  });
136
144
 
@@ -176,4 +184,294 @@ describe("worker", () => {
176
184
  it.skip("crashes if there is a message error from the worker thread", function* () {
177
185
  // don't know how to trigger
178
186
  });
187
+
188
+ describe("worker-initiated requests", () => {
189
+ it("handles a single request from worker", function* () {
190
+ const worker = yield* useWorker<never, never, string, void>(
191
+ import.meta.resolve("./test-assets/single-request-worker.ts"),
192
+ { type: "module" },
193
+ );
194
+
195
+ const result = yield* worker.forEach<string, string>(function* (request) {
196
+ return `echo: ${request}`;
197
+ });
198
+
199
+ expect(result).toEqual("received: echo: hello");
200
+ });
201
+
202
+ it("handles multiple sequential requests from worker", function* () {
203
+ const worker = yield* useWorker<never, never, number, void>(
204
+ import.meta.resolve("./test-assets/sequential-requests-worker.ts"),
205
+ { type: "module" },
206
+ );
207
+
208
+ let counter = 0;
209
+ const result = yield* worker.forEach<string, number>(
210
+ function* (_request) {
211
+ counter += 1;
212
+ return counter;
213
+ },
214
+ );
215
+
216
+ expect(result).toEqual(3);
217
+ });
218
+
219
+ it("propagates errors from host handler to worker and crashes host", function* () {
220
+ const worker = yield* useWorker<never, never, string, void>(
221
+ import.meta.resolve("./test-assets/error-handling-worker.ts"),
222
+ { type: "module" },
223
+ );
224
+
225
+ // Host should crash after forwarding error to worker
226
+ let hostError: Error | undefined;
227
+ try {
228
+ yield* worker.forEach<string, string>(function* (request) {
229
+ if (request === "fail") {
230
+ throw new Error("host error");
231
+ }
232
+ return "ok";
233
+ });
234
+ } catch (e) {
235
+ hostError = e as Error;
236
+ }
237
+
238
+ // Verify host crashed with the original error
239
+ expect(hostError).toBeDefined();
240
+ expect(hostError?.message).toEqual("host error");
241
+ });
242
+
243
+ it("handles concurrent requests from worker", function* () {
244
+ const worker = yield* useWorker<never, never, number[], void>(
245
+ import.meta.resolve("./test-assets/concurrent-requests-worker.ts"),
246
+ { type: "module" },
247
+ );
248
+
249
+ const result = yield* worker.forEach<number, number>(function* (request) {
250
+ yield* sleep(request * 10);
251
+ return request * 2;
252
+ });
253
+
254
+ expect(result).toEqual([6, 4, 2]);
255
+ });
256
+
257
+ it("supports bidirectional communication", function* () {
258
+ const worker = yield* useWorker<string, string, string, void>(
259
+ import.meta.resolve("./test-assets/bidirectional-worker.ts"),
260
+ { type: "module" },
261
+ );
262
+
263
+ yield* spawn(function* () {
264
+ yield* worker.forEach<string, string>(function* (request) {
265
+ return `host-response: ${request}`;
266
+ });
267
+ });
268
+
269
+ const hostResult = yield* worker.send("from-host");
270
+ expect(hostResult).toEqual("worker-response: from-host");
271
+
272
+ const finalResult = yield* worker;
273
+ expect(finalResult).toEqual("done: host-response: from-worker");
274
+ });
275
+
276
+ it("existing workers without send still work", function* () {
277
+ const worker = yield* useWorker(
278
+ import.meta.resolve("./test-assets/echo-worker.ts"),
279
+ { type: "module" },
280
+ );
281
+
282
+ const result = yield* worker.send("hello world");
283
+ expect(result).toEqual("hello world");
284
+ });
285
+
286
+ it("forEach completes with result when worker sends no requests", function* () {
287
+ const worker = yield* useWorker<never, never, string, void>(
288
+ import.meta.resolve("./test-assets/no-requests-worker.ts"),
289
+ { type: "module" },
290
+ );
291
+
292
+ let handlerCalled = false;
293
+ const result = yield* worker.forEach<string, string>(
294
+ function* (_request) {
295
+ handlerCalled = true;
296
+ return "response";
297
+ },
298
+ );
299
+
300
+ expect(result).toEqual("done without requests");
301
+ expect(handlerCalled).toBe(false);
302
+ });
303
+
304
+ it("yield worker after forEach returns same result", function* () {
305
+ const worker = yield* useWorker<never, never, string, void>(
306
+ import.meta.resolve("./test-assets/single-request-worker.ts"),
307
+ { type: "module" },
308
+ );
309
+
310
+ const result1 = yield* worker.forEach<string, string>(
311
+ function* (request) {
312
+ return `echo: ${request}`;
313
+ },
314
+ );
315
+
316
+ const result2 = yield* worker;
317
+
318
+ expect(result1).toEqual("received: echo: hello");
319
+ expect(result2).toEqual("received: echo: hello");
320
+ });
321
+
322
+ it("yield forEach after worker returns cached result", function* () {
323
+ const worker = yield* useWorker<never, never, string, void>(
324
+ import.meta.resolve("./test-assets/no-requests-worker.ts"),
325
+ { type: "module" },
326
+ );
327
+
328
+ const result1 = yield* worker;
329
+
330
+ let handlerCalled = false;
331
+ const result2 = yield* worker.forEach<string, string>(
332
+ function* (_request) {
333
+ handlerCalled = true;
334
+ return "response";
335
+ },
336
+ );
337
+
338
+ expect(result1).toEqual("done without requests");
339
+ expect(result2).toEqual("done without requests");
340
+ expect(handlerCalled).toBe(false);
341
+ });
342
+
343
+ it("yield worker multiple times returns same result", function* () {
344
+ const worker = yield* useWorker<never, never, string, void>(
345
+ import.meta.resolve("./test-assets/no-requests-worker.ts"),
346
+ { type: "module" },
347
+ );
348
+
349
+ const result1 = yield* worker;
350
+ const result2 = yield* worker;
351
+ const result3 = yield* worker;
352
+
353
+ expect(result1).toEqual("done without requests");
354
+ expect(result2).toEqual("done without requests");
355
+ expect(result3).toEqual("done without requests");
356
+ });
357
+
358
+ it("queues requests sent before forEach is called", function* () {
359
+ const worker = yield* useWorker<never, never, string, void>(
360
+ import.meta.resolve("./test-assets/single-request-worker.ts"),
361
+ { type: "module" },
362
+ );
363
+
364
+ // Yield control to allow worker to send request before forEach is set up
365
+ // The channel implementation buffers requests, so sleep(0) is sufficient
366
+ yield* sleep(0);
367
+
368
+ const result = yield* worker.forEach<string, string>(function* (request) {
369
+ return `echo: ${request}`;
370
+ });
371
+
372
+ expect(result).toEqual("received: echo: hello");
373
+ });
374
+
375
+ it("throws error when forEach is called concurrently", function* () {
376
+ expect.assertions(1);
377
+ const worker = yield* useWorker<never, never, string, void>(
378
+ import.meta.resolve("./test-assets/slow-request-worker.ts"),
379
+ { type: "module" },
380
+ );
381
+
382
+ const forEachStarted = withResolvers<void>();
383
+ const allowHandlerToComplete = withResolvers<void>();
384
+
385
+ // Start first forEach in background
386
+ yield* spawn(function* () {
387
+ yield* worker.forEach<string, string>(function* (_request) {
388
+ forEachStarted.resolve();
389
+ // Block until test signals completion (deterministic latch instead of sleep)
390
+ yield* allowHandlerToComplete.operation;
391
+ return "slow response";
392
+ });
393
+ });
394
+
395
+ // Wait for first forEach to start handling a request
396
+ yield* forEachStarted.operation;
397
+
398
+ // Second forEach should throw
399
+ try {
400
+ yield* worker.forEach<string, string>(function* (_request) {
401
+ return "should not be called";
402
+ });
403
+ } catch (e) {
404
+ expect((e as Error).message).toEqual("forEach is already in progress");
405
+ }
406
+
407
+ // Allow first handler to complete so test can clean up
408
+ allowHandlerToComplete.resolve();
409
+ });
410
+
411
+ it("error cause contains name, message, and stack from host", function* () {
412
+ const worker = yield* useWorker<never, never, string, void>(
413
+ import.meta.resolve("./test-assets/error-cause-worker.ts"),
414
+ { type: "module" },
415
+ );
416
+
417
+ // Host should crash after forwarding error to worker
418
+ let hostError: Error | undefined;
419
+ try {
420
+ yield* worker.forEach<string, string>(function* (_request) {
421
+ const error = new TypeError("custom type error");
422
+ throw error;
423
+ });
424
+ } catch (e) {
425
+ hostError = e as Error;
426
+ }
427
+
428
+ // Verify host crashed with the original error
429
+ expect(hostError).toBeDefined();
430
+ expect(hostError?.name).toEqual("TypeError");
431
+ expect(hostError?.message).toEqual("custom type error");
432
+ });
433
+
434
+ it("error cause contains name, message, and stack from worker", function* () {
435
+ expect.assertions(4);
436
+ const worker = yield* useWorker<string, string, void, void>(
437
+ import.meta.resolve("./test-assets/error-throw-worker.ts"),
438
+ { type: "module" },
439
+ );
440
+
441
+ try {
442
+ yield* worker.send("trigger-error");
443
+ } catch (e) {
444
+ const error = e as Error & { cause?: unknown };
445
+ expect(error.message).toContain("Worker handler failed");
446
+ expect(error.cause).toBeDefined();
447
+ const cause = error.cause as {
448
+ name: string;
449
+ message: string;
450
+ stack?: string;
451
+ };
452
+ expect(cause.name).toEqual("RangeError");
453
+ expect(cause.message).toEqual("worker range error");
454
+ }
455
+ });
456
+
457
+ it("worker can call send inside messages.forEach handler", function* () {
458
+ const worker = yield* useWorker<string, string, string, void>(
459
+ import.meta.resolve("./test-assets/send-inside-foreach-worker.ts"),
460
+ { type: "module" },
461
+ );
462
+
463
+ // Handle worker-initiated requests
464
+ yield* spawn(function* () {
465
+ yield* worker.forEach<string, string>(function* (request) {
466
+ return `host-handled: ${request}`;
467
+ });
468
+ });
469
+
470
+ // Send message to worker, which triggers it to call send() back to host
471
+ const result = yield* worker.send("trigger");
472
+ expect(result).toEqual(
473
+ "processed: trigger with host-handled: worker-request-for: trigger",
474
+ );
475
+ });
476
+ });
179
477
  });
package/worker.ts CHANGED
@@ -1,29 +1,71 @@
1
- import assert from "node:assert";
2
1
  import {
3
2
  Err,
4
3
  Ok,
4
+ type Operation,
5
+ type Result,
6
+ createChannel,
5
7
  on,
6
8
  once,
7
- type Operation,
8
9
  resource,
9
- type Result,
10
10
  spawn,
11
11
  withResolvers,
12
12
  } from "effection";
13
13
  import Worker from "web-worker";
14
14
 
15
- import { useMessageChannel } from "./message-channel.ts";
15
+ import { useChannelRequest, useChannelResponse } from "./channel.ts";
16
+ import {
17
+ type ForEachContext,
18
+ type SerializedError,
19
+ errorFromSerialized,
20
+ } from "./types.ts";
21
+ // Note: Ok/Err still used for outcome handling; serializeError no longer needed here
16
22
 
17
23
  /**
18
- * Argument received by workerMain function
24
+ * Resource returned by useWorker, providing APIs for worker communication.
19
25
  *
20
26
  * @template TSend - value main thread will send to the worker
21
27
  * @template TRecv - value main thread will receive from the worker
22
- * @template TData - data passed from the main thread to the worker during initialization
28
+ * @template TReturn - worker operation return value
23
29
  */
24
30
  export interface WorkerResource<TSend, TRecv, TReturn>
25
31
  extends Operation<TReturn> {
32
+ /**
33
+ * Send a message to the worker and wait for a response.
34
+ */
26
35
  send(data: TSend): Operation<TRecv>;
36
+ /**
37
+ * Handle requests initiated by the worker.
38
+ * Only one forEach can be active at a time.
39
+ *
40
+ * The handler receives a context object with a `progress` method for
41
+ * sending progress updates back to the worker.
42
+ *
43
+ * @template WRequest - value worker sends to host
44
+ * @template WResponse - value host sends back to worker
45
+ * @template WProgress - progress type sent back to worker (optional)
46
+ *
47
+ * @example Basic usage (no progress)
48
+ * ```ts
49
+ * yield* worker.forEach(function* (request) {
50
+ * return computeResponse(request);
51
+ * });
52
+ * ```
53
+ *
54
+ * @example With progress streaming
55
+ * ```ts
56
+ * yield* worker.forEach(function* (request, ctx) {
57
+ * yield* ctx.progress({ step: 1, message: "Starting..." });
58
+ * yield* ctx.progress({ step: 2, message: "Processing..." });
59
+ * return { result: "done" };
60
+ * });
61
+ * ```
62
+ */
63
+ forEach<WRequest, WResponse, WProgress = never>(
64
+ fn: (
65
+ request: WRequest,
66
+ ctx: ForEachContext<WProgress>,
67
+ ) => Operation<WResponse>,
68
+ ): Operation<TReturn>;
27
69
  }
28
70
 
29
71
  /**
@@ -80,30 +122,93 @@ export function useWorker<TSend, TRecv, TReturn, TData>(
80
122
  ): Operation<WorkerResource<TSend, TRecv, TReturn>> {
81
123
  return resource(function* (provide) {
82
124
  let outcome = withResolvers<TReturn>();
125
+ let outcomeSettled = false;
126
+
127
+ const resolveOutcome = (value: TReturn) => {
128
+ if (outcomeSettled) {
129
+ return;
130
+ }
131
+ outcomeSettled = true;
132
+ outcome.resolve(value);
133
+ };
134
+
135
+ const rejectOutcome = (error: Error) => {
136
+ if (outcomeSettled) {
137
+ return;
138
+ }
139
+ outcomeSettled = true;
140
+ outcome.reject(error);
141
+ };
83
142
 
84
143
  let worker = new Worker(url, options);
85
144
  let subscription = yield* on(worker, "message");
86
145
 
87
- let onclose = (event: MessageEvent) => {
88
- if (event.data.type === "close") {
89
- let { result } = event.data as { result: Result<TReturn> };
90
- if (result.ok) {
91
- outcome.resolve(result.value);
92
- } else {
93
- outcome.reject(result.error);
146
+ // Channel for worker-initiated requests (buffered via eager subscription)
147
+ const requests = createChannel<
148
+ { value: unknown; response: MessagePort },
149
+ void
150
+ >();
151
+ // Subscribe immediately so messages buffer before forEach is called
152
+ const requestSubscription = yield* requests;
153
+
154
+ // Flags for forEach state
155
+ let forEachInProgress = false;
156
+ let forEachCompleted = false;
157
+ let opened = false;
158
+
159
+ // Signal for when worker is ready (received "open" message)
160
+ const ready = withResolvers<void>();
161
+
162
+ // Spawned message loop - handles incoming messages using each pattern
163
+ yield* spawn(function* () {
164
+ while (true) {
165
+ const next = yield* subscription.next();
166
+ if (next.done) {
167
+ break;
168
+ }
169
+
170
+ const msg = next.value.data;
171
+ if (!opened && msg.type !== "open") {
172
+ const error = new Error(
173
+ `expected first message to arrive from worker to be of type "open", but was: ${msg.type}`,
174
+ );
175
+ ready.reject(error);
176
+ throw error;
94
177
  }
95
- }
96
- };
97
178
 
98
- worker.addEventListener("message", onclose);
179
+ if (msg.type === "open") {
180
+ opened = true;
181
+ ready.resolve();
182
+ } else if (msg.type === "close") {
183
+ const { result } = msg as { result: Result<TReturn> };
184
+ if (result.ok) {
185
+ resolveOutcome(result.value);
186
+ } else {
187
+ const serializedError = result.error as unknown as SerializedError;
188
+ rejectOutcome(
189
+ errorFromSerialized("Worker failed", serializedError),
190
+ );
191
+ }
192
+ // Close channel so forEach terminates naturally
193
+ yield* requests.close(undefined);
194
+ } else if (msg.type === "request") {
195
+ yield* requests.send({ value: msg.value, response: msg.response });
196
+ }
197
+ }
99
198
 
100
- let first = yield* subscription.next();
199
+ if (!opened) {
200
+ const error = new Error(
201
+ "worker terminated before sending open message",
202
+ );
203
+ ready.reject(error);
204
+ throw error;
205
+ }
206
+ });
101
207
 
102
- assert(
103
- first.value.data.type === "open",
104
- `expected first message to arrive from worker to be of type "open", but was: ${first.value.data.type}`,
105
- );
208
+ // Wait for "open" message before proceeding
209
+ yield* ready.operation;
106
210
 
211
+ // Handle worker errors
107
212
  yield* spawn(function* () {
108
213
  let event = yield* once(worker, "error");
109
214
  event.preventDefault();
@@ -118,29 +223,108 @@ export function useWorker<TSend, TRecv, TReturn, TData>(
118
223
 
119
224
  yield* provide({
120
225
  *send(value) {
121
- let channel = yield* useMessageChannel();
226
+ const response = yield* useChannelResponse<TRecv>();
122
227
  worker.postMessage(
123
228
  {
124
229
  type: "send",
125
230
  value,
126
- response: channel.port2,
231
+ response: response.port,
127
232
  },
128
- [channel.port2],
233
+ [response.port],
129
234
  );
130
- channel.port1.start();
131
- let event = yield* once(channel.port1, "message");
132
- let result = (event as MessageEvent).data;
235
+ const result = yield* response;
133
236
  if (result.ok) {
134
237
  return result.value;
135
238
  }
136
- throw result.error;
239
+ throw errorFromSerialized("Worker handler failed", result.error);
137
240
  },
241
+
242
+ *forEach<WRequest, WResponse, WProgress = never>(
243
+ fn: (
244
+ request: WRequest,
245
+ ctx: ForEachContext<WProgress>,
246
+ ) => Operation<WResponse>,
247
+ ): Operation<TReturn> {
248
+ // Prevent calling forEach more than once
249
+ if (forEachCompleted) {
250
+ throw new Error("forEach has already completed");
251
+ }
252
+
253
+ // Prevent concurrent forEach
254
+ if (forEachInProgress) {
255
+ throw new Error("forEach is already in progress");
256
+ }
257
+ forEachInProgress = true;
258
+
259
+ try {
260
+ // Iterate until channel closes (when worker sends "close")
261
+ let next = yield* requestSubscription.next();
262
+ while (!next.done) {
263
+ const request = next.value;
264
+ // Track handler errors - we forward to worker but also re-throw to host
265
+ let handlerError: Error | undefined;
266
+
267
+ // Create a task for this request and wait for it to complete
268
+ const task = yield* spawn(function* () {
269
+ const channelRequest = yield* useChannelRequest<
270
+ WResponse,
271
+ WProgress
272
+ >(request.response);
273
+ try {
274
+ // Create context with progress method
275
+ const ctx: ForEachContext<WProgress> = {
276
+ progress: (data: WProgress) =>
277
+ channelRequest.progress(data),
278
+ };
279
+ const result = yield* fn(request.value as WRequest, ctx);
280
+ yield* channelRequest.resolve(result);
281
+ } catch (error) {
282
+ // Forward error to worker so it knows the request failed
283
+ yield* channelRequest.reject(error as Error);
284
+ // Store error to re-throw after forwarding (don't swallow host errors)
285
+ handlerError = error as Error;
286
+ }
287
+ });
288
+
289
+ // Wait for the handler to complete
290
+ yield* task;
291
+
292
+ // If the handler failed, stop processing and re-throw
293
+ if (handlerError) {
294
+ throw handlerError;
295
+ }
296
+ next = yield* requestSubscription.next();
297
+ }
298
+ return yield* outcome.operation;
299
+ } finally {
300
+ forEachInProgress = false;
301
+ forEachCompleted = true;
302
+ }
303
+ },
304
+
138
305
  [Symbol.iterator]: outcome.operation[Symbol.iterator],
139
306
  });
140
307
  } finally {
141
308
  worker.postMessage({ type: "close" });
309
+ if (!outcomeSettled) {
310
+ while (!outcomeSettled) {
311
+ const event = yield* once(worker, "message");
312
+ const msg = event.data;
313
+ if (msg.type === "close") {
314
+ const { result } = msg as { result: Result<TReturn> };
315
+ if (result.ok) {
316
+ resolveOutcome(result.value);
317
+ } else {
318
+ const serializedError =
319
+ result.error as unknown as SerializedError;
320
+ rejectOutcome(
321
+ errorFromSerialized("Worker failed", serializedError),
322
+ );
323
+ }
324
+ }
325
+ }
326
+ }
142
327
  yield* settled(outcome.operation);
143
- worker.removeEventListener("message", onclose);
144
328
  }
145
329
  });
146
330
  }
@@ -1,3 +0,0 @@
1
- import { type Operation } from "effection";
2
- export declare function useMessageChannel(): Operation<MessageChannel>;
3
- //# sourceMappingURL=message-channel.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"message-channel.d.ts","sourceRoot":"","sources":["../message-channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAY,MAAM,WAAW,CAAC;AAErD,wBAAgB,iBAAiB,IAAI,SAAS,CAAC,cAAc,CAAC,CAU7D"}
@@ -1,13 +0,0 @@
1
- import { resource } from "effection";
2
- export function useMessageChannel() {
3
- return resource(function* (provide) {
4
- let channel = new MessageChannel();
5
- try {
6
- yield* provide(channel);
7
- }
8
- finally {
9
- channel.port1.close();
10
- channel.port2.close();
11
- }
12
- });
13
- }
@@ -1,13 +0,0 @@
1
- import { type Operation, resource } from "effection";
2
-
3
- export function useMessageChannel(): Operation<MessageChannel> {
4
- return resource(function* (provide) {
5
- let channel = new MessageChannel();
6
- try {
7
- yield* provide(channel);
8
- } finally {
9
- channel.port1.close();
10
- channel.port2.close();
11
- }
12
- });
13
- }