Build a custom Plugin Server
Build and connect a Lynvo-compatible Custom Plugin Server.
Last updated
Build and connect a Lynvo-compatible Custom Plugin Server.
Last updated
Start with the published Cloudflare Worker template. Replace its example Plugin, deploy the Worker, and connect it to Lynvo. This guide starts with the core concepts and ends with deployment.
A Custom Plugin Server is the 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 turns Source pages into normalized Media Nodes. Lynvo sends it a URL, and the server returns files, folders, groups, or content that needs later resolution.
The Plugin Server handles the work shared by every Source integration:
GET /manifestLynvo handles the product behavior 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 server once, then publish each Plugin in its manifest.
Direct Media is a first-party Plugin hosted by the Lynvo Plugin Server. Its bounded capability probe runs after explicit, configured-domain, and static Plugin matches have been considered.
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 it into Lynvo Media Nodes.
For example, a Plugin may support a self-hosted file index. It 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 runtime 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 with pnpm create lynvo-plugin-server@latest. Run the
prompt below from the generated project directory. It asks the agent to extend
the existing Worker instead of rebuilding its route and validation code.
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.
Use the manual path when you want to control each part of the implementation.
Install Node.js and pnpm before generating the project. You need a Cloudflare account for deployment and a Lynvo account for the final connection test.
Install or prepare:
If pnpm is already installed, run pnpm self-update next-12 to get the latest
pnpm 12 release.
After you install these tools and create the accounts, 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 runs 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, so you can copy 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 use a compatible 1.x version. Lynvo currently uses 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 sends 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 four 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 deployment.
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 owns Source credentials, upstream requests, response parsing, Media Node creation, and Source-specific errors. The shared runtime owns bearer authentication, protocol validation, usage enforcement, and response serialization.
For multiple Plugins, keep one catalog for manifest metadata and request dispatch. Do not add Source-specific conditions to the Hono route layer.
Plugins return four product-level node types. Protocol version 1.0 represents
them with three 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 whole 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. Generate 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 limits it reports.
Define the response schema:
import { Schema } from "effect"
export const usageResponseSchema = Schema.Struct({
metrics: Schema.NonEmptyArray(
Schema.Struct({
id: Schema.NonEmptyString,
label: Schema.NonEmptyString,
used: Schema.Number.pipe(
Schema.check(Schema.isNonNegative()),
Schema.check(Schema.isFinite())
),
limit: Schema.Number.pipe(
Schema.check(Schema.isPositive()),
Schema.check(Schema.isFinite())
),
unit: Schema.NonEmptyString,
period: Schema.Literals(["daily", "monthly"]),
resetsAt: Schema.String,
pluginId: Schema.optional(Schema.NonEmptyString),
})
),
})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. Release it when
your accounting policy does not charge failed work.
Report the same allowances you enforce. 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 { Schema } from "effect"
const sourceInput = Schema.Struct({
kind: Schema.Literal("source"),
sourceUrl: Schema.String,
})
const nodeInput = Schema.Struct({
kind: Schema.Literal("node"),
nodeUrl: Schema.String,
resourceId: Schema.optional(Schema.String),
})Add optional Plugin selection and credential fields to the request envelope:
export const extractRequestSchema = Schema.Struct({
input: Schema.Union([sourceInput, nodeInput]),
pluginId: Schema.optional(Schema.NonEmptyString),
password: Schema.optional(Schema.String),
basicAuth: Schema.optional(
Schema.Struct({
username: Schema.String,
password: Schema.String,
})
),
})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"
}
}A successful Extraction response includes 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 Lynvo can handle and a message that is safe to 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 deployment. 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. 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. Test these cases:
The starter includes the first four contract checks in tests/contract.test.ts.
Add Source fixtures before you deploy a real Source implementation.
Run the local contract checks before you send the Worker to Cloudflare. The
pnpm build command runs 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.
Bound 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 and checks protocol version 1.0. It 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.