Skip to content

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:

json
{"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:

js
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'):

ScenarioJava sendsJS 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).

js
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

HookTrigger timingGame API available
onInitAfter JS context creation, code loaded
onLoadAfter Paper's onEnable
onUnloadPlugin 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 code

Hot 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/RELOAD and wait for the JS side's onUnload + unloadDone (up to ~5s); ② timeout without exit; ③ enter forced termination with a ~1s grace period — the same flag from QuickJSContext.interrupt() drives both the QuickJS interpreter interrupt and the $_send upcall checkpoint (both uncatchable; JS catch/finally cannot intercept), plus Thread.interrupt() to wake Java blocking points; ④ still alive after the grace period → abandon and quarantine the engine (no further dispatch; its $send raises 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.zip from plugins/Yeow/ into plugins/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 to plugins/Yeow/<path> then plugins/Yeow/<name>-<version>.yeow.zip
  • /yeow install <url> — Downloads and installs to plugins/Yeow/<name>-<version>.yeow.zip (standard format, auto-scanned on next startup)
  • /yeow update <url> — Scans plugins/Yeow/ and matches old packages by yeow.json name; moves old package to plugins/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.zip for the same plugin causes this conflict — manually remove one