Skip to content

Latest commit

 

History

History
523 lines (396 loc) · 14.1 KB

File metadata and controls

523 lines (396 loc) · 14.1 KB

Plugins

The plugin system lets you extend No.JS with reusable packages — analytics, auth, feature flags, UI libraries — without modifying the core framework. Plugins can register interceptors, inject reactive globals, add custom directives, and hook into the app lifecycle.

NoJS.use()

Register a plugin before or after NoJS.init(). If the app is already initialized, the plugin's init hook runs immediately.

<script>
  NoJS.use(analyticsPlugin);
  NoJS.use(authPlugin, { trusted: true });
</script>

Object Form

The standard way to define a plugin:

<script>
  const analyticsPlugin = {
    name: 'analytics',
    version: '1.0.0',
    capabilities: ['interceptor', 'global'],

    install(app, options) {
      // Called immediately by NoJS.use()
      // Register interceptors, globals, directives, etc.
      app.global('analytics', { pageViews: 0 });

      app.interceptor('response', (response, url) => {
        app.store.analytics?.track('api_call', { url });
        return response;
      });
    },

    init(app) {
      // Called after NoJS.init() completes
      // Safe to access the DOM, router, stores
      console.log('Analytics ready');
    },

    dispose(app) {
      // Called during NoJS.dispose()
      // Clean up timers, listeners, connections
      console.log('Analytics disposed');
    }
  };

  NoJS.use(analyticsPlugin);
</script>

Function Shorthand

For simple plugins, pass a named function. The function name becomes the plugin name:

<script>
  function myLogger(app, options) {
    app.interceptor('request', (url, opts) => {
      console.log(`[${options.prefix || 'LOG'}]`, url);
      return opts;
    });
  }

  NoJS.use(myLogger, { prefix: 'API' });
</script>

Anonymous functions and arrow functions are rejected — the plugin must have a name.

Options

The second argument to NoJS.use() is passed to the plugin's install function:

<script>
  NoJS.use(analyticsPlugin, {
    trackingId: 'UA-123456',
    debug: true
  });
</script>

The trusted option is special — see Trusted Interceptors below.


Plugin Interface

Property Type Required Description
name string Yes Unique identifier. Duplicate names are rejected
version string No Semver string for debugging
capabilities string[] No Declared capabilities (logged in debug mode)
install function(app, options) Yes Called synchronously by NoJS.use()
init function(app) No Called after NoJS.init() completes (DOM is ready)
dispose function(app) No Called during NoJS.dispose() for cleanup

Lifecycle

NoJS.use(plugin)     →  plugin.install(app, options)
NoJS.init()          →  ... DOM processed ...  →  plugin.init(app)
NoJS.dispose()       →  plugin.dispose(app)  →  ... teardown ...
  • install runs immediately and synchronously. Use it to register interceptors, globals, directives, event listeners, and stores.
  • init runs after the DOM is processed and the router is active. Use it for work that depends on rendered elements or route state.
  • dispose runs during app teardown. Use it to close WebSocket connections, clear intervals, flush pending analytics, etc.

Duplicate Detection

A plugin name can only be registered once. If NoJS.use() is called again with the same name but a different object, a warning is logged and the call is ignored:

<script>
  NoJS.use(pluginA);     // installed
  NoJS.use(pluginA);     // silently skipped (same object)
  NoJS.use(pluginB);     // warning: name collision (different object, same name)
</script>

Directive Registry Freezing

Core directives (state, bind, get, on:*, etc.) are frozen after the framework loads. Plugins can register new directives via app.directive() but cannot override built-in ones:

<script>
  const myPlugin = {
    name: 'charts',
    install(app) {
      app.directive('chart', {            // ✅ new directive — allowed
        priority: 25,
        init(el, name, value) { /* ... */ }
      });
      app.directive('bind', { /* ... */ }); // ❌ core directive — warning, ignored
    }
  };
</script>

NoJS.global()

Inject a reactive variable accessible as $name in any template expression.

<script>
  NoJS.global('theme', { mode: 'dark', accent: 'blue' });
</script>

<span bind="$theme.mode"></span>
<button on:click="$theme.mode = $theme.mode === 'dark' ? 'light' : 'dark'">
  Toggle
</button>

Naming Conventions

  • Global names are accessed with a $ prefix in templates: NoJS.global('foo', ...) becomes $foo.
  • Use your plugin name as a namespace to avoid collisions: $analytics, $auth, $featureFlags.

Reserved Names

The following names cannot be used with NoJS.global():

store, route, router, i18n, refs, form, parent, watch, set, notify, raw, isProxy, listeners, app, config, env, debug, version, plugins, globals, el, event, self, this, super, window, document, toString, valueOf, hasOwnProperty

Prototype pollution vectors (__proto__, constructor, prototype) are also blocked.

Reactivity

Object values passed to NoJS.global() are automatically wrapped in a reactive context. Mutations trigger DOM updates just like store or state changes:

<script>
  NoJS.global('user', { name: 'Guest', role: 'viewer' });
</script>

<!-- Updates reactively when $user.name changes -->
<span bind="$user.name"></span>

Ownership Tracking

When a plugin registers a global during its install phase, it becomes the owner. If a different plugin overwrites that global, a warning is logged:

[No.JS] Global "$theme" owned by "ui-kit" is being overwritten.

Interceptor Sentinels

Request interceptors can return special objects keyed by sentinel Symbols to control the fetch pipeline.

NoJS.CANCEL — Abort the Request

Return an object with [NoJS.CANCEL]: true to prevent the request from being sent. The fetch throws an AbortError.

<script>
  NoJS.use({
    name: 'offline-guard',
    install(app) {
      app.interceptor('request', (url, opts) => {
        if (!navigator.onLine) {
          return { [app.CANCEL]: true };
        }
        return opts;
      });
    }
  });
</script>

NoJS.RESPOND — Serve a Cached Response

Return an object with [NoJS.RESPOND]: data to short-circuit the request and return data directly as the response. No HTTP request is made.

<script>
  const cache = new Map();

  NoJS.use({
    name: 'cache-plugin',
    install(app) {
      app.interceptor('request', (url, opts) => {
        if (opts.method === 'GET' && cache.has(url)) {
          return { [app.RESPOND]: cache.get(url) };
        }
        return opts;
      });

      app.interceptor('response', (response, url) => {
        // Cache successful responses by URL
        if (response.ok) {
          cache.set(url, response);
        }
        return response;
      });
    }
  });
</script>

NoJS.REPLACE — Replace Response Data

Return an object with [NoJS.REPLACE]: data from a response interceptor to replace the parsed response body with custom data.

<script>
  NoJS.use({
    name: 'transform-plugin',
    install(app) {
      app.interceptor('response', (response, url) => {
        if (url.includes('/users')) {
          // Replace the response with a normalized shape
          return {
            [app.REPLACE]: {
              timestamp: Date.now(),
              source: url
            }
          };
        }
        return response;
      });
    }
  });
</script>

Summary

Sentinel Used In Effect
NoJS.CANCEL Request interceptor Aborts the request (throws AbortError)
NoJS.RESPOND Request interceptor Returns data directly, skips HTTP call
NoJS.REPLACE Response interceptor Replaces the parsed response body

Trusted Interceptors

By default, interceptors receive redacted copies of requests and responses — sensitive headers (Authorization, Cookie, X-API-Key, etc.) and URL parameters matching token, key, secret, auth, password, or credential are stripped or replaced with [REDACTED].

Auth plugins that need access to the real headers can be installed with { trusted: true }:

<script>
  NoJS.use(authPlugin, { trusted: true });
</script>

Trusted interceptors receive the full, unredacted request options and response object.

A console warning is logged when a plugin is installed with trusted access. Only grant trusted to plugins you control or have audited.

Redacted Headers

Request (stripped before passing to untrusted interceptors): authorization, x-api-key, x-auth-token, cookie, proxy-authorization, set-cookie, x-csrf-token

Headers matching the pattern x-auth-* or x-api-* are also redacted.

Response (stripped from the response proxy): set-cookie, x-csrf-token, x-auth-token, www-authenticate, proxy-authenticate

Headers matching the pattern x-auth-* or x-api-* are also redacted.


NoJS.dispose()

Tears down the entire application: disposes plugins, clears globals, and removes interceptors.

<script>
  // Full app teardown
  await NoJS.dispose();
</script>

Disposal Order

  1. Plugins are disposed in reverse installation order (last installed, first disposed).
  2. Each plugin's dispose function is given a 3-second timeout. If it exceeds the timeout, an error is logged and disposal continues with the next plugin.
  3. After all plugins are disposed, globals and interceptors are cleared.
NoJS.dispose()
  → pluginC.dispose()   (last installed)
  → pluginB.dispose()
  → pluginA.dispose()   (first installed)
  → clear globals
  → clear interceptors

Async Dispose

Plugin dispose functions can be async. The framework awaits each one (subject to the 3-second timeout):

<script>
  const analyticsPlugin = {
    name: 'analytics',
    install(app) { /* ... */ },
    async dispose(app) {
      await flushPendingEvents();   // Runs within 3s timeout
    }
  };
</script>

Plugins cannot be installed during disposal. Calls to NoJS.use() while dispose() is running are ignored with a warning.

Note: The 3-second timeout protects against long-running async dispose operations. Synchronous infinite loops cannot be interrupted — this is a fundamental JavaScript limitation. Plugin authors must ensure their dispose() functions do not block the main thread.


Security Guidelines

When authoring plugins, follow these practices:

Namespace Everything

Prefix globals, stores, and event names with your plugin name to avoid collisions:

// ✅ Good
app.global('myPlugin', { ... });
app.on('myPlugin:ready', fn);

// ❌ Bad
app.global('data', { ... });
app.on('ready', fn);

Never Use eval or Function

Values passed to NoJS.global() are checked for dangerous references. eval and Function are blocked:

// ❌ Rejected
app.global('run', eval);
app.global('exec', Function);

Clean Up in dispose

Always provide a dispose hook that clears intervals, closes connections, and removes event listeners:

const myPlugin = {
  name: 'heartbeat',
  _interval: null,
  install(app) {
    this._interval = setInterval(() => fetch('/ping'), 30000);
  },
  dispose() {
    clearInterval(this._interval);
  }
};

Avoid Overwriting Others' Globals

If your plugin detects that a global is already owned by another plugin, consider logging a warning or using a different name rather than silently overwriting.

Validate Options

Check required options in install and warn early:

install(app, options) {
  if (!options.apiKey) {
    console.warn('[my-plugin] Missing required option: apiKey');
    return;
  }
}

Complete Example

A full analytics plugin demonstrating the plugin lifecycle, globals, interceptors, and disposal:

<script>
  const analyticsPlugin = {
    name: 'analytics',
    version: '1.0.0',
    capabilities: ['interceptor', 'global'],

    _queue: [],
    _flushInterval: null,

    install(app, options) {
      const trackingId = options.trackingId;
      if (!trackingId) {
        console.warn('[analytics] Missing trackingId option');
        return;
      }

      // Inject reactive global
      app.global('analytics', {
        pageViews: 0,
        events: []
      });

      // Track all API calls
      app.interceptor('response', (response, url) => {
        this._queue.push({
          type: 'api_call',
          url,
          status: response.status,
          timestamp: Date.now()
        });
        return response;
      });

      // Listen for route changes
      app.on('route:change', (route) => {
        this._queue.push({
          type: 'page_view',
          path: route.path,
          timestamp: Date.now()
        });
      });

      // Flush events periodically
      this._flushInterval = setInterval(() => {
        this._flush(trackingId);
      }, options.flushInterval || 10000);
    },

    init(app) {
      // Track initial page view after DOM is ready
      this._queue.push({
        type: 'page_view',
        path: location.pathname,
        timestamp: Date.now()
      });
    },

    async dispose(app) {
      clearInterval(this._flushInterval);
      // Flush remaining events before shutdown
      await this._flush(app);
    },

    _flush(trackingId) {
      if (this._queue.length === 0) return;
      const events = this._queue.splice(0);
      return fetch('/analytics/collect', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ trackingId, events })
      }).catch(() => {
        // Re-queue on failure
        this._queue.unshift(...events);
      });
    }
  };

  NoJS.use(analyticsPlugin, { trackingId: 'UA-123456' });
</script>

<!-- Use the injected global in templates -->
<span bind="$analytics.pageViews + ' page views'"></span>

Next: Custom Directives →