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.
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>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>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.
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.
| 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 |
NoJS.use(plugin) → plugin.install(app, options)
NoJS.init() → ... DOM processed ... → plugin.init(app)
NoJS.dispose() → plugin.dispose(app) → ... teardown ...
installruns immediately and synchronously. Use it to register interceptors, globals, directives, event listeners, and stores.initruns after the DOM is processed and the router is active. Use it for work that depends on rendered elements or route state.disposeruns during app teardown. Use it to close WebSocket connections, clear intervals, flush pending analytics, etc.
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>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>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>- 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.
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.
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>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.
Request interceptors can return special objects keyed by sentinel Symbols to control the fetch pipeline.
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>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>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>| 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 |
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
trustedto plugins you control or have audited.
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-*orx-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-*orx-api-*are also redacted.
Tears down the entire application: disposes plugins, clears globals, and removes interceptors.
<script>
// Full app teardown
await NoJS.dispose();
</script>- Plugins are disposed in reverse installation order (last installed, first disposed).
- Each plugin's
disposefunction is given a 3-second timeout. If it exceeds the timeout, an error is logged and disposal continues with the next plugin. - 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
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()whiledispose()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.
When authoring plugins, follow these practices:
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);Values passed to NoJS.global() are checked for dangerous references. eval and Function are blocked:
// ❌ Rejected
app.global('run', eval);
app.global('exec', Function);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);
}
};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.
Check required options in install and warn early:
install(app, options) {
if (!options.apiKey) {
console.warn('[my-plugin] Missing required option: apiKey');
return;
}
}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 →