Using Classes

Learn how to use class wrappers for cleaner, object-oriented plugin APIs in JavaScript.

Classes in Plugify provide an object-oriented way to work with plugin resources, offering lifecycle management and cleaner APIs. Instead of manually managing handles and calling functions with explicit pointers, you can use intuitive class-based interfaces.

Why Use Classes?

Working with handle-based APIs in the traditional C-style can be verbose and error-prone:

Without Classes (C-style)
import * as s2sdk from ':s2sdk';

// Create a KeyValues handle manually
const kv_handle = s2sdk.Kv1Create("MyConfig");

// Set properties using the handle
s2sdk.Kv1SetName(kv_handle, "ServerSettings");

// Find a subkey
const subkey_handle = s2sdk.Kv1FindKey(kv_handle, "Players");

// Easy to forget cleanup!
s2sdk.Kv1Destroy(subkey_handle);
s2sdk.Kv1Destroy(kv_handle);

With classes, the same code becomes much cleaner:

With Classes (OOP-style)
import * as s2sdk from ':s2sdk';

// Create using a class constructor
const kv = new s2sdk.KeyValues("MyConfig");

// Use intuitive methods
kv.SetName("ServerSettings");

// Find a subkey - returns a KeyValues instance
const subkey = kv.FindKey("Players");

// Must manually clean up!
subkey.close();
kv.close();

Benefits:

  • Cleaner syntax - Methods instead of functions with explicit handles
  • Type safety - Better IDE autocomplete with TypeScript definitions
  • Less error-prone - Harder to mix up handles
  • Automatic validation - Methods check handle validity before calling
  • JavaScript-idiomatic API - Feels natural to JavaScript developers

Important Limitation:

How Classes Work

When a plugin defines classes in its manifest, Plugify automatically generates JavaScript class wrappers that:

  1. Wrap the underlying handle - Store the raw pointer internally
  2. Bind methods - Convert function calls to method calls with automatic handle passing
  3. Manage lifecycle - Register with FinalizationRegistry for eventual cleanup
  4. Provide utility methods - get(), release(), valid(), close(), reset() for handle management
  5. Validate handles - Automatically check handle validity before method calls
  6. Provide type hints - Generate .d.ts TypeScript definition files for IDE support

Defining Classes in Your Manifest

To create classes for your plugin, add a classes section to your manifest:

plugin.pplugin
{
  "name": "example_plugin",
  "version": "1.0.0",
  "language": "cpp",
  "methods": [
    {
      "name": "Kv1Create",
      "funcName": "Kv1Create",
      "paramTypes": [
        { "name": "setName", "type": "string" }
      ],
      "retType": { "type": "ptr64" }
    },
    {
      "name": "Kv1Destroy",
      "funcName": "Kv1Destroy",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" }
      ],
      "retType": { "type": "void" }
    },
    {
      "name": "Kv1GetName",
      "funcName": "Kv1GetName",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" }
      ],
      "retType": { "type": "string" }
    },
    {
      "name": "Kv1SetName",
      "funcName": "Kv1SetName",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" },
        { "name": "name", "type": "string" }
      ],
      "retType": { "type": "void" }
    }
  ],
  "classes": [
    {
      "name": "KeyValues",
      "description": "Wrapper for KeyValues handle",
      "handleType": "ptr64",
      "invalidValue": "0",
      "constructors": ["Kv1Create"],
      "destructor": "Kv1Destroy",
      "bindings": [
        {
          "name": "GetName",
          "method": "Kv1GetName",
          "bindSelf": true
        },
        {
          "name": "SetName",
          "method": "Kv1SetName",
          "bindSelf": true
        }
      ]
    }
  ]
}

Key fields explained:

  • name: The JavaScript class name (PascalCase recommended)
  • handleType: The type of the underlying handle (usually ptr64 or ptr32)
  • invalidValue: What value represents an invalid handle (usually "0" or "-1")
  • constructors: Array of method names that create instances
  • destructor: Method name that cleans up resources (optional)
  • bindings: Array of methods available on the class
    • bindSelf: If true, automatically passes the handle as the first parameter

Generated JavaScript Code

When you define classes in your manifest, Plugify generates JavaScript class wrappers with several built-in methods:

Generated Class (Conceptual)
class KeyValues {
    constructor(...args) {
        // Initialize to invalid state
        this._handle = 0;  // invalidValue
        this._owned = Ownership.BORROWED;

        // Direct handle construction
        // Pattern: new KeyValues(handle_value, Ownership.OWNED)
        if (args.length >= 2 && Ownership.is(args[1])) {
            this._handle = args[0];
            this._owned = args[1];

            // Register for finalization if owned
            if (this._owned === Ownership.OWNED) {
                finalizerRegistry.register(this, {
                    handle: this._handle,
                    owned: this._owned,
                    destructor: _plugin.Kv1Destroy,
                    invalidValue: 0
                }, this);
            }
            return;
        }

        // Constructor call mode
        try {
            this._handle = _plugin.Kv1Create(...args);
            this._owned = Ownership.OWNED;

            // Register for finalization
            finalizerRegistry.register(this, {
                handle: this._handle,
                owned: this._owned,
                destructor: _plugin.Kv1Destroy,
                invalidValue: 0
            }, this);
        } catch (e) {
            throw e;
        }
    }

    close() {
        if (this._handle !== 0 && this._owned === Ownership.OWNED) {
            _plugin.Kv1Destroy(this._handle);
            // Unregister from finalizer
            finalizerRegistry.unregister(this);
        }
        this._handle = 0;
        this._owned = Ownership.BORROWED;
    }

    // Utility methods
    get() {
        return this._handle;
    }

    release() {
        if (this._owned === Ownership.OWNED) {
            finalizerRegistry.unregister(this);
        }
        const tmp = this._handle;
        this._handle = 0;
        this._owned = Ownership.BORROWED;
        return tmp;
    }

    reset() {
        this.close();
    }

    valid() {
        return this._handle !== 0;
    }

    // Bound methods (with automatic handle validation)
    GetName() {
        if (this._handle === 0) {
            throw new Error("KeyValues handle is closed or not initialized");
        }
        return _plugin.Kv1GetName(this._handle);
    }

    SetName(name) {
        if (this._handle === 0) {
            throw new Error("KeyValues handle is closed or not initialized");
        }
        _plugin.Kv1SetName(this._handle, name);
    }

    FindKey(keyName) {
        if (this._handle === 0) {
            throw new Error("KeyValues handle is closed or not initialized");
        }
        const result = _plugin.Kv1FindKey(this._handle, keyName);
        // Automatically wraps return value based on retAlias
        if (result !== 0) {
            return new KeyValues(result, Ownership.OWNED);
        }
        return null;
    }

    AddSubKey(subKey) {
        if (this._handle === 0) {
            throw new Error("KeyValues handle is closed or not initialized");
        }
        // Automatically releases ownership from subKey parameter
        const handle = (typeof subKey.release === 'function') ? subKey.release() : subKey;
        _plugin.Kv1AddSubKey(this._handle, handle);
    }
}

A corresponding .d.ts TypeScript definition file is also generated for IDE support:

plugin.d.ts
import { Ownership } from 'plugify';

export class KeyValues {
    constructor(setName: string);
    constructor(handle: number, ownership: Ownership);

    close(): void;
    get(): number;
    release(): number;
    reset(): void;
    valid(): boolean;

    GetName(): string;
    SetName(name: string): void;
    FindKey(keyName: string): KeyValues | null;
    AddSubKey(subKey: KeyValues): void;
}

Built-in Utility Methods

Every generated class includes several utility methods for handle management:

valid() - Check Handle Validity

Returns true if the handle is valid (not equal to invalidValue):

Using valid()
import * as s2sdk from ':s2sdk';

const kv = new s2sdk.KeyValues("Config");

if (kv.valid()) {
    kv.SetName("ServerConfig");
    console.log("Handle is valid");
} else {
    console.log("Handle is invalid");
}

// After release, handle becomes invalid
const handle = kv.release();
console.log(kv.valid());  // false

get() - Access Raw Handle

Returns the underlying handle value. Use this when you need to pass the raw handle to C-style functions:

Using get()
import * as s2sdk from ':s2sdk';

const kv = new s2sdk.KeyValues("Config");

// Get the raw handle
const raw_handle = kv.get();
console.log(`Handle value: ${raw_handle}`);

// Pass to a function expecting a raw handle
some_c_function(kv.get());

release() - Transfer Ownership

Releases ownership of the handle and returns it. After calling release(), the class instance becomes invalid and will not call the destructor:

Using release()
import * as s2sdk from ':s2sdk';

function create_and_release() {
    const kv = new s2sdk.KeyValues("Config");
    kv.SetName("ServerConfig");

    // Transfer ownership out
    const handle = kv.release();

    // kv is now invalid, won't clean up
    console.log(kv.valid());  // false

    return handle;
}

// We now own the handle and must clean it up manually
const raw_handle = create_and_release();
// ... use raw_handle ...
s2sdk.Kv1Destroy(raw_handle);  // Manual cleanup required!

Use release() when you need to:

  • Transfer ownership to another system
  • Store the handle in a long-lived data structure
  • Interface with C-style code that takes ownership

close() - Manual Cleanup

Explicitly closes the handle and calls the destructor if the object owns it:

Using close()
import * as s2sdk from ':s2sdk';

const kv = new s2sdk.KeyValues("Config");
kv.SetName("ServerConfig");

// Explicitly close the handle now
kv.close();

// Handle is now invalid
console.log(kv.valid());  // false

// Methods will throw error
try {
    kv.SetName("Test");
} catch (e) {
    console.error(`Error: ${e.message}`);  // "KeyValues handle is closed"
}

The close() method is:

  • Idempotent - Safe to call multiple times
  • Unregisters from finalizer - Prevents double-cleanup
  • Ownership-aware - Only calls destructor if object owns the handle
  • Required for immediate cleanup - JavaScript has no deterministic destructors

reset() - Alias for Close

Convenience method that calls close():

Using reset()
import * as s2sdk from ':s2sdk';

const kv = new s2sdk.KeyValues("Config");
kv.SetName("ServerConfig");

// Reset is equivalent to close
kv.reset();

console.log(kv.valid());  // false

Resource Management

JavaScript classes generated by Plugify require manual resource management:

Manual Cleanup (Required)

JavaScript has no deterministic resource cleanup mechanism. You must explicitly call close():

Manual Cleanup
import * as s2sdk from ':s2sdk';

function processConfig() {
    const kv = new s2sdk.KeyValues("Config");
    kv.SetName("ServerConfig");

    try {
        // ... use kv ...
    } finally {
        // CRITICAL: Always clean up in finally block
        kv.close();
    }
}

processConfig();

Best practice: Always use try-finally blocks to ensure cleanup:

Try-Finally Pattern
import * as s2sdk from ':s2sdk';

function processConfig() {
    const kv = new s2sdk.KeyValues("Config");

    try {
        kv.SetName("ServerConfig");
        kv.SetString("hostname", "My Server");
        // ... use kv ...
    } finally {
        kv.close();  // Guaranteed cleanup even if exceptions occur
    }
}

FinalizationRegistry (Non-Deterministic Fallback)

Plugify uses JavaScript's FinalizationRegistry internally to cleanup resources when objects are garbage collected. However, this is non-deterministic and should not be relied upon:

Finalization Fallback
import * as s2sdk from ':s2sdk';

function processConfig() {
    const kv = new s2sdk.KeyValues("Config");
    kv.SetName("ServerConfig");
    // ... use kv ...
    // NO close() call - relies on finalization (BAD!)
}

processConfig();
// kv will eventually be cleaned up by FinalizationRegistry
// but timing is unpredictable - DO NOT RELY ON THIS!

No Destructor Case

If a class doesn't have a destructor defined in the manifest, it acts as a simple wrapper without automatic cleanup:

Wrapper Without Destructor
import * as s2sdk from ':s2sdk';

// Class with no destructor - just a convenience wrapper
const wrapper = new s2sdk.SomeWrapper();

// Still has utility methods
if (wrapper.valid()) {
    const handle = wrapper.get();
}

// No automatic cleanup - handle persists
// Useful for stateless wrappers or global resources

Working with Ownership

Some methods transfer ownership of resources. The manifest specifies this with the owner field in paramAliases and retAlias.

Taking Ownership (Method Parameters)

When a method takes ownership of a resource, you should transfer it and not use it afterward:

Ownership Transfer
import * as s2sdk from ':s2sdk';

// Create parent and child
const parent = new s2sdk.KeyValues("Parent");
const child = new s2sdk.KeyValues("Child");

try {
    // AddSubKey takes ownership of child
    parent.AddSubKey(child);

    // child is now owned by parent
    // child.valid() may still be true, but don't use it!
    // parent will handle cleanup
} finally {
    parent.close();
}

In the manifest, this is defined as:

Manifest Ownership
{
  "name": "AddSubKey",
  "method": "Kv1AddSubKey",
  "bindSelf": true,
  "paramAliases": [
    {
      "name": "subKey",
      "owner": true  // This parameter takes ownership
    }
  ]
}

Best practice: After transferring ownership, avoid using the object:

Proper Ownership Handling
import * as s2sdk from ':s2sdk';

const parent = new s2sdk.KeyValues("Parent");
const child = new s2sdk.KeyValues("Child");

try {
    // Transfer ownership
    parent.AddSubKey(child);

    // Release our reference to prevent accidental use
    child.release();  // Now child.valid() === false

    // Only use through parent
    const found = parent.FindKey("Child");
    if (found) {
        found.close();
    }
} finally {
    parent.close();
}

Returning Ownership (Return Values)

When a method returns a new resource with ownership:

Return Ownership
import * as s2sdk from ':s2sdk';

const parent = new s2sdk.KeyValues("Parent");

try {
    // FindKey returns a NEW KeyValues that we own
    const child = parent.FindKey("Settings");

    if (child && child.valid()) {
        try {
            child.SetName("UpdatedSettings");
            // We're responsible for child's lifecycle
        } finally {
            child.close();  // Must clean up returned object
        }
    }
} finally {
    parent.close();
}

In the manifest:

Manifest Return Ownership
{
  "name": "FindKey",
  "method": "Kv1FindKey",
  "bindSelf": true,
  "retAlias": {
    "name": "KeyValues",
    "owner": true  // Caller owns the returned object
  }
}

Non-Owning References

When owner: false, the method returns a reference without transferring ownership:

Non-Owning Reference
import * as s2sdk from ':s2sdk';

const parent = new s2sdk.KeyValues("Parent");

try {
    // GetFirstSubKey returns a reference, parent still owns it
    const child_ref = parent.GetFirstSubKey();

    if (child_ref && child_ref.valid()) {
        // Use the reference, but DON'T close it
        const name = child_ref.GetName();
        // Don't call child_ref.close() or child_ref.release()
        // child_ref will be cleaned up by parent
    }
} finally {
    parent.close();
}

Plugin Lifecycle and Global Objects

When using classes in plugins, you must be careful about object cleanup during plugin unload. If objects are still alive when your plugin is unloaded, their finalizers may be called after the plugin's code is no longer in memory, leading to undefined behavior or crashes.

Managing Global Objects

If you store class instances in global variables or module-level objects, you must explicitly clean them up in your plugin's pluginEnd() function:

plugin.mjs
import { Plugin } from 'plugify';
import * as s2sdk from ':s2sdk';

// Global object - dangerous if not cleaned up!
let g_config = null;

export class MyPlugin extends Plugin {
    pluginStart() {
        // Create global configuration
        g_config = new s2sdk.KeyValues("GlobalConfig");
        g_config.SetName("ServerSettings");
        console.log("Plugin started with global config");
    }

    pluginEnd() {
        // CRITICAL: Clean up global objects before plugin unload
        if (g_config) {
            g_config.close();
            g_config = null;
        }
        console.log("Plugin ended, global config cleaned up");
    }
}

Safe Patterns

✅ Safe: Try-finally with cleanup

Safe Pattern
export class MyPlugin extends Plugin {
    onCommand(args) {
        const kv = new s2sdk.KeyValues("TempConfig");

        try {
            kv.SetName("CommandConfig");
            // ... use kv ...
        } finally {
            kv.close();  // Guaranteed cleanup - safe!
        }
    }
}

✅ Safe: Instance variable with cleanup

Safe Pattern
export class MyPlugin extends Plugin {
    pluginStart() {
        this.config = new s2sdk.KeyValues("PluginConfig");
        this.config.SetName("Settings");
    }

    pluginEnd() {
        // Clean up instance variables
        if (this.config) {
            this.config.close();
            this.config = null;
        }
    }
}

❌ Unsafe: Global without cleanup

Unsafe Pattern
import * as s2sdk from ':s2sdk';

// DANGEROUS: Global object
const g_config = new s2sdk.KeyValues("GlobalConfig");

export class MyPlugin extends Plugin {
    pluginStart() {
        g_config.SetName("ServerSettings");
    }

    pluginEnd() {
        // MISSING: No cleanup!
        // g_config finalizer will run after plugin unload - CRASH!
    }
}

❌ Unsafe: Module-level cache without cleanup

Unsafe Pattern
import * as s2sdk from ':s2sdk';

// DANGEROUS: Module-level cache
const kv_cache = new Map();

export class MyPlugin extends Plugin {
    cacheConfig(name) {
        kv_cache.set(name, new s2sdk.KeyValues(name));
    }

    pluginEnd() {
        // MISSING: No cache cleanup!
        // Cached objects will finalize after plugin unload - CRASH!
    }
}

Cleanup Checklist

Before your plugin unloads (pluginEnd()), ensure:

  1. ✅ All global class instances are explicitly closed or set to null
  2. ✅ All module-level collections (Map, Set, Array) containing class instances are cleared
  3. ✅ All class instance variables are explicitly cleaned up
  4. ✅ No references to class instances remain in any long-lived data structures

Complete Example: Configuration System

Here's a complete example showing how to use classes for a configuration system:

config_manager.mjs
import * as s2sdk from ':s2sdk';

export class ConfigManager {
    constructor(config_name) {
        this.root = new s2sdk.KeyValues(config_name);

        if (!this.root.valid()) {
            throw new Error(`Failed to create config: ${config_name}`);
        }
    }

    close() {
        if (this.root) {
            this.root.close();
            this.root = null;
        }
    }

    createSection(section_name) {
        const section = new s2sdk.KeyValues(section_name);

        if (!section.valid()) {
            return false;
        }

        try {
            this.root.AddSubKey(section);
            return true;
        } catch (e) {
            section.close();
            return false;
        }
    }

    getSection(section_name) {
        const section = this.root.FindKey(section_name);

        if (section && section.valid()) {
            return section;
        }
        return null;
    }

    setValue(section_name, key, value) {
        const section = this.getSection(section_name);

        if (section) {
            try {
                section.SetString(key, value);
                return true;
            } finally {
                section.close();
            }
        }
        return false;
    }

    getValue(section_name, key, defaultValue = "") {
        const section = this.getSection(section_name);

        if (section) {
            try {
                return section.GetString(key, defaultValue);
            } finally {
                section.close();
            }
        }
        return defaultValue;
    }

    save(filename) {
        if (!this.root.valid()) {
            return false;
        }
        return this.root.SaveToFile(filename);
    }

    load(filename) {
        if (!this.root.valid()) {
            return false;
        }
        return this.root.LoadFromFile(filename);
    }
}

// Usage with try-finally
const config = new ConfigManager("ServerConfig");
try {
    config.createSection("Server");
    config.setValue("Server", "hostname", "My Server");
    config.setValue("Server", "maxplayers", "32");

    config.createSection("Game");
    config.setValue("Game", "mode", "competitive");

    config.save("config.kv");
} finally {
    config.close();  // Must explicitly cleanup!
}

Best Practices

  1. Always use try-finally - Wrap resource usage in try-finally blocks for guaranteed cleanup
  2. Call close() explicitly - JavaScript has no deterministic destructors, manual cleanup is required
  3. Clean up global objects in pluginEnd() - Critical: Explicitly clean up all global or module-level class instances before plugin unload to avoid crashes
  4. Don't rely on FinalizationRegistry - Finalization timing is unpredictable, always use close()
  5. Check valid() for safety - Especially after operations that might fail
  6. Respect ownership - Don't use objects after transferring ownership
  7. Use release() sparingly - Only when you need manual control
  8. Avoid mixing styles - Prefer class API over raw C-style functions
  9. Handle errors - Be prepared to catch errors from methods called on closed handles
  10. Check returned objects - Methods might return null on failure
  11. Use TypeScript - Type definitions help catch errors at development time

When NOT to Use Classes

Classes are designed for resources that need lifecycle management. Don't define classes for:

  • Stateless utility functions - Simple functions that don't manage resources
  • Functions that return primitive values - No need to wrap simple getters
  • One-off operations - Operations that don't maintain state
  • Global singletons - Resources that live for the entire program lifetime

For these cases, continue using the regular function-based API.

Troubleshooting

"Failed to create X" Error

If a constructor throws an error, the underlying create function returned an invalid handle:

Handle Constructor Failure
import * as s2sdk from ':s2sdk';

try {
    const kv = new s2sdk.KeyValues("Config");
    // ... use kv ...
    kv.close();
} catch (e) {
    console.error(`Failed to create KeyValues: ${e.message}`);
    // Handle error - maybe retry or use default config
}

Using Closed or Released Objects

Attempting to use an object after calling close() or release() will throw an error:

Using Closed Handle
import * as s2sdk from ':s2sdk';

const kv = new s2sdk.KeyValues("Config");
kv.close();

// This throws an error
try {
    kv.SetName("Test");
} catch (e) {
    console.error(e.message);  // "KeyValues handle is closed"
}

// Check valid() first to avoid exceptions
if (kv.valid()) {
    kv.SetName("Test");
} else {
    console.log("Handle is closed!");
}

All bound methods automatically validate the handle before calling the underlying C function:

Automatic Validation
import * as s2sdk from ':s2sdk';

const kv = new s2sdk.KeyValues("Config");
const handle = kv.release();

// Any method call will fail
try {
    const name = kv.GetName();
} catch (e) {
    console.error(e.message);  // "KeyValues handle is closed"
}

Dangling References

Be careful with non-owning references when the owner is destroyed:

Dangling Reference Problem
import * as s2sdk from ':s2sdk';

function getChildRef() {
    const parent = new s2sdk.KeyValues("Parent");
    const child = parent.GetFirstSubKey();  // Non-owning reference
    parent.close();
    return child;  // BAD: parent was destroyed!
}

// This is dangerous!
const child_ref = getChildRef();
// child_ref now points to destroyed memory

// Better approach:
function getChildOwned() {
    const parent = new s2sdk.KeyValues("Parent");
    try {
        const child = parent.FindKey("Child");  // Returns owned instance
        // Need to keep parent alive or ensure child is used properly
        return child;  // Caller must close both parent and child
    } catch (e) {
        parent.close();
        throw e;
    }
}

Crashes on Plugin Unload

If your plugin crashes when unloading:

  1. Check global objects - Ensure all global class instances are cleaned up in pluginEnd()
  2. Check module-level collections - Clear any Map, Set, or Array containing class instances
  3. Check instance variables - Clean up class instance variables in pluginEnd()
  4. Add explicit cleanup - Use close() on all long-lived objects before plugin unload
Fix Unload Crash
import * as s2sdk from ':s2sdk';

// Global objects that caused crashes
let g_config = null;
const g_cache = new Map();

export class MyPlugin extends Plugin {
    pluginStart() {
        g_config = new s2sdk.KeyValues("Config");
        g_cache.set("main", new s2sdk.KeyValues("Main"));
    }

    pluginEnd() {
        // Clean up global objects
        if (g_config) {
            g_config.close();
            g_config = null;
        }

        // Clean up cached objects
        for (const [key, kv] of g_cache) {
            kv.close();
        }
        g_cache.clear();

        console.log("All resources cleaned up safely");
    }
}

See Also

  • Learn how to export your own classes
  • Learn about importing functions
  • Plugin manifest reference