How to make plugins

A WakaPAC plugin is a small object that WakaPAC calls into at two points in a component's life: when the component is created, and when it's destroyed. In between, the plugin is free to do whatever it wants — wrap a third-party library, talk to a browser API, poll a device — and communicate back to the component the same way everything else in WakaPAC does: by sending messages into msgProc. This page builds a plugin skeleton from scratch, step by step, to walk through every piece of the contract. The examples use placeholder names (MyPlugin, data-myplugin) that you'd replace with whatever your plugin actually wraps.

explanation

The Contract

Registering a plugin is a single call, made once, before any components that need it are created:

wakaPAC.use(MyPlugin, /* options */ {});

wakaPAC.use() requires the library to expose one method:

MyPlugin.createPacPlugin(pac, options)

pac is the wakaPAC object itself — plugins receive it as an argument rather than referencing the global, so a plugin file can be loaded and tested independently of how the host page names it. options is whatever second argument was passed to wakaPAC.use(), meant for plugin-wide configuration such as an API key or a CDN URL. createPacPlugin runs once, synchronously, at registration time, and must return a descriptor object with up to two lifecycle hooks:

{
    onComponentCreated(abstraction, pacId, config) { /* ... */ },
    onComponentDestroyed(pacId)                    { /* ... */ }
}

Both hooks are optional. onComponentCreated fires once for every WakaPAC component on the page, immediately after that component finishes initializing — abstraction is the component's reactive model object (the same this your msgProc and templates see), pacId is its data-pac-id, and config is the third argument passed to that component's own wakaPAC() call. onComponentDestroyed fires once when a component is torn down, and is passed only the pacId — by that point the container element may already be gone, so a plugin holding onto any per-component state needs to have kept its own record of what to clean up.

Because onComponentCreated fires for every component, not just ones meant for your plugin, the first thing the hook must do is decide whether it applies at all.

Step 1 — Deciding When to Activate

Activation criteria are entirely up to the plugin. Some plugins only make sense for a specific tag; others are looser and only care that the element carries a specific attribute, regardless of tag. Check for whatever marks a component as belonging to your plugin, and return immediately for anything else:

onComponentCreated(abstraction, pacId, config) {
    const container = pac.getContainerByPacId(pacId);

    if (!container || !container.hasAttribute('data-myplugin')) {
        return;
    }

    // ... activate for this component
}

pac.getContainerByPacId(pacId) is how a plugin resolves the actual DOM element from the id WakaPAC handed it. It's the same lookup wakaPAC.sendMessage() and wakaPAC.postMessage() use internally, so it's always safe to call from inside a hook.

Step 2 — Per-Instance Configuration

Plugin-wide defaults come from the options passed to wakaPAC.use(); per-component overrides come from the config object passed as the third argument to that component's wakaPAC() call, conventionally namespaced under a key matching the plugin's own name so it doesn't collide with the framework's own config keys:

<script>
    wakaPAC.use(MyPlugin, {
        someOption: false,
        timeout:    10000
    });

    wakaPAC('#widget', {
        msgProc(event) { }
    }, {
        myplugin: { someOption: true }  // overrides the plugin default for this instance only
    });
</script>

<div data-pac-id="widget" data-myplugin></div>

Inside createPacPlugin, merge the plugin-wide defaults once, then merge per-instance overrides on top of that inside onComponentCreated:

createPacPlugin(pac, options = {}) {
    const defaults = {
        someOption: options.someOption ?? false,
        timeout:    options.timeout ?? 10000
    };

    return {
        onComponentCreated(abstraction, pacId, config) {
            const container = pac.getContainerByPacId(pacId);

            if (!container || !container.hasAttribute('data-myplugin')) {
                return;
            }

            const instanceConfig = { ...defaults, ...(config.myplugin ?? {}) };

            // ... use instanceConfig below
        }
    };
}

Step 3 — Wrapping the External API and Tracking State

Most plugins need somewhere to keep per-component state — a library instance, a subscription handle, a timer id — that isn't part of the reactive model. A Map keyed by pacId, declared in the plugin's module closure, is enough for almost any plugin:

const _registry = new Map();

// inside onComponentCreated, after the activation and config-merging above:
const handle = externalLibrary.subscribe(
    (result) => handleUpdate(pacId, result),
    (error)  => handleError(pacId, error),
    instanceConfig
);

_registry.set(pacId, { handle });

Keeping this state in a closure rather than on the abstraction object matters: the abstraction is a reactive proxy, and anything written to it is diffed and can trigger DOM updates or get serialized by other tooling. A subscription handle or library instance has no business going through that path — see Step 4 for what should live on the abstraction.

Step 4 — Talking Back: Messages and Reactive Properties

A plugin has two ways to hand data back to the component, and they serve different purposes.

Sending messages

pac.sendMessage() and pac.postMessage() both deliver a message into the component's msgProc, in the same { message, wParam, lParam, detail } shape as every built-in WakaPAC message. The difference is timing: sendMessage dispatches synchronously, in the current call stack; postMessage defers delivery to the next tick via setTimeout. Use postMessage when you're inside a callback fired by the same event you're about to react to and want to avoid re-entrancy; use sendMessage everywhere else.

Custom message ids must not collide with WakaPAC's own constants or another plugin's. The convention is to derive them from pac.MSG_PLUGIN (0x2000), the block WakaPAC reserves specifically for this:

const MSG_UPDATED = pac.MSG_PLUGIN + 0x100;
const MSG_ERROR    = pac.MSG_PLUGIN + 0x101;

function handleUpdate(pacId, result) {
    pac.sendMessage(pacId, MSG_UPDATED, 0, 0, { value: result.value });
}

Attach the constants to the plugin object itself so consuming code can reference them by name instead of hardcoding the offset:

MyPlugin.MSG_UPDATED = MSG_UPDATED;
MyPlugin.MSG_ERROR    = MSG_ERROR;

Setting reactive properties directly

For values a component mostly wants to display rather than react to with logic, writing straight to the abstraction is simpler than requiring a msgProc case for every update — this is how WakaPAC's own built-in state (scroll position, online/offline status, DPR) reaches templates:

function handleUpdate(pacId, result, abstraction) {
    abstraction.value = result.value;

    pac.sendMessage(pacId, MSG_UPDATED, 0, 0, { value: result.value });
}

This lets the host page bind directly in markup without writing any JavaScript at all:

<div data-pac-id="widget" data-myplugin>
    Current value: {{ value }}
</div>

Sending a message and setting a property aren't mutually exclusive — send a message when the component needs to react (log it, save it, validate it), and set a property when the component just needs to show it. Document both together in a plugin's header comment — the reactive properties it maintains and the messages it dispatches — so consumers know they have a choice.

Note the abstraction parameter being threaded through in the snippet above — abstraction is only handed to onComponentCreated itself, not to callbacks registered inside it, so callbacks that need to touch it later have to capture it in their closure (alongside pacId) the same way _registry does.

Step 5 — Exposing a Public API

Components react to messages, but the rest of the application often needs to reach into a plugin imperatively — force a refresh, stop a subscription, read a current value on demand. The simplest approach is to put plain methods directly on the plugin object, reading from the same registry onComponentCreated populated:

window.MyPlugin = {
    createPacPlugin(pac, options = {}) { /* ... as above ... */ },

    getValue(pacId) {
        return _registry.get(pacId)?.lastValue;
    },

    stop(pacId) {
        const entry = _registry.get(pacId);

        if (!entry) {
            return;
        }

        entry.handle.unsubscribe();
        entry.handle = null;
    }
};

// called from anywhere on the page:
MyPlugin.stop('widget');

Methods that operate on a specific component should always take pacId as their first argument, mirroring every other targeted call in WakaPAC (sendMessage, postMessage, getContainerByPacId), and should no-op quietly for an unregistered pacId rather than throwing.

Step 6 — Cleanup

Every resource acquired in onComponentCreated needs a matching release in onComponentDestroyed — a live subscription left running after its component is gone is a leak that keeps firing callbacks into a registry entry nothing will ever read again:

onComponentDestroyed(pacId) {
    const entry = _registry.get(pacId);

    if (!entry) {
        return;
    }

    if (entry.handle) {
        entry.handle.unsubscribe();
    }

    _registry.delete(pacId);
}

If a plugin defers any part of its own setup — waiting on a script tag to load, an async handshake, anything that might still be pending when the component is destroyed — onComponentDestroyed also needs to cancel that pending work, not just release what's already been acquired. A plugin that queues components while a shared external script loads, for instance, needs to pull a destroyed component out of that queue too, so it's never initialized after the fact.

Putting It Together

(function () {
    "use strict";

    const _registry = new Map();

    window.MyPlugin = {
        createPacPlugin(pac, options = {}) {
            const defaults = {
                someOption: options.someOption ?? false,
                timeout:    options.timeout ?? 10000
            };

            const MSG_UPDATED = pac.MSG_PLUGIN + 0x100;
            const MSG_ERROR    = pac.MSG_PLUGIN + 0x101;

            this.MSG_UPDATED = MSG_UPDATED;
            this.MSG_ERROR    = MSG_ERROR;

            return {
                onComponentCreated(abstraction, pacId, config) {
                    const container = pac.getContainerByPacId(pacId);

                    if (!container || !container.hasAttribute('data-myplugin')) {
                        return;
                    }

                    const instanceConfig = { ...defaults, ...(config.myplugin ?? {}) };

                    const handle = externalLibrary.subscribe(
                        (result) => {
                            abstraction.value = result.value;

                            pac.sendMessage(pacId, MSG_UPDATED, 0, 0, { value: result.value });
                        },
                        (error) => {
                            pac.sendMessage(pacId, MSG_ERROR, 0, 0, { message: error.message });
                        },
                        instanceConfig
                    );

                    _registry.set(pacId, { handle });
                },

                onComponentDestroyed(pacId) {
                    const entry = _registry.get(pacId);

                    if (!entry) {
                        return;
                    }

                    if (entry.handle) {
                        entry.handle.unsubscribe();
                    }

                    _registry.delete(pacId);
                }
            };
        },

        stop(pacId) {
            const entry = _registry.get(pacId);

            if (!entry) {
                return;
            }

            entry.handle.unsubscribe();
            entry.handle = null;
        }
    };

})();
<script src="wakapac.js"></script>
<script src="myplugin.js"></script>

<script>
    wakaPAC.use(MyPlugin, { someOption: true });

    wakaPAC('#widget', {
        msgProc(event) {
            switch (event.message) {
                case MyPlugin.MSG_UPDATED:
                    console.log('Value updated:', event.detail.value);
                    break;

                case MyPlugin.MSG_ERROR:
                    console.error('Plugin error:', event.detail.message);
                    break;
            }
        }
    });
</script>

<div data-pac-id="widget" data-myplugin>
    Current value: {{ value }}
</div>

Messages

The two messages this example plugin dispatches, for reference — this is the shape a plugin's own documentation should take.

Updated (MSG_UPDATED)

ParameterTypeDescription
wParamnumberAlways 0.
lParamnumberAlways 0.
detail.valueanyThe updated value.

Error (MSG_ERROR)

ParameterTypeDescription
wParamnumberAlways 0.
lParamnumberAlways 0.
detail.messagestringHuman-readable error description.

API

MyPlugin.stop(pacId)

Stops the active subscription for a component. Silently ignored if the pacId is not registered.

ParameterTypeDescription
pacIdstringThe data-pac-id of the target component.
Returns void

Best Practices

  • Check activation first, unconditionally: onComponentCreated runs for every component on the page, not just ones meant for your plugin. Resolve the container and check its tag or attributes before doing anything else, and return early otherwise.
  • Guard against a missing container: pac.getContainerByPacId() can return null — a component can be destroyed between when a hook is scheduled and when it runs, especially around any asynchronous setup. Check before touching it.
  • Namespace custom messages off pac.MSG_PLUGIN: pick an offset block for your plugin and stay inside it. Attach the resolved constants to your plugin object so callers reference YourPlugin.MSG_SOMETHING rather than a magic number.
  • Don't put non-reactive state on the abstraction: library instances, subscription handles, and timer ids belong in a closure-scoped registry keyed by pacId, not as properties on the reactive abstraction. Reserve abstraction properties for values templates actually bind to.
  • sendMessage vs postMessage: use sendMessage by default. Reach for postMessage only when delivering synchronously from inside a callback could cause re-entrant handling of the event that triggered it.
  • Merge config in two layers: plugin-wide defaults from wakaPAC.use(Plugin, options), overridden per-instance by a namespaced key in the third argument to that component's wakaPAC() call. Strip undefined values before handing a merged config object to a third-party library that doesn't expect them.
  • Always implement onComponentDestroyed if you implement onComponentCreated: anything acquired — event listeners, subscriptions, timers, injected DOM — needs an explicit release. If setup can still be pending when a component is destroyed, cancel it there too, not just release what already succeeded.
  • Make targeted API methods take pacId first and no-op quietly: match the calling convention of sendMessage, postMessage, and getContainerByPacId, and don't throw for a pacId your plugin never registered.
  • One registration per plugin: wakaPAC.use() silently ignores a second registration of the same library object, so createPacPlugin is a safe, one-time place to do expensive setup (injecting a script tag, opening a shared connection) that every component instance should share rather than repeat.