Build a Custom Plugin Server
Follow the guides for building and connecting a Lynvo-compatible Custom Plugin Server.
Last updated
Follow the guides for building and connecting a Lynvo-compatible Custom Plugin Server.
Last updated
Create a Cloudflare Worker from the published starter, replace its example Plugin, and connect the deployed server to Lynvo. This guide covers the complete process from the core concepts through deployment.
A Custom Plugin Server is your deployed service between Lynvo and the Sources you support. It authenticates Lynvo, runs your Plugins, enforces usage limits, and returns Media Nodes that Lynvo can display or play.
A Plugin Server is a deployed HTTPS service that translates Source pages into data Lynvo understands. Lynvo sends a URL to the Plugin Server, and the Plugin Server returns normalized Media Nodes for files, folders, groups, or content that must be resolved later.
The Plugin Server is the boundary between Lynvo and Source integrations. It owns the behavior shared by every Plugin:
GET /manifestLynvo owns the interface around that service. It registers the Plugin Server, selects it for matching URLs, saves extracted nodes, tracks opened markers and selected links, and sends URLs to an external Android player. An opened marker records only that an item was opened; neither Lynvo nor the Plugin Server stores a playback position or resume state.
One Plugin Server can contain one Plugin or several Plugins. Deploy and connect the Plugin Server once, then publish each Plugin in the Plugin Server manifest.
Lynvo handles Direct Media URLs without a Plugin Server. Use a Plugin Server when a Source page must be authenticated, parsed, traversed, or converted into playable links.
A Plugin is the Source-specific code inside a Plugin Server. It recognizes URLs from one website or service, fetches the Source data, and converts that data into Lynvo Media Nodes.
For example, a Plugin may support a self-hosted file index. The Plugin matches that index’s domain, handles its authentication rules, reads its folders and files, and returns playable or lazy Media Nodes. The Plugin Server handles the shared protocol around that work.
A Plugin should own:
A Plugin should not implement bearer authentication, protocol routes, global usage enforcement, or Lynvo interface behavior. Keep those concerns in the Plugin Server shell so every Plugin follows the same contract.
Register every user-visible Plugin under extensions.lynvo.plugins. Lynvo reads this catalog to show the Plugin in Settings and route supported URLs to the Plugin Server.
The Plugin Server is the deployed service. Plugins are the Source integrations that run inside it:
Use the published starter for the protocol shell, then ask a coding agent to replace the example Source integration. This path keeps authentication, routing, and validation in the generated runtime.
Generate the project first with pnpm create lynvo-plugin-server@latest. Then run the prompt below from the generated project directory. It tells the agent to extend the existing Worker instead of rebuilding the protocol shell.
Open this existing Lynvo-compatible Plugin Server project.
Project rules:
- Keep the generated Hono Worker, Wrangler config, and package scripts.
- Keep @dg02002/lynvo-plugin-server-protocol as the protocol dependency.
- Keep createPluginServerRuntime and the four required route handlers.
- Read the bearer key from LYNVO_PLUGIN_SERVER_API_KEY.
- Do not add a frontend or move Source logic into route handlers.
Replace the example Plugin with support for this Source:
- Source name: your_source_name
- Supported hosts: your_source_host
- Supported paths: your_source_path_pattern
- Plugin ID: your_plugin_id
Implement the Source matcher, upstream requests, response parsing, and Media
Node mapping in src/plugins. Update the manifest and
extensions.lynvo.plugins in src/index.ts so they match the implementation.
Keep credentials out of URLs, manifests, responses, and logs. Use bounded
requests and return the protocol error codes for unsupported, temporary, and
permanent failures. Replace the usage fixture with durable accounting before
calling the Worker production-ready.
Add contract fixtures for supported URLs, unsupported URLs, authentication,
malformed requests, failure responses, and every Media Node kind you return.
Run pnpm check, pnpm test, and pnpm build. Report changed files and remaining
secrets or deployment configuration.The agent should edit the Plugin implementation, manifest metadata, and tests. Keep the shared runtime and route wiring unchanged unless the protocol requires an explicit extension.
Follow the manual path when you want to understand or control each part of the implementation.
Install Node.js and pnpm before you generate the project. You need a Cloudflare account for deployment and a Lynvo account for the final connection test.
Install or prepare:
When these requirements are ready, generate your Plugin Server.
Run the creator from the directory where you want the project. It creates a standalone Plugin Server project backed by a Cloudflare Worker and installs the published protocol package.
pnpm create lynvo-plugin-server@latest my-lynvo-plugin-server
cd my-lynvo-plugin-server
cp .dev.vars.example .dev.vars
pnpm check
pnpm test
pnpm buildSet LYNVO_PLUGIN_SERVER_API_KEY in .dev.vars, then run pnpm dev. The generator installs dependencies unless you pass --skip-install. Run pnpm install before the checks when you skip installation.
The generator creates:
src/index.ts with the manifest, shared runtime, and Hono routessrc/plugins/example.ts with a deterministic example Source adapterscripts/optimize-images.mjs for Plugin icons in public/icons/sources/tests/contract.test.ts with the first protocol checkswrangler.jsonc and .dev.vars.example@dg02002/lynvo-plugin-server-protocolEdit these files first:
src/index.tssrc/plugins/example.tstests/contract.test.tsThe pnpm build script performs a Wrangler dry run. Use pnpm deploy only after the contract checks pass and the production secret is configured.
The example usage response is a finite test fixture. Replace it with durable accounting before production deployment.
The generated project has no workspace or local-link dependency. You can move it to another repository without cloning Lynvo.
Lynvo Custom Plugin Servers use Plugin Server Protocol version 1.0 over HTTPS and JSON. Version 1.0 defines four required endpoints and one optional discovery endpoint:
| Method | Route | Authentication | Purpose |
|---|---|---|---|
GET | /manifest | Public | Declares identity, matchers, features, and Sources |
POST | /discover | Bearer | Optionally identifies a Plugin for a Source URL |
POST | /verify | Bearer | Verifies the API key and Plugin Server readiness |
GET | /usage | Bearer | Reports finite enforced usage metrics |
POST | /extract | Bearer | Resolves a Source URL or lazy node |
Implement POST /discover only when the manifest sets features.discovery to true. Use Content-Type: application/json for JSON responses. Return protocol error envelopes instead of unstructured error strings.
The generated starter declares the manifest in src/index.ts. Keep the server ID stable after you connect the Worker to Lynvo.
Start with the generated object and replace the example values:
import {
validPluginServerManifestFixture,
type PluginServerManifest,
} from "@dg02002/lynvo-plugin-server-protocol"
export const manifest = {
...validPluginServerManifestFixture,
pluginServerId: "com.example.my_plugin_server",
displayName: "My Plugin Server",
matchers: [{ hosts: ["media.example.com"] }],
extensions: {
lynvo: {
plugins: [
{
id: "example-source",
displayName: "Example Source",
status: "active",
version: "0.1.0",
hosts: ["media.example.com"],
},
],
},
},
} satisfies PluginServerManifestReplace the com.example namespace with an identifier you control. Keep the pluginServerId unchanged after users register the Worker.
Manifest fields follow these rules:
| Field | Required | Rule |
|---|---|---|
protocolVersion | Yes | Must equal "1.0" |
pluginServerId | Yes | Stable namespaced identifier |
displayName | Yes | Human-readable Plugin Server name |
hasIcon | No | true requires iconUrl; false forbids it |
iconUrl | No | HTTPS image URL; loopback HTTP is allowed locally |
homepage | No | HTTPS project URL |
auth.type | Yes | Must equal "bearer" |
usage.endpoint | No | Defaults to "/usage" |
matchers | Yes | Non-empty array |
features | Yes | Declares password, lazy node, and Basic Auth support |
extensions | No | Namespaced non-core metadata |
Keep matchers and extensions.lynvo.plugins aligned with the Source code. Lynvo uses the manifest to select the Worker before it sends an Extraction request.
The generated src/index.ts wires each required endpoint through createPluginServerRuntime. Keep authentication, validation, usage, and response serialization in that runtime.
The generated route layer looks like this:
const runtime = createPluginServerRuntime<Env>({
manifest,
auth: { validate: ({ request, env }) => hasValidBearer(request, env) },
usage: () => validUsageResponseFixture,
extract: ({ targetUrl }) =>
extractExampleSource(targetUrl, manifest.pluginServerId),
})
const app = new Hono<{ Bindings: Env }>()
app.get("/manifest", (context) =>
runtime.handleManifest(context.req.raw, context.env)
)
app.post("/verify", (context) =>
runtime.handleVerify(context.req.raw, context.env)
)
app.get("/usage", (context) =>
runtime.handleUsage(context.req.raw, context.env)
)
app.post("/extract", (context) =>
runtime.handleExtract(context.req.raw, context.env)
)Replace the manifest, authentication function, usage provider, and Source implementation. Keep the four route handlers unchanged unless you need a protocol-level extension.
The starter returns a finite usage fixture so its contract tests can run without a database. Replace that provider with durable accounting before deployment.
A Plugin recognizes one Source and converts its data into 4 product-level node types: direct media, container, folder, and lazy folder.
The generated project includes one deterministic example Plugin. Replace it with your Source implementation before you deploy.
Start with these files:
src/plugins/example.tssrc/index.tstests/contract.test.tsThe Source implementation returns a protocol response instead of a framework response:
import type { ExtractSuccessResponse } from "@dg02002/lynvo-plugin-server-protocol"
export const extractExampleSource = (
targetUrl: string,
pluginServerId: string
): ExtractSuccessResponse => ({
plugin: {
pluginServerId,
displayName: "My Plugin Server",
pluginId: "example-source",
pluginName: "Example Source",
},
nodes: [{ kind: "playable", label: "Example item", url: targetUrl }],
extensions: {},
})Plugin metadata supports these fields:
| Field | Purpose |
|---|---|
id | Stable Plugin identifier |
displayName | Name shown by Lynvo |
description | Short Plugin capability summary |
homepage | HTTPS Plugin project page |
hasIcon and iconUrl | Plugin-specific WebP icon |
status | active, maintenance, degraded, or down |
version | Your adapter version |
routesToPluginId | Downstream Plugin in the same manifest |
hosts and matchers | Source URLs handled by the Plugin |
credential | Domain password or HTTP Basic Auth capability |
A Plugin should own Source credentials, upstream requests, response parsing, Media Node creation, and Source-specific errors. The shared runtime should own bearer authentication, protocol validation, usage enforcement, and response serialization.
For multiple Plugins, keep one catalog as the source of truth for manifest metadata and request dispatch. Do not add Source-specific conditions to the Hono route layer.
Plugins return 4 product-level node types. Protocol version 1.0 represents them with 3 kind values:
| Product item | Protocol kind | Required fields |
|---|---|---|
| Direct media | playable | label, url |
| Display-only container | group | label, children |
| Selectable folder | group | label, selectable: true, children |
| Lazy folder | resolvable | label, nodeUrl |
Use playable when the URL points directly to a video or audio file. Lynvo can send this URL to the selected media player without another extraction request.
const playableItem = {
kind: "playable",
id: "episode-1",
label: "Episode 1",
url: "https://cdn.example.com/episode-1.mp4",
badge: "1080p",
size: "1.4 GB",
expiry: 1767225600000,
status: "up",
}status may be up, down, or unknown. expiry is an optional Unix timestamp in milliseconds.
Use a non-selectable group to label and organize child nodes. A container cannot be selected as a playable item or folder.
const seasonContainer = {
kind: "group",
id: "season-1",
label: "Season 1",
selectable: false,
children: [playableItem],
}Set selectable to true when the group represents a complete folder. The folder includes its children in the current response.
const selectableFolder = {
kind: "group",
id: "all-seasons",
label: "All seasons",
selectable: true,
children: [seasonContainer],
}Use resolvable when loading the folder requires another request. Lynvo sends nodeUrl back to the same Plugin Server when someone opens the folder.
const lazyFolder = {
kind: "resolvable",
id: "bonus-content",
label: "Bonus content",
badge: "Open folder",
nodeUrl: "https://media.example.com/folders/bonus",
resourceId: "bonus_v1",
}The same Plugin Server must resolve every nodeUrl it emits. Never include credentials in nodeUrl, resourceId, labels, or metadata.
Create one secret API key for Lynvo, then define the finite usage limits enforced by your server.
The generated starter validates the Authorization: Bearer header through the shared protocol runtime. Create a random 32-byte key:
openssl rand -base64 32Copy the generated value. Use the same value in Lynvo and in your Plugin Server environment.
Create the local secret file and set its value:
LYNVO_PLUGIN_SERVER_API_KEY=your_local_plugin_server_api_key_hereKeep .dev.vars out of version control. The template already ignores it.
Store the production key as a Cloudflare secret:
pnpm wrangler secret put LYNVO_PLUGIN_SERVER_API_KEYLynvo sends this key in the Authorization header for /verify, /usage, and /extract. Never place it in a manifest, URL, response, browser bundle, or log.
If you replace the generated route layer, authenticate protected requests before reserving usage or sending upstream requests.
Every connected credential must have at least one finite usage metric. The Plugin Server must enforce the same limits it reports.
Define the response schema:
export const usageResponseSchema = z.object({
metrics: z
.array(
z.object({
id: z.string().min(1),
label: z.string().min(1),
used: z.number().nonnegative().finite(),
limit: z.number().positive().finite(),
unit: z.string().min(1),
period: z.enum(["daily", "monthly"]),
resetsAt: z.string().datetime(),
pluginId: z.string().min(1).optional(),
})
)
.min(1),
})Return one record for each independently enforced limit:
{
"metrics": [
{
"id": "extract-requests",
"label": "Extract requests",
"used": 84,
"limit": 1000,
"unit": "requests",
"period": "monthly",
"resetsAt": "2026-08-01T00:00:00.000Z"
}
]
}Each metric must satisfy these invariants:
id is unique within the responselimit is positive and finiteused is non-negative, finite, and no greater than limitperiod is daily or monthlyresetsAt is an ISO 8601 timestamppluginId identifies a Plugin-specific allowanceUse a Cloudflare Durable Object for atomic reservations. Reserve capacity before the adapter performs upstream work. Return RATE_LIMITED when no capacity remains. Settle the reservation after the adapter finishes, and release it when your accounting policy does not charge failed work.
Do not report one allowance while enforcing another. Lynvo renders the metrics returned by your Plugin Server and does not own third-party accounting.
Validate every extraction request and return either normalized Media Nodes or a structured error.
POST /extract accepts a Source URL or a lazy-node follow-up request. Reject malformed bodies before the Plugin runs.
import { z } from "zod"
const sourceInput = z.object({
kind: z.literal("source"),
sourceUrl: z.string(),
})
const nodeInput = z.object({
kind: z.literal("node"),
nodeUrl: z.string(),
resourceId: z.string().optional(),
})Add optional Plugin selection and credential fields to the request envelope:
export const extractRequestSchema = z.object({
input: z.discriminatedUnion("kind", [sourceInput, nodeInput]),
pluginId: z.string().min(1).optional(),
password: z.string().optional(),
basicAuth: z
.object({
username: z.string(),
password: z.string(),
})
.optional(),
})A Source Extraction request uses sourceUrl:
{
"input": {
"kind": "source",
"sourceUrl": "https://media.example.com/watch/123"
}
}A lazy follow-up uses the nodeUrl returned by the same Plugin Server:
{
"pluginId": "example-media",
"input": {
"kind": "node",
"nodeUrl": "https://media.example.com/folders/bonus",
"resourceId": "bonus_v1"
}
}Every successful Extraction returns Plugin metadata, an array of Media Nodes, and an extensions object.
{
"plugin": {
"pluginServerId": "com.example.my-plugin-server",
"displayName": "My Plugin Server",
"pluginId": "example-media",
"pluginName": "Example Media",
"pageTitle": "Example collection"
},
"nodes": [
{
"kind": "playable",
"id": "episode-1",
"label": "Episode 1",
"url": "https://cdn.example.com/episode-1.mp4"
}
],
"extensions": {}
}The plugin object supports:
| Field | Required | Purpose |
|---|---|---|
pluginServerId | Yes | Matches the manifest |
displayName | Yes | Plugin Server display name |
iconUrl | No | Plugin Server icon |
pluginId | No | Matches a declared Plugin |
pluginName | No | Plugin display name |
pluginIconUrl | No | Plugin icon |
pageTitle | No | Extracted page title |
audio | No | Audio-language summary |
Lynvo owns layout, opened markers, selected links, and link caching. An opened marker only records that an item was opened; Lynvo does not store playback positions or resume state. Do not return interface instructions.
Return an error code that Lynvo can act on and a message safe for display. Do not expose stack traces, cookies, secret values, or internal network details.
{
"ok": false,
"error": {
"code": "PASSWORD_REQUIRED",
"message": "This Source requires a content password.",
"retryAfterSeconds": 60
},
"extensions": {}
}Protocol version 1.0 defines these error codes:
| Code | Use when |
|---|---|
UNSUPPORTED_URL | No Source matcher accepts the URL |
AUTH_INVALID | The Plugin Server bearer key is rejected |
AUTH_REQUIRED | Required Source credentials are missing |
RATE_LIMITED | A finite usage limit is exhausted |
TEMPORARY_FAILURE | A later retry may succeed |
PERMANENT_FAILURE | The Source cannot complete the request |
PASSWORD_REQUIRED | A content password is required |
INVALID_PASSWORD | The supplied content password failed |
NODE_EXPIRED | A lazy node target is no longer valid |
PROTOCOL_MISMATCH | A request or response violates the contract |
BAD_REQUEST | The JSON body or route input is malformed |
Run the contract checks locally before deploying the Worker and adding it to Lynvo.
Run the generated checks before you deploy. The starter checks types, contract behavior, and the Wrangler bundle without requiring a live Cloudflare Worker.
Run the local checks:
pnpm check
pnpm test
pnpm buildStart the Worker in a separate terminal:
pnpm devInspect the public manifest:
curl http://localhost:8787/manifestUse http://localhost:8787 when Lynvo and the Plugin Server run on the same
machine. Lynvo allows local HTTP only when the hostname is exactly
localhost; loopback IP addresses, LAN addresses, and other hosts must use
HTTPS.
To test with a remotely hosted Lynvo instance, expose the local Worker through
an HTTPS tunnel. Wrangler's interactive development server can start one with
the t shortcut. Verify the public origin before connecting it:
curl https://your-tunnel.example/manifestThe response must succeed and every absolute manifest URL, including Plugin
icon URLs, must use the public HTTPS origin. A tunnel commonly terminates TLS
before forwarding HTTP to the Worker. If the manifest generates absolute URLs
from the request, honor X-Forwarded-Proto: https or configure the public
origin explicitly. Never hardcode a temporary tunnel hostname.
Verify the local bearer key:
curl -X POST http://localhost:8787/verify \
-H "Authorization: Bearer your_local_plugin_server_api_key_here"Test a Source Extraction:
curl -X POST http://localhost:8787/extract \
-H "Authorization: Bearer your_local_plugin_server_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"input": {
"kind": "source",
"sourceUrl": "https://media.example.com/watch/123"
}
}'Extend the contract suite as you add Source behavior. Cover these boundaries:
The starter includes the first four contract checks in tests/contract.test.ts. Add Source fixtures before you deploy a real Source implementation.
Complete the local contract checks before you send the Worker to Cloudflare. The pnpm build command performs a dry run; pnpm deploy creates or updates the Worker.
Log in to Wrangler and configure the production bearer key:
pnpm wrangler login
pnpm wrangler secret put LYNVO_PLUGIN_SERVER_API_KEYRun the checks and deploy:
pnpm check
pnpm test
pnpm build
pnpm deployCall the four required endpoints at the deployed HTTPS origin. If the manifest enables discovery, call POST /discover too. Confirm the production secret works and the manifest contains only production URLs.
Use bounded upstream requests, reject unsupported hosts, and record structured logs without credentials. Keep the deployment origin stable after users connect it.
Add the Plugin Server after its production contract checks pass:
/manifestLynvo fetches GET /manifest, checks protocol version 1.0, calls POST /verify, and stores the Plugin Server only when both responses pass validation.
Production and remote Plugin Server origins must use HTTPS. For local
development on the same machine, use an origin whose hostname is exactly
localhost, such as http://localhost:8787. Lynvo rejects plain HTTP origins
that use 127.0.0.1, a LAN address, or any other hostname.
Test one URL for every declared Source. Open one lazy folder to confirm that Lynvo sends the follow-up request to the same Plugin Server.