@zwave-js/waddle 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 AlCalzone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,425 @@
1
+ # ![Waddle](docs/logo.jpg)
2
+
3
+ ## A cooperative task scheduler
4
+
5
+ ### Install
6
+
7
+ ```
8
+ npm install @zwave-js/waddle
9
+ ```
10
+
11
+ ### Usage
12
+
13
+ To use the task scheduler, create a new instance and start it:
14
+
15
+ ```js
16
+ const scheduler = new TaskScheduler();
17
+ scheduler.start();
18
+ ```
19
+
20
+ Afterwards you can queue tasks and they will automatically be executed. Each task is simply a [generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) that can `yield` to the scheduler at any time. Thi will allow other tasks to run - hence the name "cooperative task scheduler".
21
+
22
+ ```js
23
+ const order = [];
24
+
25
+ // Start a task with normal priority
26
+ const task1 = scheduler.queueTask({
27
+ priority: TaskPriority.Normal,
28
+ task: async function* () {
29
+ order.push("1a");
30
+ // Simulate some work
31
+ await wait(1);
32
+ // Then yield to the scheduler
33
+ yield;
34
+
35
+ order.push("1b");
36
+ await wait(1);
37
+ yield;
38
+
39
+ order.push("1c");
40
+ return 1;
41
+ },
42
+ });
43
+
44
+ // Start a task with high priority
45
+ const task2 = scheduler.queueTask({
46
+ priority: TaskPriority.High,
47
+ task: async function* () {
48
+ order.push("2a");
49
+ await wait(1);
50
+ yield;
51
+
52
+ order.push("2b");
53
+ await wait(1);
54
+ yield;
55
+
56
+ order.push("2c");
57
+ return 2;
58
+ },
59
+ });
60
+ ```
61
+
62
+ `queueTask` return a Promise that resolves to the value the task returns. This way you can wait for the task to finish and return values from them:
63
+
64
+ ```js
65
+ const results = await Promise.all([task1, task2]);
66
+ console.log(results); // [1, 2]
67
+ ```
68
+
69
+ Each task has a priority that determines in which order the tasks are executed. Looking at the above example, we can see that task 2 has a higher priority than task 1. Once task 1 yields to the scheduler, task 2 will start executing. Because task 2 has a higher priority, it will run to completion before task 1 continues:
70
+
71
+ ```js
72
+ console.log(order); // ["1a", "2a", "2b", "2c", "1b", "1c"]
73
+ ```
74
+
75
+ To stop the scheduler, simply...
76
+
77
+ ```js
78
+ await scheduler.stop();
79
+ ```
80
+
81
+ #### Identifying tasks
82
+
83
+ To identify individual tasks, e.g. for canceling them (see below), you can pass additional information to the task builder. This is all optional:
84
+
85
+ ```js
86
+ const task2 = scheduler.queueTask({
87
+ // A human-readable name for the task
88
+ name: "My Task",
89
+ // Information to programmatically identify the task
90
+ tag: {
91
+ id: "my-task",
92
+ argument: 42,
93
+ }
94
+ priority: TaskPriority.High,
95
+ task: async function* () {
96
+ // ...
97
+ },
98
+ });
99
+ ```
100
+
101
+ The `tag` must be an object that has at least a string `id`. It is recommended to use a custom type for this to be able to distinguish related tasks. For example:
102
+
103
+ ```ts
104
+ export type TaskTag =
105
+ | {
106
+ // Rebuild routes for all nodes
107
+ id: "rebuild-routes";
108
+ }
109
+ | {
110
+ // Rebuild routes for a single node
111
+ id: "rebuild-node-routes";
112
+ nodeId: number;
113
+ }
114
+ | {
115
+ // Perform an OTA firmware update for a node
116
+ id: "firmware-update-ota";
117
+ nodeId: number;
118
+ };
119
+ ```
120
+
121
+ This information can also be used to retrieve a task from the scheduler:
122
+
123
+ ```js
124
+ const task = scheduler.findTask((task) => task.tag?.id === "rebuild-routes");
125
+ // ^ Either a Promise or undefined, depending on whether the task exists or not
126
+ ```
127
+
128
+ #### Task Priority
129
+
130
+ There are several task priorities defined:
131
+
132
+ ```ts
133
+ /**
134
+ * The priority of a task.
135
+ *
136
+ * Higher priority tasks are executed first and interrupt lower priority tasks.
137
+ * The recommended priority for application-initiated communication is `Normal`.
138
+ * `Low` and `Lower` are recommended for internal long-running tasks that should not interfere with user-initiated tasks.
139
+ * `Idle` is recommended for tasks that should only run when no other tasks are pending.
140
+ */
141
+ export enum TaskPriority {
142
+ Highest,
143
+ High = 1,
144
+ Normal = 2,
145
+ Low = 3,
146
+ Lower = 4,
147
+ Idle = 5,
148
+ }
149
+ ```
150
+
151
+ When a task yields, the scheduler may switch to a different task. Tasks of equal priority will be interleaved, while tasks with higher priority will run to completion before lower priority tasks are resumed. Tasks with lower priority will not run as long as there are tasks with a higher priority pending.
152
+
153
+ #### Interrupt Behavior
154
+
155
+ You can also specify how the scheduler should behave when the task is at a yield point. The following interrupt behaviors are available:
156
+
157
+ ```ts
158
+ export enum TaskInterruptBehavior {
159
+ /** The task may not be interrupted */
160
+ Forbidden,
161
+ /** The task will be resumed after being interrupted (default) */
162
+ Resume,
163
+ /** The task needs to be restarted after being interrupted */
164
+ Restart,
165
+ }
166
+ ```
167
+
168
+ By default, all tasks will simply be resumed where they left off when the scheduler wants to run them again.
169
+ You can also specify that a task needs to be restarted from the beginning after being interrupted by a higher priority task.
170
+ Additionally, some tasks can be marked as not interruptible. This means that even if they reach a point where they could be interrupted and a higher priority task is pending, they will not be interrupted.
171
+
172
+ #### Waiting while yielding
173
+
174
+ The previous examples showed how to yield to the scheduler, which is fine for most cases.
175
+
176
+ When asynchronously performing work that takes a while to complete, simply `await`ing that would block the scheduler and is therefore not recommended:
177
+
178
+ ```js
179
+ // ❌ Do not do this!
180
+ async function* fetchResourcesTask() {
181
+ // These 3 calls will all run in a block without allowing other tasks to run:
182
+ await doLongRunningWork1();
183
+ await doLongRunningWork2();
184
+ await doLongRunningWork3();
185
+ }
186
+ ```
187
+
188
+ Instead, the `Promise` that should be awaited can be passed back to the scheduler, so it knows when the task is ready to continue. This is done by `yield`ing a function that returns the `Promise`, like so:
189
+
190
+ ```js
191
+ // ✅ Do this instead!
192
+ async function* fetchResourcesTask() {
193
+ yield () => doLongRunningWork1();
194
+ yield () => doLongRunningWork2();
195
+ yield () => doLongRunningWork3();
196
+ }
197
+ ```
198
+
199
+ At each `yield` point, the task will be suspended and the scheduler can run other tasks. Once the returned `Promise` resolves (and another task is ready to yield), the original task will be resumed.
200
+
201
+ You can also use the results of the `Promise` in the task:
202
+
203
+ ```js
204
+ async function* fetchResourcesTask() {
205
+ const result1 = yield () => doLongRunningWork1();
206
+ const result2 = yield () => doLongRunningWork2();
207
+ const result3 = yield () => doLongRunningWork3();
208
+
209
+ // Do something with the results
210
+ console.log(result1, result2, result3);
211
+ }
212
+ ```
213
+
214
+ When using TypeScript, you may need to assert the return type due to limits in type inference in generator functions:
215
+
216
+ ```ts
217
+ async function* fetchResourcesTask() {
218
+ // async function doLongRunningWork1(): Promise<string> { ... }
219
+ const result1 = (yield () => doLongRunningWork1()) as string;
220
+ // async function doLongRunningWork2(): Promise<number> { ... }
221
+ const result2 = (yield () => doLongRunningWork2()) as number;
222
+ // async function doLongRunningWork3(): Promise<boolean> { ... }
223
+ const result3 = (yield () => doLongRunningWork3()) as boolean;
224
+
225
+ // Do something with the results
226
+ console.log(result1, result2, result3);
227
+ }
228
+ ```
229
+
230
+ #### Waiting for subtasks
231
+
232
+ If a task depends on the results of another task, the parent task can also ask the scheduler to execute that task and wait for it to finish.
233
+ This can be done by yielding a `TaskBuilder` object, which is what you'd normally pass to `queueTask`:
234
+
235
+ ```js
236
+ const childTaskBuilder = {
237
+ priority: TaskPriority.Normal,
238
+ task: async function* () {
239
+ // Do some work
240
+ await wait(1);
241
+ return 42;
242
+ },
243
+ };
244
+
245
+ const parentTask = scheduler.queueTask({
246
+ priority: TaskPriority.Normal,
247
+ task: async function* () {
248
+ const childResult = yield childTaskBuilder;
249
+ return childResult + 1;
250
+ },
251
+ });
252
+
253
+ const result = await parentTask;
254
+ console.log(result); // 43
255
+ ```
256
+
257
+ Like with yielding Promises, you may need to help TypeScript with the return type:
258
+
259
+ ```ts
260
+ // [...]
261
+ const parentTask = scheduler.queueTask({
262
+ priority: TaskPriority.Normal,
263
+ task: async function* () {
264
+ const childResult = (yield childTaskBuilder) as number;
265
+ return childResult + 1;
266
+ },
267
+ });
268
+ ```
269
+
270
+ #### Calling other generator functions
271
+
272
+ In JavaScript, a generator function can call another generator function and forward its results using the `yield*` operator:
273
+
274
+ ```js
275
+ function* generator1() {
276
+ yield 1;
277
+ yield 2;
278
+ }
279
+ function* generator2() {
280
+ yield* generator1();
281
+ yield 3;
282
+ }
283
+ for (const value of generator2()) {
284
+ console.log(value); // 1, 2, 3
285
+ }
286
+ ```
287
+
288
+ The same principle can be used to split tasks into multiple functions:
289
+
290
+ ```js
291
+ async function* task1() {
292
+ yield () => doLongRunningWork1();
293
+ yield () => doLongRunningWork2();
294
+ }
295
+ async function* task2() {
296
+ yield () => doLongRunningWork3();
297
+ yield () => doLongRunningWork4();
298
+ }
299
+ async function* mainTask() {
300
+ yield* task1();
301
+ yield* task2();
302
+ }
303
+
304
+ const task = scheduler.queueTask({
305
+ priority: TaskPriority.Normal,
306
+ task: mainTask,
307
+ });
308
+ ```
309
+
310
+ This task will yield to the scheduler at each `yield` point in the `task1` and `task2` functions, just as if they were all in the same function.
311
+
312
+ #### Error handling
313
+
314
+ If a task throws an error, the scheduler will catch it and reject the `Promise` returned by `queueTask`. The error can be handled like any other Promise:
315
+
316
+ ```js
317
+ const task = scheduler.queueTask({
318
+ priority: TaskPriority.Normal,
319
+ task: async function* () {
320
+ throw new Error("Something went wrong");
321
+ },
322
+ });
323
+ try {
324
+ await task;
325
+ } catch (error) {
326
+ console.error(error); // Error: Something went wrong
327
+ }
328
+ ```
329
+
330
+ The same is true for yielded Promises
331
+
332
+ ```js
333
+ const task = scheduler.queueTask({
334
+ priority: TaskPriority.Normal,
335
+ task: async function* () {
336
+ try {
337
+ yield () => someWorkThatMightFail();
338
+ } catch (error) {
339
+ console.error(error); // Error: Something went wrong
340
+ }
341
+ },
342
+ });
343
+ ```
344
+
345
+ or for subtasks:
346
+
347
+ ```js
348
+ const childTaskBuilder = {
349
+ priority: TaskPriority.Normal,
350
+ task: async function* () {
351
+ throw new Error("Something went wrong");
352
+ },
353
+ };
354
+
355
+ const parentTask = scheduler.queueTask({
356
+ priority: TaskPriority.Normal,
357
+ task: async function* () {
358
+ try {
359
+ yield childTaskBuilder;
360
+ } catch (error) {
361
+ console.error(error); // Error: Something went wrong
362
+ }
363
+ },
364
+ });
365
+ ```
366
+
367
+ #### Canceling tasks
368
+
369
+ To cancel one or more tasks, simply call the `scheduler.removeTasks` method. This method takes a predicate function that will be called for each active and queued task. If the predicate returns `true`, the task will be removed from the scheduler. This can be used to cancel tasks that are no longer needed.
370
+
371
+ Note that running tasks will not be canceled immediately. Instead they will run until the next `yield` point first.
372
+
373
+ ```js
374
+ // Cancel all tasks
375
+ scheduler.removeTasks(() => true);
376
+ ```
377
+
378
+ You can also access the task's `name` and `tag` properties (see above) to decide which tasks to cancel:
379
+
380
+ ```js
381
+ // Cancel all rebuild routes tasks
382
+ scheduler.removeTasks((task) => task.tag?.id === "rebuild-routes");
383
+ ```
384
+
385
+ The function will resolve to `true` if at least one task was canceled, or `false` if no tasks were canceled:
386
+
387
+ ```js
388
+ const canceled = await scheduler.removeTasks(
389
+ (task) => task.tag?.id === "rebuild-routes",
390
+ );
391
+ if (canceled) {
392
+ console.log("Canceled all rebuild routes tasks");
393
+ } else {
394
+ console.log("No tasks were canceled");
395
+ }
396
+ ```
397
+
398
+ Canceled tasks will result in an `Error`. Take care of this when awaiting them!
399
+
400
+ By default, each canceled tasks will be rejected with this error:
401
+
402
+ ```js
403
+ new Error("Task was removed");
404
+ ```
405
+
406
+ To customize the behavior, either pass a custom error to the `removeTasks` method
407
+
408
+ ```js
409
+ const canceled = await scheduler.removeTasks(
410
+ () => true,
411
+ new Error("We are all doomed!"),
412
+ );
413
+ ```
414
+
415
+ or customize the default error by passing a custom error factory to the `TaskScheduler` constructor:
416
+
417
+ ```js
418
+ const scheduler = new TaskScheduler(() => new Error("We are all doomed!"));
419
+ ```
420
+
421
+ ## Changelog
422
+
423
+ ### 1.0.0 (2025-05-16)
424
+
425
+ - Initial release
@@ -0,0 +1,138 @@
1
+ /** A high-level task that can be started and stepped through */
2
+ export interface Task<TReturn, TaskTag extends {
3
+ id: string;
4
+ } = {
5
+ id: string;
6
+ }, TError extends Error = Error> {
7
+ readonly id: number;
8
+ readonly timestamp: number;
9
+ readonly builder: TaskBuilder<TReturn, TaskTag>;
10
+ /** The parent task spawning this subtask, if any */
11
+ readonly parent?: Task<unknown, TaskTag, TError>;
12
+ /** A name to identify the task */
13
+ readonly name?: string;
14
+ /** A tag to identify the task programmatically */
15
+ readonly tag?: TaskTag;
16
+ /** The task's priority */
17
+ readonly priority: TaskPriority;
18
+ /** How the task should behave when interrupted */
19
+ readonly interrupt: TaskInterruptBehavior;
20
+ /** Starts the task it if hasn't been started yet, and executes the next step of the task */
21
+ step(): Promise<TaskStepResult<TReturn, TaskTag>>;
22
+ /** Stops the task without further executing it, cleans up, and prepares it for starting again */
23
+ reset(): Promise<void>;
24
+ /** Resolves the task's promise to notify the caller */
25
+ resolve(result: TReturn): void;
26
+ /** Rejects the task's promise to notify the caller */
27
+ reject(error: TError): void;
28
+ /** The current state of the task */
29
+ get state(): TaskState;
30
+ readonly generator: ReturnType<TaskBuilder<TReturn>["task"]> | undefined;
31
+ readonly promise: Promise<TReturn>;
32
+ }
33
+ /** Defines the necessary information for creating a task */
34
+ export interface TaskBuilder<TReturn, TaskTag extends {
35
+ id: string;
36
+ } = {
37
+ id: string;
38
+ }, TInner = unknown> {
39
+ /** A name to identify the task */
40
+ name?: string;
41
+ /** A tag to identify the task programmatically */
42
+ tag?: TaskTag;
43
+ /** The task's priority */
44
+ priority: TaskPriority;
45
+ /** How the task should behave when interrupted */
46
+ interrupt?: TaskInterruptBehavior;
47
+ /**
48
+ * The task's main generator function. This is called repeatedly until it's done.
49
+ * The function must yield at points where it may be interrupted.
50
+ *
51
+ * At those points, the task can also wait for something to happen:
52
+ * - Either a Promise, in which case it must yield a function that returns that Promise
53
+ * - Or another task, in which case it must yield a TaskBuilder object or a function that returns one
54
+ *
55
+ * Yielded Promises should not spawn new tasks. If they do, the spawned tasks MUST have a higher priority than the parent task.
56
+ */
57
+ task: () => AsyncGenerator<(() => Promise<TInner> | TaskBuilder<TReturn, TaskTag, TInner>) | (() => TaskBuilder<TReturn, TaskTag, TInner>) | TaskBuilder<TReturn, TaskTag, TInner> | undefined, TReturn, TInner>;
58
+ /** A cleanup function that gets called when the task is dropped */
59
+ cleanup?: () => Promise<void>;
60
+ }
61
+ export type TaskReturnType<T extends TaskBuilder<unknown>> = T extends TaskBuilder<infer R> ? R : never;
62
+ /**
63
+ * The priority of a task.
64
+ *
65
+ * Higher priority tasks are executed first and interrupt lower priority tasks.
66
+ * The recommended priority for application-initiated communication is `Normal`.
67
+ * `Low` and `Lower` are recommended for internal long-running tasks that should not interfere with user-initiated tasks.
68
+ * `Idle` is recommended for tasks that should only run when no other tasks are pending.
69
+ */
70
+ export declare enum TaskPriority {
71
+ Highest = 0,
72
+ High = 1,
73
+ Normal = 2,
74
+ Low = 3,
75
+ Lower = 4,
76
+ Idle = 5
77
+ }
78
+ export declare enum TaskState {
79
+ /** The task has not been created yet */
80
+ None = 0,
81
+ /** The task is being executed */
82
+ Active = 1,
83
+ /** The task is waiting for a Promise to resolve */
84
+ AwaitingPromise = 2,
85
+ /** The task is waiting for another task to finish */
86
+ AwaitingTask = 3,
87
+ /** The task is finished */
88
+ Done = 4
89
+ }
90
+ export declare enum TaskInterruptBehavior {
91
+ /** The task may not be interrupted */
92
+ Forbidden = 0,
93
+ /** The task will be resumed after being interrupted (default) */
94
+ Resume = 1,
95
+ /** The task needs to be restarted after being interrupted */
96
+ Restart = 2
97
+ }
98
+ export type TaskStepResult<T, TaskTag extends {
99
+ id: string;
100
+ } = {
101
+ id: string;
102
+ }> = {
103
+ newState: TaskState.Done;
104
+ result: T;
105
+ } | {
106
+ newState: TaskState.Active;
107
+ } | {
108
+ newState: TaskState.AwaitingPromise;
109
+ promise: Promise<unknown>;
110
+ } | {
111
+ newState: TaskState.AwaitingTask;
112
+ task: Task<unknown, TaskTag>;
113
+ };
114
+ export declare class TaskScheduler<TaskTag extends {
115
+ id: string;
116
+ } = {
117
+ id: string;
118
+ }, TError extends Error = Error> {
119
+ private defaultErrorFactory;
120
+ private verbose;
121
+ constructor(defaultErrorFactory?: () => TError, verbose?: boolean);
122
+ private _tasks;
123
+ private _currentTask;
124
+ private _idGenerator;
125
+ private _continueSignal;
126
+ private _stopSignal;
127
+ private _stopPromise;
128
+ queueTask<T>(builder: TaskBuilder<T, TaskTag>): Promise<T>;
129
+ /** Removes/stops tasks matching the given predicate. Returns `true` when a task was removed, `false` otherwise. */
130
+ removeTasks(predicate: (task: Task<unknown, TaskTag>) => boolean, reason?: TError): Promise<boolean>;
131
+ findTask<T = unknown>(predicate: (task: Task<T, TaskTag>) => boolean): Promise<T> | undefined;
132
+ /** Creates a task that can be executed */
133
+ private createTask;
134
+ start(): void;
135
+ private run;
136
+ stop(): Promise<void>;
137
+ }
138
+ //# sourceMappingURL=Task.d.ts.map