Compare commits

...

19 commits

Author SHA1 Message Date
Victor Westerlund
6b18c90228 Remove promise anipattern 2022-01-21 13:07:16 +01:00
Victor Westerlund
46f3a07c85
Replace setTimeout with requestAnimationFrame (#3)
* dev21w45-a

* dev21w45-b

* Implement requestAnimationFrame

This commit replaces setTimeout with a working example of requestAnimationFrame. Some further testing will have to be done

* Add support for loop
2021-12-26 14:07:37 +01:00
Victor Westerlund
a6e51c1e48
Update README.md 2021-12-26 12:50:52 +01:00
Victor Westerlund
22eda97800
Merge pull request #2 from VictorWesterlund/feature/comlink
ES6 Proxies with Comlink
2021-11-10 14:48:14 +01:00
Victor Westerlund
9e362617e9
Update README.md 2021-11-10 12:52:43 +01:00
539690cbdd dev21w45c
Ready for review
2021-11-10 12:31:04 +01:00
675fe748e4 dev21w45b 2021-11-09 17:23:04 +01:00
86ea8cd031 dev21w45a 2021-11-08 16:38:56 +01:00
ff0ed25a3b dev21w44b 2021-11-07 13:36:26 +01:00
dd071ea8bb dev21w44a 2021-11-05 17:34:20 +01:00
4073785f93 Fix Content-Type test false negative
Some servers will append extra stuff (like encoding) to the `Content-Type` response header. This fixes that
2021-10-31 13:23:58 +01:00
b2556f1ced Added flag queue
Calls sent to setFlag before a monkey has been spawned will be queued until the next GIVE_MANIFEST event is received.

- Replaced this.monkey with let monkey
2021-10-18 13:12:04 +02:00
c713bb8755 Added ack function to class scope
Moved ack function from giveManifest to the MonkeyManager class so other functions can use it in the future.
2021-10-11 16:02:06 +02:00
f53bea079e
Update README.md
Fixed sample output (and song lyrics, very important stuff)
2021-10-11 10:40:14 +02:00
32493065fc
Fixed logo font
Replaced embedded font reference with outlines in logo
2021-10-09 17:50:54 +02:00
d1b04e406b Minor changes 2021-10-08 17:04:33 +02:00
3ab606641e Fixed task reference
And removed a console.log
2021-10-08 15:15:53 +02:00
f6e86a5892 Corrected manifest data references 2021-10-08 14:45:46 +02:00
17224b0a86 Renamed "worker" to "do"
Renaming the "worker" folder to "do" so we can add more workers without having to cram them all in one folder.
2021-10-08 13:56:10 +02:00
6 changed files with 333 additions and 338 deletions

View file

@ -1,110 +1,43 @@
import { default as MonkeyWorker } from "./worker/MonkeyManager.mjs";
import { default as MonkeyMaster } from "./monkey/MonkeyMaster.mjs";
export default class Monkeydo extends MonkeyWorker {
constructor(methods = {},manifest = false) {
super(methods);
this.monkeydo = {
version: "0.2.1",
debugLevel: 0,
// Flag if debugging is enabled, regardless of level
get debug() {
return this.debugLevel > 0 ? true : false;
},
// Set debug level. Non-verbose debugging if called without an argument
set debug(level = 1) {
this.debugLevel = level;
}
};
Object.seal(this.monkeydo);
// Monkeydo manifest parsed with load()
this.manifest = {
header: null,
body: null
};
if(!window.Worker) {
throw new Error("JavaScript Workers aren't supported by your browser");
}
if(manifest) {
this.load(manifest);
export default class Monkeydo extends MonkeyMaster {
constructor(methods) {
if(typeof methods !== "object") {
throw new TypeError(`Expected type 'object' but got '${typeof methods}' when initializing Monkeydo`);
}
super();
this.methods = {};
Object.assign(this.methods,methods);
}
debug(...attachments) {
if(this.monkeydo.debug) {
console.warn("-- Monkeydo debug -->",attachments);
return;
}
// Execute a task
do(task) {
if(!task[1] in this.methods) return;
const args = task.splice(2);
this.methods[task[1]]?.(...args);
}
// Loop playback; -1 or false = infinite
loop(times = -1) {
// Typecast boolean to left shifted integer;
if(typeof times === "boolean") {
times = times ? -1 : 0;
// Loop playback X times or negative number for infinite
async loop(times = 255) {
if(typeof times !== "number") {
times = parseInt(times);
}
times = times < 0 ? -1 : times;
this.setFlag("loop",times);
times = Math.floor(times);
times = Math.min(Math.max(times,0),255); // Clamp number to 8 bits
return await this.setFlag("loop",times);
}
// Load a Monkeydo manifest from JSON via string or URL
// Load Monkeydo manifest
async load(manifest) {
const errorPrefix = "MANIFEST_IMPORT_FAILED: ";
let data;
if(typeof manifest !== "string") {
this.debug(manifest);
throw new TypeError(errorPrefix + "Expected JSON or URL");
if(typeof manifest === "object") {
manifest = JSON.stringify(manifest);
}
// Attempt to parse the argument as JSON
try {
data = JSON.parse(manifest);
}
catch {
// If that fails, attempt to parse it as a URL
try {
manifest = new URL(manifest);
const fetchManifest = await fetch(manifest);
// If the URL parsed but the fetch response is invalid, give up and throw an error
if(!fetchManifest.ok || fetchManifest.headers.get("Content-Type") !== "application/json") {
throw new TypeError(errorPrefix + "Invalid response Content-Type or HTTP status");
}
data = await fetchManifest.json();
}
catch(error) {
this.debug(manifest);
if(!error instanceof TypeError) {
throw new TypeError(errorPrefix + "Invalid JSON or URL");
}
throw error;
}
}
// Make sure the parsed JSON is a valid Monkeydo manifest
if(!data.hasOwnProperty("header") || !data.hasOwnProperty("body")) {
this.debug(data);
throw new Error(errorPrefix + "Expected 'header' and 'body' properties in object");
}
this.manifest.header = data.header;
this.manifest.body = data.body;
return true;
return await this.loadManifest(manifest);
}
// Execute tasks from Monkeydo manifest
async do() {
const errorPrefix = "DO_FAILED: ";
// Abort if the manifest object doesn't contain any header data
if(!this.manifest.header) {
this.debug(this.manifest.header);
throw new Error(errorPrefix + `Expected header object from contructed property`);
}
// Hand over the loaded manifest to the MonkeyWorker task manager
const monkey = this.giveManifest();
this.play();
async play(manifest = null) {
if(!this.ready && !manifest) throw new Error("Can not start playback without a manifest");
if(manifest) await this.load(manifest);
return await this.start();
}
}

125
README.md
View file

@ -1,9 +1,83 @@
<p align="center">
<img width="400" src="https://storage.googleapis.com/public.victorwesterlund.com/github/VictorWesterlund/monkeydo/monkeydo.svg"/>
<img width="400" src="https://storage.googleapis.com/public.victorwesterlund.com/github/VictorWesterlund/monkeydo/monkeydo_.svg"/>
</p>
<h3 align="center">Threaded task chaining for JavaScript</h3>
<h3 align="center">Multithreaded web animations and task chaining</h3>
<hr>
<p align="center">Monkeydo uses the portable data format JSON to read tasks, making it easy to read by primates and machines alike.</p>
<p align="center">Execute general purpose JavaScript on cue with greater performance. Monkeydo is great, and designed for, complex DOM animations.</p>
<p align="center"><img src="https://storage.googleapis.com/public.victorwesterlund.com/github/VictorWesterlund/monkeydo/simple_demo.gif"/></a>
<table>
<td>
<p align="center">Monkeydo JSON manifest<br><a href="#manifest-semantics">View semantics</a></p>
<pre lang="json">
{
"tasks": [
[2000,"moveTo",100,0],
[1500,"setColor","red"],
[2650,"setColor","blue"],
[550,"moveTo",350,0]
]
}
</pre>
</td>
<td>
<p align="center">Normal JavaScript</p>
<pre lang="js">
const methods = {
element: document.getElementById("element"),
moveTo: (x,y) => {
methods.element.style.setProperty("transform",`translate(${x}%,${y}%)`);
},
setColor: (color) => {
methods.element.style.setProperty("background-color",color);
}
};
</pre>
</td>
</table>
<a href="https://victorwesterlund.github.io/monkeydo-demo/demo/simple_shapes">Open live demo</a>
<h1 align="center">Use Monkeydo</h1>
<p>Monkeydo comes as an ES6 module. In this guide we'll import this directly from a <i>./modules/</i> folder, but any location accessible by the importing script will work.</p>
<ol>
<li><strong>Import <code>Monkeydo</code> as an ESM</strong>
<pre lang="js">
import { default as Monkeydo } from "./modules/Monkeydo/Monkeydo.mjs";
</pre>
</li>
<li><strong>Define your JS methods in an object</strong>
<pre lang="js">
const methods = {
singForMe: (foo,bar) => {
console.log(foo,bar);
}
}
</pre>
</li>
<li><strong>Define your tasks in a JSON manifest (file or JSON-compatible JavaScript)</strong>
<pre lang="json">
{
"tasks": [
[0,"singForMe","Just like a","monkey"],
[1200,"singForMe","I've been","dancing"],
[160,"singForMe","my whole","life"]
]
}
</pre>
</li>
<li><strong>Initialize and run <code>Monkeydo</code> with your methods and manifest</strong>
<pre lang="js">
const monkey = new Monkeydo(methods);
monkey.play(manifest);
</pre>
</li>
</ol>
<p>The example above would be the same as running:</p>
<pre lang="js">
console.log("Just like a","monkey"); // Right away
console.log("I've been","dancing"); // 1.2 seconds after the first
console.log("my whole","life"); // and then 160 milliseconds after the second
</pre>
<h1>Manifest Semantics</h1>
<p>The JS passed to the Monkeydo constructor is executed by the initiator thread (ususally the main thread) when time is up. Which method and when is defined in a JSON file or string with the following semantics:</p>
<table>
<td>
<pre lang="json">
@ -22,7 +96,7 @@
</tr>
<tr>
<td align="center">0</td>
<td><strong>Delay</strong><br>Wait this many milliseconds before running this task</td>
<td><strong>Delay</strong><br>Wait this many milliseconds before running this method</td>
</tr>
<tr>
<td align="center">1</td>
@ -35,46 +109,3 @@
</table>
</td>
</table>
<h1 align="center">Use Monkeydo</h1>
<p>Monkeydo comes as an importable ECMAScript 6 module. In this guide we'll import this directly from a <i>./modules/</i> folder, but any web-accesible location will work.</p>
<ol>
<li>Import <code>Monkeydo</code> from your repo clone or download
<pre lang="js">
import { default } from "./modules/Monkeydo/Monkeydo.mjs";
</pre>
</li>
<li>Define your JS methods
<pre lang="js">
const methods = {
myJavaScriptMethod: (foo,bar) => {
console.log(foo,bar);
}
}
</pre>
</li>
<li>Define your tasks in a JSON file (or directly in JavaScript)
<pre lang="json">
{
"tasks": [
[0,"myJavaSriptMethod","I see skies of","blue"],
[300,"myJavaSriptMethod","red","roses too"],
[160,"myJavaSriptMethod","I see them","bloom"],
[1200,"myJavaSriptMethod","for","me and you"]
]
}
</pre>
</li>
<li>Initialize and run <code>Monkeydo</code> with your methods and manifest
<pre lang="js">
const monkey = new Monkeydo(methods,manifest);
monkey.do();
</pre>
</li>
</ol>
<p>The example above would be the same as running:</p>
<pre lang="js">
console.log("I see skies","of blue"); // Right away
console.log("red","roses too"); // 300 milliseconds after the first
console.log("I see them","bloom"); // 160 milliseconds after that one
console.log("for","me and you"); // and lastly, 1200 after that
</pre>

92
monkey/Monkey.js Normal file
View file

@ -0,0 +1,92 @@
// Dedicated worker (monkey) that executes tasks from a Monkeydo manifest
importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");
class Monkey {
constructor() {
this.flags = new Uint8ClampedArray(3);
this.tasks = {
tasks: [],
length: 0,
_target: 0,
_i: 0,
set manifest(manifest) {
this.tasks = manifest;
this.length = this.tasks.length - 1;
},
get task() {
return this.tasks[this._i];
},
get target() {
return this._target;
}
}
}
// Advance to the next task or loop
next() {
// Reset index and loop if out of tasks
if(this.tasks._i >= this.tasks.length) {
this.tasks._i = -1;
if(this.flags[1] === 255) return; // Loop forever
this.flags[2] -= 1;
}
this.tasks._i++;
const nextTask = this.tasks.task;
this.tasks._target = performance.now() + nextTask[0];
}
// Main event loop, runs on every frame
tick() {
if(this === undefined) return;
if(this.flags[0] === 0 || this.flags[2] === 0) return this.abort();
const frame = Math.min(performance.now(),this.tasks.target);
if(frame == this.tasks.target) {
postMessage(["TASK",this.tasks.task]);
this.next();
}
requestAnimationFrame(this.tick.bind(this));
}
abort() {
this.flags[2] = 0; // Playing: false
}
// Set or get a runtime flag
flag(index,value = null) {
return value ? this.flags[index] = value : this.flags[index];
}
// Fetch and install manifest from URL
async fetchManifest(url) {
const manifest = await fetch(url);
if(!manifest.ok) {
console.error("Monkeydo fetch error:",manifest);
throw new Error("Server responded with an error");
};
const json = await manifest.json();
return await this.loadManifest(json);
}
// Install a Monkeydo manifest
async loadManifest(manifest) {
if(typeof manifest !== "object") {
try {
manifest = JSON.parse(manifest);
}
catch {
Promise.reject("Failed to load manifest");
}
}
this.tasks.manifest = manifest.tasks;
this.flags[0] = 1; // Manifest loaded: true
return true;
}
}
Comlink.expose(Monkey);

135
monkey/MonkeyMaster.mjs Normal file
View file

@ -0,0 +1,135 @@
// Task manager for Monkeydo dedicated workers (monkeys)
import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";
export default class MonkeyMaster {
constructor() {
this.comlink = null;
this.ready = false;
// Tasks will be queued here on runtime if the worker isn't ready
this.queue = {
_flags: [],
set flag(flag) {
this._flags.push(flag);
},
// Attempt to send all queued flags
sendAllFlags: () => {
// Copy flags and clear queue
const flags = [...this.queue._flags];
this.queue._flags = [];
flags.forEach(flag => this.setFlag(...flag));
}
};
}
// Import worker relative to this module
getWorkerPath() {
const name = "Monkey.js";
const url = new URL(import.meta.url);
// Replace pathname of this file with worker
const path = url.pathname.split("/");
path[path.length - 1] = name;
url.pathname = path.join("/");
return url.toString();
}
async init() {
// Spawn and wrap dedicated worker with Comlink
const worker = new Worker(this.getWorkerPath());
worker.addEventListener("message",event => {
if(event.data[0] !== "TASK") return;
this.do(event.data[1]); // Send inner array (task)
});
const Monkey = Comlink.wrap(worker);
this.comlink = await new Monkey();
// Wait for comlink to spin up
if(!this.comlink) Promise.reject("Failed to establish Comlink with worker");
this.ready = true;
// Send queued flags when worker is ready
this.queue.sendAllFlags();
return true;
}
// Return a flag array index by name
flagStringToIndex(flag) {
const flags = [
"MANIFEST_LOADED",
"LOOP",
"PLAYING"
];
// Translate string to index
if(typeof flag === "string" || flag < 0) {
flag = flags.indexOf(flag.toUpperCase());
}
// Check that key is in bounds
if(flag < 0 || flags > flags.length - 1) return false;
return flag;
}
async getFlag(flag) {
const key = this.flagStringToIndex(flag);
if(!key) Promise.reject("Invalid flag");
return await this.comlink.flag(key);
}
// Set or queue worker runtime flag
async setFlag(flag,value) {
const key = this.flagStringToIndex(flag);
if(!key) Promise.reject("Invalid flag");
// Set the flag when the worker is ready
if(!this.ready) {
this.queue.flag = [key,value];
return;
}
// Tell worker to update flag by key
const update = await this.comlink.flag(key,value);
if(!update) {
this.queue.flag = [key,value];
}
return update;
}
// Load a Monkeydo manifest by URL or JSON string
async loadManifest(manifest) {
if(!this.ready) await this.init();
let load = null;
// Attempt load string as URL and fetch manifest
try {
const url = new URL(manifest);
// If the URL parsed but fetch failed, this promise will reject
load = this.comlink.fetchManifest(url.toString());
}
// Or attempt to load string as JSON if it's not a URL
catch {
load = this.comlink.loadManifest(manifest);
}
load.then(() => Promise.resolve())
.catch(() => Promise.reject("Failed to load manifest"));
}
async stop() {
return await this.comlink.abort();
}
// Start playback of a loaded manifest
async start() {
const playing = await this.getFlag("playing");
let loop = await this.getFlag("loop");
loop = loop > 0 ? loop : 1; // Play once if loop has no value
if(playing > 0) return;
await this.setFlag("playing",loop);
return await this.comlink.tick();
}
}

View file

@ -1,117 +0,0 @@
// Task scheduler and iterator of Monkeydo manifests
class Monkey {
constructor(manifest) {
this.data = manifest.body;
this.dataLength = this.data.length - 1;
this.flags = {
playing: 0,
stacking: 0, // Subsequent calls to play() will build a queue (jQuery-style)
loop: 0, // Loop n times; <0 = infinite
}
this.i = 0; // Manifest iterator index
this.queue = {
task: null,
next: null
}
Object.seal(this.queue);
}
// Parse task components and send them to main thread
run(task) {
this.i++; // Advance index
postMessage(["TASK",task]);
}
// Interrupt timeout and put monkey to sleep
interrupt() {
clearTimeout(this.queue.task);
clearTimeout(this.queue.next);
this.queue.task = null;
this.queue.next = null;
this.flags.playing = 0;
}
play() {
// Stack playback as loops if flag is set
if(this.flags.playing) {
if(this.flags.stacking) {
this.flags.loop++;
}
return;
}
this.queueNext();
}
// Schedule task for execution by index
queueNext() {
this.flags.playing = 1;
const data = this.data[this.i];
const task = {
wait: data[0],
func: data[1],
args: data.slice(2)
};
// Schedule the current task to run after the specified wait time
this.queue.task = setTimeout(() => this.run(task),task.wait);
// We're out of tasks to schedule..
if(this.i >= this.dataLength) {
this.i = -1;
// Exit if we're out of loops
if(this.flags.loop === 0) {
this.flags.playing = 0;
return false;
}
// Decrement loop iterations if not infinite (negative int)
if(this.flags.loop > 0) {
this.flags.loop = this.flags.loop - 1;
}
}
// Run this function again when the scheduled task will fire
this.queue.next = setTimeout(() => this.queueNext(),task.wait);
}
}
// Global message event handler for this worker
onmessage = (message) => {
const type = message.data[0] ? message.data[0] : message.data;
const data = message.data[1];
switch(type) {
// Attempt to load manfiest provided by initiator thread
case "GIVE_MANIFEST":
try {
this.monkey = new Monkey(data);
postMessage(["RECEIVED_MANIFEST","OK"]);
}
catch(error) {
postMessage(["RECEIVED_MANIFEST",error]);
}
break;
case "SET_PLAYING":
if(data === true) {
this.monkey.play();
return;
}
this.monkey.interrupt();
break;
case "GET_FLAG":
const flag = this.monkey.flags[data];
postMessage(parseInt(flag));
break;
case "SET_FLAG":
this.monkey.flags[data[0]] = data[1];
break;
default: return; // No op
}
}

View file

@ -1,79 +0,0 @@
// Task manager for Monkeydo dedicated workers
export default class MonkeyManager {
constructor(methods) {
// Object of scoped methods for this manifest
this.methods = {};
Object.assign(this.methods,methods);
// Get path of this file
let location = new URL(import.meta.url);
location = location.pathname.replace("MonkeyManager.mjs",""); // Get parent directory
// Spawn a dedicated worker for scheduling events from manifest
this.worker = new Worker(location + "Monkey.js");
this.worker.addEventListener("message",message => this.message(message));
}
// Get a status flag from the worker
async getFlag(flag) {
this.worker.postMessage(["GET_FLAG",flag]);
const response = await new Promise((resolve) => {
this.worker.addEventListener("message",message => resolve(message.data));
});
this.debug("GET_FLAG",flag,response);
return response;
}
// Set a status flag for the worker
async setFlag(flag,value = 0) {
const flagExists = await this.getFlag(flag);
if(flagExists === null) {
this.debug(flagExists);
throw new Error("Flag does not not exist");
}
this.worker.postMessage(["SET_FLAG",[flag,value]]);
}
// Call method from object and pass arguments
runTask(task) {
this.methods[task.func](...task.args);
}
play() {
this.worker.postMessage(["SET_PLAYING",true]);
}
pause() {
this.worker.postMessage(["SET_PLAYING",false]);
}
// Pass manifest to worker and await response
async giveManifest() {
this.worker.postMessage(["GIVE_MANIFEST",this.manifest]);
const status = await new Promise((resolve,reject) => {
const ack = this.worker.addEventListener("message",message => {
if(message.data[0] !== "RECEIVED_MANIFEST") {
return false;
}
if(message.data[1] !== "OK") {
reject(message.data);
}
resolve();
});
this.worker.removeEventListener("message",ack);
});
return status;
}
message(message) {
const type = message.data[0] ? message.data[0] : message.data;
const data = message.data[1];
if(type !== "TASK") {
return false;
}
this.runTask(data);
}
}