Lifecycle & Hot Reload
Lifecycle hooks (onInit/onLoad/onUnload) and trigger timing; hot reload mechanism; production
/yeow reload/unload.
Callback System
Unified Callback Messages
All Java→JS callbacks (events, completions, async results) use the same message format:
{"t":"cb", "p":"cb_42", "r":{...}}t— fixed"cb"p— callbackId, generated by_registerCallback(format"cb_N")r— callback data, content varies by scenario
The JS-side _hm function has a single callback handler branch:
if (t === 'cb' || t === 'CALLBACK') {
const e = _cbs[p];
if (e) { e.h(r); if (!e.persistent) delete _cbs[p]; }
}Java registers waits via SyncCallbackHelper; JS responds via $_send('task'):
| Scenario | Java sends | JS response |
|---|---|---|
| Event | {t:"cb", p:cbId, eventId, r:{event data}} | $send('task', {type:'event.complete', params:{eventId, mods}}) |
| Completion | {t:"cb", p:cbId, r:{sender, args}} | $send('task', {type:'command.tabComplete', params:{callbackId, completions}}) |
| Async post | {t:"cb", p:cbId, r:result} | Automatic — callback function handles r |
All response messages go through the Scheduler's Tasks.execute() → SyncCallbackHelper.complete() — no new JNI functions are added.
Event Registration
eventOn() internally calls _registerCallback(fn, {persistent:true}) to register a callback, and sends the generated cbId to the Java side via $_send('task', {type:'event.subscribe', params:{callbackId, eventType}}). Java's EventBridge.subs maintains an eventType → plugin → cbId mapping:
subs = {
"blockBreak": { "myPlugin": "cb_42", "otherPlugin": "cb_43" },
"playerJoin": { "myPlugin": "cb_44" }
}When an event fires, EventBridge looks up the corresponding cbId via subs[eventType][plugin] and sends {t:"cb", p:cbId, r:data} directly. No prefix or encoding is needed. For each plugin, the entry in the callback registry _cbs is called, triggering the user's event handler.
Completer Registration
Inside registerCommand(), the completer also registers a callback via _registerCallback, and the cmdId is sent to the Java-side CommandTasks via the command.register task. The user passes completion results in complete(result).
import { onInit, onLoad, onUnload } from 'yeow-api';
onInit(() => {
// Runs immediately after the JS thread message loop starts
// Can register commands/events, but should not operate on the game (availability is not guaranteed at this point)
});
onLoad(() => {
// Triggered via the message loop after Paper's onEnable
// All game operations are available
});
onUnload(() => {
// Executes when the plugin is disabled or hot-reloaded
// Clean up resources, save data, etc.
});Trigger Timing
| Hook | Trigger timing | Game API available |
|---|---|---|
onInit | After JS context creation, code loaded | ❌ |
onLoad | After Paper's onEnable | ✅ |
onUnload | Plugin disabled or hot-reloaded | ✅ |
Hot Reload
When the dev-server detects a file change:
dev-server → WebSocket hot-reload → Java main thread
│
├─ command.unregisterAll ← Clean up old commands
├─ eventBridge.unsubscribeAll ← Clean up old events
├─ purgePluginServices ← Clean up old services (including native subprocesses)
└─ pt.reload(newCode) ← Blocking wait
│
├─ Phase 1: Send RELOAD → JS queue → wait for graceful exit (up to ~5s)
│ ├─ _hm → onUnload callback
│ ├─ $send('lifecycle', {type:'unloadDone'})
│ └─ running = false → Message loop exits → Old context destroyed (JS thread self-destructs)
│
├─ Phase 2: Timeout without unloadDone → graceful exit failed
│
├─ Phase 3: Forced termination + grace period (~1s)
│ ├─ ctx.interrupt(): one flag drives both the QuickJS interpreter interrupt and the $_send checkpoint (uncatchable)
│ ├─ thread.interrupt(): wake interruptible Java blocking points
│ └─ Thread aborts within the grace period → self-destructs the context and is reclaimed
│
├─ Phase 4: Still alive after the grace period → abandon + quarantine
│ ├─ No further messages/events (postMessage/ping dropped); its $send raises an uncatchable abort
│ └─ Rebuild a fresh entity (new thread/queue/context); the old entity is abandoned temporarily (reclaimed when the stuck call returns)
│
├─ **Never destroy a context across threads** (QuickJS single-thread model; cross-thread destruction = crash)
├─ Clean up old timers / io / http / lingering tasks
├─ Clear the message queue
└─ start() → New thread → New context → New codeHot reload waits synchronously on the main thread, without affecting other Yeow plugins.
Force-kill mechanism (four phases) (normative requirements + example flow: Unload and Forced Termination): ① send
DISABLE/RELOADand wait for the JS side'sonUnload+unloadDone(up to ~5s); ② timeout without exit; ③ enter forced termination with a ~1s grace period — the same flag fromQuickJSContext.interrupt()drives both the QuickJS interpreter interrupt and the$_sendupcall checkpoint (both uncatchable; JScatch/finallycannot intercept), plusThread.interrupt()to wake Java blocking points; ④ still alive after the grace period → abandon and quarantine the engine (no further dispatch; its$sendraises an uncatchable abort; a reload rebuilds a fresh entity). Abandonment is temporary: once the stuck call returns the thread still self-destructs its context and is reclaimed (only a never-returning native operation leaks, needing a restart). Never destroy a context across threads (QuickJS single-thread model; cross-thread destruction = crash).
Production reload / unload
/yeow reload and /yeow unload use the same unload steps as development hot reload (5s forced termination):
/yeow unload <plugin|all> /yeow reload <plugin|all> [path]
│ │
└── unloadPlugin(name) └── unloadPlugin(name) → registerPlugin(original path or new path)
│
├─ command.unregisterAll ← Clean up old commands
├─ eventBridge.unsubscribeAll ← Clean up old events
├─ purgePluginServices ← Clean up old services
├─ pt.stopAndWait() ← DISABLE + 5s wait + forced termination
└─ plugins.remove(name) ← Remove from registry/yeow reload my-plugin— Re-reads the package from disk at the original path (JAR or zip path)/yeow reload my-plugin plugins/Yeow/other.yeow.zip— Loads from a new source (URL also works; temporary, not persisted)/yeow reload all— Reloads all at their original paths/yeow unload <plugin|all>— Unloads (5s forced termination)/yeow uninstall <plugin>— Unloads and moves the corresponding.yeow.zipfromplugins/Yeow/intoplugins/Yeow/.backup/(data directory needs manual cleanup)/yeow load <path|url|name>— Temporary load (URL downloads to cache, not preserved across restarts). When the path is not found, falls back toplugins/Yeow/<path>thenplugins/Yeow/<name>-<version>.yeow.zip/yeow install <url>— Downloads and installs toplugins/Yeow/<name>-<version>.yeow.zip(standard format, auto-scanned on next startup)/yeow update <url>— Scansplugins/Yeow/and matches old packages byyeow.jsonname; moves old package toplugins/Yeow/.backup/, writes new version; if plugin is running, auto-reloads- Duplicate loading of a plugin under the same name (auto-scan / command / template JAR) is rejected with a warning in all scenarios; deploying both a template JAR and a
.yeow.zipfor the same plugin causes this conflict — manually remove one