diff --git a/Monkeydo.mjs b/Monkeydo.mjs index 5319ee8..918319a 100644 --- a/Monkeydo.mjs +++ b/Monkeydo.mjs @@ -1,96 +1,47 @@ -import { default as MonkeyWorker } from "./do/MonkeyManager.mjs"; +import { default as MonkeyMaster } from "./monkey/MonkeyMaster.mjs"; -export default class Monkeydo extends MonkeyWorker { - constructor(methods = {}) { - 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 = { - tasks: null - }; - - if(!window.Worker) { - throw new Error("JavaScript Workers aren't supported by your browser"); +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); + // Execute a task + do(task) { + if(!task[1] in this.methods) return; + const args = task.splice(2); + this.methods[task[1]]?.(...args); + } + + // Loop playback X times or negative number for infinite + async loop(times = 255) { + if(typeof times !== "number") { + times = parseInt(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 Monkeydo manifest + async load(manifest) { + if(typeof manifest === "object") { + manifest = JSON.stringify(manifest); + } + return await this.loadManifest(manifest); + } + + async play(manifest = null) { + if(!this.ready && !manifest) throw new Error("Can not start playback without a manifest"); + if(manifest) { + const load = this.load(manifest) + load.then(() => this.start()); return; } - } - - // Loop playback; -1 or false = infinite - loop(times = -1) { - // Typecast boolean to left shifted integer; - if(typeof times === "boolean") { - times = times ? -1 : 0; - } - times = times < 0 ? -1 : times; - this.setFlag("loop",times); - } - - // Load a Monkeydo manifest from JSON via string or URL - 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"); - } - - // 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")?.includes("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("tasks")) { - this.debug(data); - throw new Error(errorPrefix + "Expected 'header' and 'body' properties in object"); - } - - this.manifest.tasks = data.tasks; - return true; - } - - // Execute tasks from Monkeydo manifest - do() { - // Hand over the loaded manifest to the MonkeyWorker task manager - this.giveManifest().then(() => this.play()); + return await this.start(); } } \ No newline at end of file diff --git a/README.md b/README.md index 47f470a..405499a 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@

Use Monkeydo

Monkeydo comes as an importable ECMAScript 6 module. In this guide we'll import this directly from a ./modules/ folder, but any web-accesible location will work.

    -
  1. Import Monkeydo from your repo clone or download +
  2. Import Monkeydo as an ES6 module
    -import { default } from "./modules/Monkeydo/Monkeydo.mjs";
    +import { default as Monkeydo } from "./modules/Monkeydo/Monkeydo.mjs";
     
  3. -
  4. Define your JS methods +
  5. Define your JS methods in an object
     const methods = {
       myJavaScriptMethod: (foo,bar) => {
    @@ -52,29 +52,27 @@ const methods = {
     }
     
  6. -
  7. Define your tasks in a JSON file (or directly in JavaScript) +
  8. Define your tasks in a JSON manifest (file or JSON-compatible JavaScript)
     {
       "tasks": [
    -    [0,"myJavaSriptMethod","I see trees of","green"],
    -    [300,"myJavaSriptMethod","red","roses too"],
    -    [160,"myJavaSriptMethod","I see them","bloom"],
    -    [1200,"myJavaSriptMethod","for","me and you"]
    +    [0,"myJavaSriptMethod","Just like a","monkey"],
    +    [1200,"myJavaSriptMethod","I've been","dancing"],
    +    [160,"myJavaSriptMethod","my whole","life"]
       ]
     }
     
  9. -
  10. Initialize and run Monkeydo with your methods and manifest +
  11. Initialize and run Monkeydo with your methods and manifest
    -const monkey = new Monkeydo(methods,manifest);
    -monkey.do();
    +const monkey = new Monkeydo(methods);
    +monkey.play(manifest);
     

The example above would be the same as running:

-console.log("I see trees of","green"); // 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
+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
 
diff --git a/do/Monkey.js b/do/Monkey.js deleted file mode 100644 index ccfef0c..0000000 --- a/do/Monkey.js +++ /dev/null @@ -1,134 +0,0 @@ -// Dedicated worker which executes tasks from a Monkeydo manifest - -class Monkey { - constructor(manifest) { - const self = this; - - this.tasks = manifest.tasks; - this.tasksLength = this.tasks.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 - _forwards: 1, // Playback direction - set forwards(forwards = true) { - if(forwards == this._forwards) { - return false; - } - // Toggle playback direction - self.tasks = self.tasks.reverse(); - this._forwards = 1 - this._forwards; - }, - get forwards() { - return this._forwards; - } - } - - this.i = 0; // Manifest iterator index - this.queue = { - task: null, - next: null - } - Object.seal(this.queue); - } - - // Pass task to main thread for execution - run(task) { - postMessage(["TASK",task]); - this.i++; - } - - // 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 >= 0) { - this.flags.loop++; - } - return; - } - this.queueNext(); - } - - // Schedule task for execution by index - queueNext() { - this.flags.playing = 1; - const data = this.tasks[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.tasksLength) { - 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--; - } - } - - // Run this function again when the scheduled task will fire - this.queue.next = setTimeout(() => this.queueNext(),task.wait); - } -} - -// Unspawned monkey target -let monkey = undefined; - -// Event handler for messages received from initiator -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 { - monkey = new Monkey(data); - postMessage(["RECEIVED_MANIFEST","OK"]); - } - catch(error) { - postMessage(["RECEIVED_MANIFEST",error]); - } - break; - - case "SET_PLAYING": - if(data === true) { - monkey.play(); - return; - } - monkey.interrupt(); - break; - - case "GET_FLAG": - const flag = monkey.flags[data]; - postMessage(parseInt(flag)); - break; - - case "SET_FLAG": - monkey.flags[data[0]] = data[1]; - break; - - default: return; // No op - } -} \ No newline at end of file diff --git a/do/MonkeyManager.mjs b/do/MonkeyManager.mjs deleted file mode 100644 index 971e709..0000000 --- a/do/MonkeyManager.mjs +++ /dev/null @@ -1,113 +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)); - - this.reversed = false; - - this.init = { - ready: false, - flags: [] - } - } - - // 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) { - // Player is not initialized, add flag to queue - if(!this.init.ready) { - this.init.flags.push([flag,value]); - return false; - } - - 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]]); - } - - // Get acknowledgement from worker for a transactional operation - async ack(name) { - const status = await new Promise((resolve,reject) => { - const ack = this.worker.addEventListener("message",message => { - if(message.data[0] !== name) { - return false; - } - - if(message.data[1] !== "OK") { - reject(message.data); - } - this.init.ready = true; - resolve(); - }); - this.worker.removeEventListener("message",ack); - }); - return status; - } - - // Pass manifest to worker and await response from worker - async giveManifest() { - this.worker.postMessage(["GIVE_MANIFEST",this.manifest]); - const status = await this.ack("RECEIVED_MANIFEST"); - return status; - } - - initFlags() { - if(this.init.flags.length > 0) { - this.init.flags.forEach(flag => this.setFlag(...flag)); - } - this.init.flags = []; - } - - // Call method from object and pass arguments - run(task) { - this.methods[task.func](...task.args); - } - - play() { - this.worker.postMessage(["SET_PLAYING",true]); - } - - pause() { - this.worker.postMessage(["SET_PLAYING",false]); - } - - // Event handler for messages received from worker - message(message) { - const type = message.data[0] ? message.data[0] : message.data; - const data = message.data[1]; - - switch(type) { - case "TASK": - this.run(data); - break; - - case "DEBUG": - default: - this.debug("MESSAGE_FROM_WORKER",message.data); - break; - } - } -} \ No newline at end of file diff --git a/monkey/Monkey.js b/monkey/Monkey.js new file mode 100644 index 0000000..8d0bd09 --- /dev/null +++ b/monkey/Monkey.js @@ -0,0 +1,85 @@ +// 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 = []; + this.tasksLength = 0; + this.i = 0; + // Runtime task queue + this.queue = { + thisTask: null, + nextTask: null + } + } + + // Task scheduler + next() { + if(this.flags[0] === 0 || this.flags[2] === 0) return this.abort(); + const task = this.tasks[this.i]; + + // Run task after delay + this.queue.thisTask = setTimeout(() => { + // Dispatch task to main thread + postMessage(["TASK",task]); + this.i++; + },task[0]); + + // Loop until flag is 0 or infinite if 255 + if(this.i === this.tasksLength) { + this.i = -1; + if(this.flags[1] < 255) this.flags[2]--; + } + + // Queue the next task + this.queue.nextTask = setTimeout(() => this.next(),task[0]); + } + + abort() { + this.flags[2] = 0; // Playing: false + clearTimeout(this.queue.thisTask); + clearTimeout(this.queue.nextTask); + this.queue.thisTask = null; + this.queue.nextTask = null; + } + + // 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) { + return await new Promise((resolve,reject) => { + if(typeof manifest !== "object") { + try { + manifest = JSON.parse(manifest); + } + catch { + reject("Failed to load manifest"); + } + } + this.tasks = manifest.tasks; + // Store length as property so we don't have to calculate the offset each iteration of next() + this.tasksLength = manifest.tasks.length - 1; + this.flags[0] = 1; // Manifest loaded: true + resolve(); + }); + } +} + +Comlink.expose(Monkey); \ No newline at end of file diff --git a/monkey/MonkeyMaster.mjs b/monkey/MonkeyMaster.mjs new file mode 100644 index 0000000..da3ee63 --- /dev/null +++ b/monkey/MonkeyMaster.mjs @@ -0,0 +1,137 @@ +// 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 initialize proxy and send queued flags + return await new Promise((resolve,reject) => { + if(!this.comlink) reject("Failed to open proxy to worker"); + + this.ready = true; + this.queue.sendAllFlags(); + resolve(); + }); + } + + // 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(); + return await new Promise((resolve,reject) => { + 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(() => resolve()) + .catch(() => 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.next(); + } +} \ No newline at end of file