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 @@
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.
Monkeydo
from your repo clone or download
+ Monkeydo
as an ES6 module
-import { default } from "./modules/Monkeydo/Monkeydo.mjs";
+import { default as Monkeydo } from "./modules/Monkeydo/Monkeydo.mjs";
const methods = {
myJavaScriptMethod: (foo,bar) => {
@@ -52,29 +52,27 @@ const methods = {
}
{
"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"]
]
}
Monkeydo
with your methods and manifest
+ 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