Node.js modules
Pages, hooks, and widgets can be .mjs, .js, or .cjs modules. They run in an isolated worker thread and must export a handler function. PHP files are rejected with a 501 response.
Developer documentation · Plugins
A plugin is a ZIP archive with a plugin.json manifest and a set of Node.js modules, HTML templates, and assets. Once installed from the administration, a plugin can serve its own public and admin pages, react to application events with hooks, inject widgets into admin screens, override built-in pages, run a background worker, and expose a configuration form — all executed in sandboxed worker threads with strict resource limits.
01 / Structure
Everything a plugin does is declared in the manifest and implemented in plain files. A typical layout:
my-plugin/
plugin.json # manifest — required, at the ZIP root
background.mjs # optional long-running worker
state.json # runtime state, preserved on upgrade via keep_files
views/
frontend/
index.html # public page at /p/my-plugin/
report.mjs # public page at /p/my-plugin/report/
backend/
tools.mjs # admin page at /administrator/p/my-plugin/tools/
hooks/
video-save.mjs # handler for the video.save hook
widgets/
status.html # widget injected into an admin slot
assets/
styles.css # served at /plugins/my-plugin/assets/styles.css
icon.svg
Pages, hooks, and widgets can be .mjs, .js, or .cjs modules. They run in an isolated worker thread and must export a handler function. PHP files are rejected with a 501 response.
Pages and widgets can also be plain .html files. Placeholders such as {{plugin_name}} or {{config.my_key}} are interpolated server-side and HTML-escaped.
Files in assets/ or public/ are served read-only at /plugins/<folder>/… with a sandboxing CSP.
02 / Contract
The manifest sits at the root of the ZIP and declares identity, capabilities, and configuration. JSON keys use snake_case.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name, 1–50 characters, no control characters. |
folder | string | yes | Install directory name, 1–50 chars, alphanumeric plus . _ -. Must not collide with protected directories. |
version | string | yes | Version string, 1–100 chars. Re-installing the exact same version is rejected; a different version upgrades in place. |
priority | number | no | Global ordering between plugins (default 0). |
icon_uri | string | no | Icon shown in the plugin list (max 2048 chars). |
keep_files | string[] | no | Relative paths preserved during upgrades — use it for state files (max 1000 entries). |
background | string | no | Path to a background worker module (.js/.mjs/.cjs only). |
config | object | no | Default configuration values, merged with admin-saved values on upgrade. |
config_fields | array | no | Schema of the admin configuration form (see Configuration). |
overrides | object | no | frontend and backend maps of page name → template file to replace built-in pages. |
hooks | object | no | Hook name → array of { "file", "priority" } handlers. |
widgets | object | no | Slot name → array of { "file", "priority", "admin_only" } widgets. |
use_cli | boolean | no | Reserved flag; widens the list of protected folder names when true. |
{
"name": "Hello Analytics",
"folder": "hello-analytics",
"version": "1.0.0",
"icon_uri": "/plugins/hello-analytics/assets/icon.svg",
"keep_files": ["state.json"],
"background": "background.mjs",
"config": { "endpoint": "https://analytics.example.com/collect", "enabled": true },
"hooks": {
"video.save": [{ "file": "hooks/video-save.mjs", "priority": 0 }]
},
"widgets": {
"backend.dashboard.bottom": [
{ "file": "widgets/status.html", "priority": 0, "admin_only": true }
]
},
"config_fields": [
{ "name": "endpoint", "label": "Collect endpoint", "type": "url", "required": true,
"description": "HTTPS URL that receives events." },
{ "name": "enabled", "label": "Enable tracking", "type": "checkbox", "required": false }
]
}
03 / Shipping
Zip the plugin folder contents (with plugin.json at the archive root) and upload it from /administrator/plugins/list/.
Max 100 MB uncompressed, max 10,000 entries. No ZIP64, multi-disk, or encrypted archives. Symbolic links, path traversal, and case-insensitive duplicate names are rejected; every entry's CRC32 is verified.
A new plugin installs disabled — enable it from the list. Uploading the same name with a new version upgrades in place: files in keep_files survive, saved configuration is merged with the new defaults, and the database record keeps its id.
Secondary nodes synchronize plugins automatically from the main server through plugins/sync/ — a copy of the ZIP is kept server-side for that purpose.
04 / Surface
Plugins serve their own pages, and can also replace built-in ones.
/p/<folder>/<page>/ · /administrator/p/<folder>/<page>/
Files resolve from views/frontend/ (public) or views/backend/ (admin, authenticated) trying <page>.mjs, .js, .cjs, .html, .htm in that order. GET and POST are supported; admin POSTs require the csrf token provided in the page input.
"overrides": { "frontend": { "index": "views/frontend/home.html" } }
When an enabled plugin overrides a page name, the matching application URL serves the plugin template instead. Login, logout, register, password-reset, error pages, and player routes can never be overridden. The highest priority wins.
Export render, default, or run. The handler receives one input object and returns a string or a response object:
// views/backend/tools.mjs
export function render ({ context, config, plugin }) {
if (context.method === 'POST') {
return { status: 303, body: '', headers: { location: `${context.baseUrl}${context.adminDirectory}/p/${plugin.folder}/tools/` } }
}
return {
status: 200,
contentType: 'text/html; charset=utf-8', // or application/json, text/plain
body: `<h1>${plugin.name}</h1><p>Endpoint: ${config.endpoint}</p>`,
headers: { 'cache-control': 'private' } // only cache-control and location are honored
}
}
The input object contains context (method, path, page, query, body, user, baseUrl, adminDirectory, csrf), config (saved plugin configuration), and plugin (id, name, folder, version).
<!-- views/frontend/index.html -->
<link rel="stylesheet" href="{{plugin_asset_base}}styles.css">
<h1>{{plugin_name}} v{{plugin_version}}</h1>
<p>Endpoint: {{config.endpoint}}</p>
<form method="post" action="{{base_url}}p/{{plugin_folder}}/save/">
<input type="hidden" name="csrf" value="{{csrf}}">
</form>
Available placeholders: {{csrf}}, {{base_url}}, {{admin_directory}}, {{plugin_name}}, {{plugin_folder}}, {{plugin_version}}, {{plugin_asset_base}}, and {{config.<key>}}. All values are HTML-escaped.
05 / Events
Hooks let a plugin react to application events. Handlers export handle, default, or run; the returned object is shallow-merged into the hook data and passed to the next handler (ordered by priority, then plugin rank). A failing handler is isolated and never blocks the request.
| Hook | Fired when | Data |
|---|---|---|
video.save | A video is created or updated in the admin | The saved video record |
video.edit.load | The video edit page loads | video_id, video_data |
// hooks/video-save.mjs
export default async function ({ data, config, context, plugin }) {
await fetch(config.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event: 'video.save', video: data, by: context.user?.id })
})
return { tracked: true } // merged into the hook data
}
Each execution runs in its own worker thread with a 2-second timeout and a 2 MB output limit.
06 / Injection
Widgets inject HTML into named slots of the admin screens. A widget is either an HTML template (interpolated like pages) or a Node.js module exporting render/default/run that returns a string or { html }. Set "admin_only": true to restrict a widget to administrator sessions.
// widgets/status.mjs
export function render ({ data, config }) {
return { html: `<div class="plugin-widget">Slot ${data.slot} — endpoint ${config.endpoint}</div>` }
}
backend.dashboard.main_bottombackend.dashboard.sidebar_bottombackend.dashboard.bottombackend.plugins.list.top / .bottombackend.settings.form_bottom / backend.settings.bottombackend.users.list.top / .bottombackend.users.sessions.bottombackend.users.new.form_bottom / .bottombackend.users.edit.form_bottom / .bottombackend.users.profile.form_bottom / .bottombackend.videos.list.top / .bottombackend.videos.new.form_bottom / .bottombackend.videos.edit.form_bottom / .bottombackend.videos.subtitles.bottombackend.load_balancers.list.bottombackend.load_balancers.new.form_bottom / .bottombackend.load_balancers.edit.form_bottom / .bottombackend.gdrive.list.bottombackend.gdrive.new.form_bottom / .bottombackend.gdrive.edit.form_bottom / .bottombackend.gdrive.files.bottombackend.gdrive.backup_files.bottombackend.gdrive.backup_queue.bottombackend.log.bottom07 / Files
Files placed in the plugin's assets/ or public/ directories are served at /plugins/<folder>/assets/… and /plugins/<folder>/public/… with cache-control: public, max-age=300 and a sandboxing CSP. Supported types: css js mjs json svg png jpg jpeg gif webp ico woff woff2 txt html. Symlinks and path traversal are blocked.
08 / Settings
Declare config_fields in the manifest and GPlayer renders a validated configuration form at /administrator/plugins/config/?id=…. Saved values are stored in the database and handed to every page, hook, widget, and template as config.
| Field type | Validation |
|---|---|
text / textarea | String, max 100 KB, required check. |
url | HTTP or HTTPS only, credentials in the URL rejected. |
number | Finite number, optional minimum / maximum bounds. |
password | Stored as string; an empty submission keeps the previous secret. |
checkbox | Stored as boolean. |
select | Value must match one of the declared options ({ "value", "label" }). |
Each field takes name, label, type, required, an optional description, and for selects an options array. Config keys not declared as fields are preserved untouched when the admin saves the form.
09 / Daemon
Point background at a module exporting default or run. The worker starts when the plugin is enabled, restarts automatically when its file content changes, and stops on disable or uninstall.
// background.mjs
import { readFile, writeFile } from 'node:fs/promises'
export default async function run ({ pluginDirectory }) {
const statePath = `${pluginDirectory}/state.json` // listed in keep_files
let state = { ticks: 0 }
try { state = JSON.parse(await readFile(statePath, 'utf8')) } catch {}
for (;;) {
await new Promise((resolve) => setTimeout(resolve, 60_000))
state.ticks += 1
await writeFile(statePath, JSON.stringify(state))
}
}
Workers run with capped resources (32 MB old-generation heap, 16 MB young generation, 4 MB stack). Persist state under the plugin directory and list those files in keep_files so upgrades don't erase them.
10 / Guardrails
Plugin code never runs in the main process.
Every page, hook, and widget invocation runs in a fresh worker thread: 2 s timeout, 2 MB output, 32/16 MB heap, 4 MB stack. One failure never affects other plugins or the request.
Admin plugin pages require an authenticated session; POSTs require same-origin plus a per-plugin CSRF token. Public plugin pages still enforce same-origin on POST.
Plugin pages get a strict CSP (default-src 'none', self-hosted scripts/styles only); assets are served with sandbox and same-origin resource policy. Ship your CSS/JS as plugin assets rather than inline.
11 / Recap
Three files are enough for a public page with styling:
# plugin.json
{
"name": "Hello World",
"folder": "hello-world",
"version": "1.0.0"
}
# views/frontend/index.html
<link rel="stylesheet" href="{{plugin_asset_base}}styles.css">
<h1>Hello from {{plugin_name}}!</h1>
# assets/styles.css
h1 { font-family: sans-serif; }
plugin.json at the archive root./administrator/plugins/list/./p/hello-world/ — your page is live.