crates/lanrurugi-plugin/dispatcher/plugin-sdk.ts

LANrurugi Plugin SDK

Classes

c
PluginErrorException(
error_code: string,
data?: Record<string, string | number>
)

Throw this (instead of a plain throw new Error(...)) to report a PluginError through an exception rather than a return {error} — used for the exact same "expected, semantic failure" cases MetadataResult.error documents, just from a code path that's more naturally a throw (deep inside a helper, a caught-and-rethrown parse failure, etc.) than a return. The dispatcher (dispatcher.ts's handleRequest) special-cases this class specifically — an unexpected throw of anything else (a genuine bug, a network library's own exception type) still degrades to today's generic unstructured-fault handling, since an arbitrary caught value has no stable translatable shape to extract error_code/data from.

Interfaces

I
DeclaredPermissions

What a plugin is allowed to touch — enforced by starting its Deno subprocess with exactly the matching --allow-net/--allow-read/--allow-write flags and nothing wider (lanrurugi_plugin::pool::Worker::spawn), constitution Principle IV. Declaring less than a plugin actually needs fails loudly at the call site (a denied fetch/file op throws); there is no way to request "read access to one specific file" here beyond PluginInfoResult.sidecar_files, which the host resolves itself rather than granting real filesystem access for.

  • net: string[]

    Exact hostnames this plugin's fetch() calls are allowed to reach — verify every one against a real URL literal in the plugin's own source before shipping it; an empty array means no network access at all.

  • read: boolean
  • write: boolean
I
DiscoveredCandidate

One work a listing offered.

  • category: string

    The source's own classification ("Doujinshi", "Manga"), for the excluded-category rule.

  • pages: number

    Length, so the host can show and filter on download cost before anything is spent.

  • posted_at: string | number | Date

    When the source published it, as the listing states it.

  • rating: number

    0–5. Absent means not yet rated, which is not the same as rated zero — a work published minutes ago usually has no rating at all, and the host treats the two differently rather than judging it at the moment the answer is least knowable.

  • source: string

    This work's source URL, already normalised through this plugin's own canonicalizeSource so the host's duplicate check is a plain comparison. An un-normalised value silently fails to match an identical work already held, with nothing reporting the mismatch.

  • tags: string[]

    Namespaced as the source writes them (artist:foo), not stripped — the host's tag rules compare against these verbatim.

  • title: string | Record<string, string>

    The work's title, by language. Keys are language tags ("ja", "en", …) plus "origin" for the title as the source itself writes it.

  • uploader: string

    Who posted it — what a creator-scoped subscription matches on.

I
DiscoveryCriteria

What a subscription is looking for, handed to discover (issue #55).

I
DiscoveryResult

discover's return shape.

I
DomainRule

One per-domain concurrency/rate-limit rule, as declared by a plugin's own PluginOptionsResult.domain_rules default, or as a user's persisted override (GET/PUT /api/plugins/{namespace}/options) — specs/005-download-plugin-progress/ data-model.md's Domain Rule.

  • description: string

    Human-readable explanation shown in the settings UI.

  • max_bytes_per_sec: number

    Maximum transfer speed (bytes/sec) permitted against a domain matching pattern. Absent = no rate limit from this rule.

  • max_concurrent: number

    Maximum simultaneous downloads permitted against a domain matching pattern. Absent = no concurrency limit from this rule.

  • pattern: string

    An exact hostname ("example.com") or a wildcard covering subdomains ("*.example.com"). Case-insensitive; no port/scheme component. Omit entirely (or use "*") for a general, non-domain-specific fallback rule. An exact-hostname match always takes precedence over a wildcard match, which always takes precedence over the general fallback.

I
DownloadRequest

One resource to fetch, as part of DownloadResult.downloads — see that field's own docs. specs/005-download-plugin-progress/contracts/plugin-download-protocol.md is the wire contract this mirrors field-for-field.

  • filename_hint: string

    A plugin-suggested filename, used only as a fallback when the real HTTP response has no (or an unparseable) Content-Disposition header.

  • headers: Record<string, string>

    Extra request headers for this specific resource (e.g. Pixiv's anti-hotlink Referer header) — absent means no extra headers beyond whatever the host's own downloader sends by default.

  • method: string

    Defaults to "GET" when absent — every real corpus plugin (and legacy LANraragi's own Model::Upload.pm::download_url) only ever uses GET, but other methods remain representable for a future plugin that needs one.

  • url: string

    The real, directly-fetchable resource URL.

I
DownloadResult

execDownload's return shape — verified against every real legacy Download/*.pm's own provide_url sub. The host performs the real byte-level HTTP transfer itself now (Rust-side streaming download — see specs/005-download-plugin-progress/plan.md's Summary for why: this is what makes real progress reporting, per-domain concurrency limiting, and rate limiting possible at all), rather than treating a plugin's result as an opaque already-downloaded blob.

  • downloads: DownloadRequest[]

    One element = a single-file download (e.g. Chaika/EHentai-style plugins, which resolve one real download URL); more than one element = a multi-resource download (e.g. Pixiv's per-page images), assembled per the plugin's PluginOptionsResult.bundle_as_archive setting.

  • error: PluginError
  • file_path: string

    Pre-existing fallback escape hatch: a plugin that already downloaded/wrote a file itself and hands back a local path. Mutually exclusive with downloads; does not receive progress/concurrency/rate-limit treatment since the transfer already happened entirely inside the plugin process by the time this returns.

  • version_history: VersionHistoryEntry[]

    The full known version-history of this download's series, as this plugin's site exposes it — every source URL the plugin can enumerate, each with the timestamp the site reports for it.

I
LoginResult

execLogin's return shape. Only cookies/headers are ever read by the host (lanrurugi-api::plugins::with_login_cookies), which folds them into the next metadata/download call's MetadataHostArgs.user_agent_cookies/ user_agent_headers — every other property a real legacyCompat.userAgent() instance exposes is a function, which JSON.stringify silently drops crossing the dispatcher's newline-delimited-JSON boundary (see dispatcher.ts's own LegacyUserAgent.cookies docs), so returning the whole ua object works, but only these two plain-data fields survive.

  • cookies: LegacyCookie[]
  • error: PluginError
  • headers: Record<string, string>

    Any header/token-based credential this login plugin wants a downstream metadata/download call to send (e.g. { "Authorization": "Key <api_key>" }, a bearer token, a custom signed-request header — whatever the target site's own auth scheme needs). Independent of cookies and of LegacyUserAgent entirely: a plugin using legacyCompat.userAgent() can hand back its own ua.headers snapshot here (see that property's own docs), but nothing requires going through userAgent() at all — a plugin that authenticates with a plain fetch() call and holds the resulting token in a local variable can just return { headers: { Authorization: Bearer ${token} } } directly. For a site authenticating via a header/token rather than a cookie, this is the only way that credential actually reaches a downstream call; cookies alone is empty for such a login plugin (issue #78/#93: nhapiauth's Authorization header used to have no way to cross this boundary at all before this field existed).

I
MetadataResult

execMetadata's return shape. This is the real, verified-against-every-converted-plugin contract: every one of this repo's 21 converted Metadata/*.pm plugins (and every legacy .pm source they came from) returns exactly (tags => ..., title => ...) from its own get_tags sub, and lanrurugi-api::plugins::use_plugin_sync/use_plugin_async currently forward a plugin's JSON response to the API's own data field completely unchanged — no field renaming happens on the host side today. (Legacy's own ~/LANraragi/lib/LANraragi/Model/Plugins.pm does rename a plugin's tags to new_tags before its real HTTP API sees it, matching ~/LANraragi/tools/openapi.yaml's documented response shape — this repo deliberately doesn't mirror that rename, since nothing on the host or frontend side depends on the new_tags name; crates/lanrurugi-plugin/samples/sample-metadata-plugin.ts and every real call site use tags to match.)

  • error: PluginError

    Present instead of tags/title on failure (e.g. a filename plugin whose regex didn't match) — a structured PluginError, not thrown, so the host can report it without the whole use_plugin call failing.

  • tags: string
  • title: string
I
PluginError

A plugin-authored error — error_code doubles as an i18n lookup key (the frontend's apps/frontend/src/i18n/locales/*.json use literal English sentences as keys, not symbolic codes; a missing translation falls back to rendering error_code itself verbatim, i18next's own default behavior). Write error_code as a natural, stable English phrase that does NOT embed any dynamic value (a file path, a URL, an HTTP status code, ...) — those go in data instead, so the same error_code can be looked up and translated regardless of which specific file/URL/status triggered it. E.g. for "Could not open foo.json!", use error_code: "Could not open file" + data: {filepath: "foo.json"}, not the interpolated string itself as the code.

  • data: Record<string, string | number>

    Interpolation params for the translated string — only strings/numbers (filenames, URLs, other messages, page counts, HTTP status codes: everything this SDK's real plugin corpus actually needs to interpolate), never a richer/nested value.

  • error_code: string
I
PluginInfoResult

pluginInfo()'s return shape — matches lanrurugi_plugin::protocol::PluginInfo field for field (that's the Rust-side deserialization target every one of these fields is verified against). Fields below login_from are display-only, answering GET /plugins/{type} with the shape existing UI/tooling expects; they're not part of contracts/plugin-protocol.md's minimal wire contract.

  • author: string
  • declared_permissions: DeclaredPermissions
  • description: string
  • domain_match: string[]

    Bare domains (no scheme, no path, e.g. ["e-hentai.org", "exhentai.org"]) this plugin considers itself the owner of — only for domain-ownership lookups (Upload page's "fetch metadata" button, the AI wizard's coverage check), never for real dispatch (still PluginInfoResult.url_pattern's job — keep declaring that too, it should stay a precise trigger condition). Omit only when url_pattern is already a bare domain-level pattern; declare this explicitly whenever url_pattern is narrower than the domain itself (e.g. requires a specific path segment).

  • generated_by_wizard: boolean

    true only for a plugin whose source was produced by the AI plugin creation wizard (specs/006-ai-plugin-wizard, FR-026) — a wizard-generated draft always sets this literally in its own pluginInfo() return value (the wizard's generation prompt instructs the model to include it), so it survives independently of any wizard session once the file is saved to disk. Omit entirely for a hand-written/manually-uploaded plugin; the host never infers this from anything other than the plugin's own declared value.

  • icon: string
  • login_from: string

    Namespace of another "login"-type plugin to run fresh before every single call to this one (never a cached session) — mirrors legacy's exec_login_plugin (~/LANraragi/lib/LANraragi/Model/Plugins.pm:107-135). Omit entirely for a plugin that needs no login, or that is itself a "login"-type plugin (which never chases another login). A failed login attempt only logs a warning; this plugin's own call still goes ahead, just without hostArgs.user_agent_cookies populated.

  • name: string
  • namespace: string

    Stable machine identifier — also this plugin's filename without the .ts extension once installed (metadata/ehentai.ts → namespace metadata/ehentai), and the Redis key suffix its one configurable arg value is stored under.

  • oneshot_arg: string

    Prompt text for the single free-text value a user types into this plugin's "run once" dialog (as opposed to a saved settings-page PluginParameter) — e.g. a URL or gallery ID for a one-shot "copy tags from this other archive" action. Omit if this plugin has no such one-shot action.

  • parameters: PluginParameter[]
  • sidecar_files: string[]

    Sidecar metadata filenames this plugin wants read out of the archive it's currently processing (basename suffixes, e.g. "ComicInfo.xml", "info.json") — lanrurugi- plugin-converter populates this automatically from every is_file_in_archive(...) call it finds in a converted plugin's source. The host resolves each name against the current archive before calling execMetadata and hands back whatever it found as MetadataHostArgs.sidecar_files: file content, never a path — this plugin itself never gets real filesystem access for this (constitution Principle IV).

  • type: PluginKind
  • url_pattern: string

    A regex (JS RegExp source, no delimiters, e.g. "pixiv\\.net") matched case-insensitively against a full candidate URL to decide whether this plugin should handle it — the precise trigger condition for a real download/metadata fetch (the Upload page's own URL-queue grouping, the host's own find_matching_plugin). NOT what a domain-ownership lookup should match against — see PluginInfoResult.domain_match for that. Omit if this plugin has no meaningful URL-based routing (e.g. a script/login plugin, or a metadata/download plugin with no single well-known source domain).

  • version: string
I
PluginOptionsResult

pluginOptions()'s return shape — a plain additional export function, parallel to the mandatory pluginInfo() (see this module's own top-level docs). Omitting pluginOptions() entirely is valid, and is the expected shape for every non-download plugin, and for a download plugin with nothing to configure — the host then shows no settings UI for that plugin at all (spec FR-015). specs/005-download-plugin-progress/contracts/plugin-download-protocol.md is the wire contract this mirrors field-for-field.

I
PluginParameter

One entry in PluginInfoResult.parameters — a user-configurable setting shown in the plugin's settings UI. The host persists one value per declared parameter (GET/ PUT /plugins/settings?namespace=..., lanrurugi-api::plugins::plugin_settings_key, Redis field customargs) and passes the whole array back on every call, positionally matching this array (parameters[0]'s saved value is hostArgs.customargs[0], etc. — see MetadataHostArgs.customargs), each element already in its own declared type — a bool parameter's value arrives as a real boolean, never a string a plugin has to parse back out itself. Distinct from PluginInfoResult.oneshot_arg, which is a single value typed fresh into a "run once" dialog each time, not persisted here.

  • description: string
  • name: string
  • required: boolean
  • type: "string" | "bool" | "int"

    Matches legacy's own param.type (~/LANraragi/templates/plugins.html.tt2's SWITCH param.type — 'string' → a text input, 'bool' → a checkbox/switch, 'int' → a number input). Missing entirely before this — every converted plugin's parameters rendered as a plain, unstyled text input regardless of their real semantics, including boolean toggles like plugins/metadata/mems.ts's own 3 params (real legacy MEMS.pm declares all 3 as type => 'bool', rendered as ON/OFF switches — a plain blank text box here was a real, visible mismatch). Legacy's own unmatched-SWITCH fallback renders a type="color" picker (its source literally comments this default "ayy lmao" — an unintentional quirk, not a real 4th parameter kind any real plugin in this corpus uses); this SDK deliberately defaults absent type to 'string' instead, since every one of this corpus's 31 plugins with declared parameters had none of this field before now and were already correctly rendered/used as plain text inputs — matching legacy's silly color-picker default would have turned every one of those already-working parameters into a broken control instead of leaving them unaffected.

I
ScriptResult

runScript's return shape — deliberately a loose bag of fields (mirrors legacy's own run_script subs, each of which returns a different ad-hoc hash with no shared shape across the three real Scripts plugins) rather than one rigid interface every script must conform to. total/id/error are SourceFinder.pm's own fields; modified/updates are nHentaiSourceConverter.pm's.

  • categories_to_create: { name: string; archive_ids: string[]; }[]

    One static Category to create per discovered subfolder — the host creates each one after runScript returns, same read-compute-return-write-to-host split as updates (foldertocat).

  • delete_old_categories: boolean

    Whether to delete every existing static (non-dynamic) category before creating the new ones — echoes back this call's own customargs[0], since the host applies the deletion itself (foldertocat).

  • elapsed_ms: number

    Wall-clock milliseconds runScript itself spent walking the directory tree and computing groupings — reported back for a direct comparison against the equivalent native Rust endpoint (POST /database/scripts/subfolders-to-categories's own elapsed_ms), which does the identical job. Not a generic field every script plugin is expected to fill in (foldertocat only).

  • error: PluginError
  • id: string

    The matching archive's ID, when total is 1 (urlfinder).

  • modified: number

    Count of archives this call changed (nhsrcconv).

  • total: number

    1 if a match was found, 0 otherwise (urlfinder).

  • updates: ScriptArchiveTags[]

    This call's intended tag rewrites — the host applies each one (same shape as ScriptHostArgs.archives) after runScript returns; a plugin never writes storage directly (nhsrcconv).

I
VersionHistoryEntry

One node of a DownloadResult.version_history chain — a single known revision of the series, as the plugin's site reports it.

  • posted_at: string

    ISO 8601 timestamp the site reports for this revision. The plugin converts from whatever the site's native format is (e-hentai's gdata reports posted as a unix-seconds string). Sole determiner of revision order: there is deliberately no separate "this one is current" flag, since a second source of truth could contradict the sort and leave the host no way to know which to believe.

  • source: string

    This revision's source URL, already normalized through this plugin's own canonicalizeSource (see DownloadResult.version_history's docs — an un-normalized value silently fails to match a stored source: tag).

Type Aliases

T
CanonicalizeSource = (url: string) => string

Optional export, parallel to pluginOptions. Given a URL this plugin's site might expose (any domain alias, with or without query string/trailing slash/scheme), returns the canonical form the host should use for matching against stored source: tags — e.g. e-hentai folding exhentai.org/g.e-hentai.org/forums.e-hentai.org all down to a single e-hentai.org/g/{gid}/{token} form. The host calls this before every source: tag comparison (both for DownloadResult.version_history matching and for plugins/script/sourcefinder.ts's existing lookup) instead of maintaining its own per-site domain-alias table, so this plugin's own domain-alias knowledge lives in the one place that actually has it.

T
Discover = (criteria: DiscoveryCriteria) => Promise<DiscoveryResult>

Optional export, parallel to pluginOptions. Given a subscription's criteria (or a listing URL), returns the candidate works this source currently offers.

T
PluginKind = "metadata" | "login" | "download" | "script"

The four plugin categories the host recognizes (lanrurugi-api::plugins::PLUGIN_CATEGORIES) — also the fixed set of subdirectories under plugins/ (metadata/, login/, download/, script/), and the value upload_plugin trusts only from a freshly-uploaded plugin's own pluginInfo() response (never a client-supplied category) to decide where to file it.

T
RelativeNewerPolicy = "always_overwrite" | "overwrite_if_same_name"

What to do when the incoming download is a newer revision of something already in the library.

T
RelativeOlderPolicy =
"block"
| "warn_then_conflict_menu"
| "silent_then_conflict_menu"

What to do when the incoming download is an older revision than something already in library.

Variables

v
DEFAULT_RELATIVE_NEWER_POLICY: RelativeNewerPolicy

Applied when neither the user nor the plugin states a preference. A download the site itself marks as a later revision of something already in the library is normally meant to replace it; keeping both would leave a superseded duplicate for the user to find and delete by hand. The old archive is only removed after the new revision is safely catalogued, so this default never risks ending up with neither.

v
DEFAULT_RELATIVE_OLDER_POLICY: RelativeOlderPolicy

Applied when neither the user nor the plugin states a preference. Asking first is the safe default for the genuinely surprising case — the user asked for a URL that turns out to be older than what they already have.

plugins/legacy-globals.d.ts

Interfaces

Type Aliases

Variables

v
legacyCompat: { reverse<T>(list: readonly T[]): T[]; chomp(s: string): string; sprintf(
format: string,
...args: unknown[]
): string; userAgent(): LegacyUserAgent; getLogger(
name: string,
category: string
): LegacyLogger; htmlUnescape(s: string): string; parseHtml(
markup: string,
xml?: boolean
): LegacyDomNode; sleep(seconds: number): Promise<void>; getVersion(): { version: string; homepage: string; }; refType(x: unknown): string; trim(s: string | null | undefined): string; fileparse(
path: string,
suffixPattern?: unknown
): [string, string, string]; redis_decode(s: string): string; }
  • chomp(s: string): string

    Perl's chomp($x) mutates $x in place, stripping exactly one trailing "\n" (Perl's default $/ record separator — not "\r\n"). JS can't mutate a caller's local binding through a function call, so converted code must reassign the result itself: x = legacyCompat.chomp(x), mirroring chomp $x;'s own mutating spirit as closely as a pure function can.

  • fileparse(
    path: string,
    suffixPattern?: unknown
    ): [string, string, string]

    File::Basename::fileparse($path, $suffix_pattern) — every real call site in the legacy plugin corpus passes the same "match a trailing dot plus any run of non-dot characters" suffix pattern (i.e. "the last extension"), so the second argument is accepted but ignored rather than genuinely interpreting an arbitrary suffix pattern. Returns [name-without-extension, directory-with-trailing-slash, extension-with-leading-dot], matching Perl's own return order.

  • getLogger(
    name: string,
    category: string
    ): LegacyLogger

    get_logger($name, $category) — see LegacyLogger's own docs for exactly what's covered.

  • getVersion(): { version: string; homepage: string; }

    LANraragi::Utils::Generic::get_version — legacy reads this straight out of its own package.json (version/homepage fields) once and caches it; this returns the equivalent static values for this project instead of trying to read a Rust crate's Cargo.toml from within the Deno sandbox. Update these two literals if Cargo.toml's [workspace.package] version/repository ever changes.

  • htmlUnescape(s: string): string

    Mojo::Util::html_unescape — decodes HTML entities (named: &amp;/&lt;/&quot;/etc., and numeric: &#39;/&#x27;). No native JS equivalent outside a real DOM (a textarea element's .value trick, unavailable in this Deno sandbox — there's no document), so reimplemented directly against the HTML5 entity table's most common names rather than pulling in a full HTML entity library for what the legacy plugin corpus only ever uses on scraped titles/summaries (a handful of the same few entities in practice).

  • parseHtml(
    markup: string,
    xml?: boolean
    ): LegacyDomNode

    Mojo::DOM->new(html) / Mojo::DOM->new->xml(1)->parse(xml) — parses markup and returns its root node. xml toggles case-sensitive tag matching (real XML/custom elements like <Genre> in a ComicInfo.xml file) vs HTML's traditionally case-insensitive tag names; see LegacyDomNode's own docs for what's covered.

  • redis_decode(s: string): string

    LANraragi::Utils::Redis::redis_decode — legacy re-decodes a value read back from Redis as UTF-8, since Perl's own Redis client hands back raw bytes. Deno's Redis client (and every string this dispatcher otherwise touches) is already a proper UTF-8 JS string by the time a plugin sees it, so this is a pure pass-through.

  • refType(x: unknown): string

    Perl's ref($x) builtin — returns "HASH"/"ARRAY" for a hashref/arrayref, or "" for anything else (a plain scalar, undef, etc.), matching Perl's own string-truthiness rule closely enough that ref($x) used as a bare boolean condition (return if ref $x;) works unmodified once translated. Only hashref/arrayref detection — real Perl ref() also recognizes CODE/SCALAR/blessed-object-class-name, none of which the legacy plugin corpus's own use of ref() ever tests for.

  • reverse<T>(list: readonly T[]): T[]

    Perl's reverse(@list) returns a new list; JS's Array.prototype.reverse mutates its receiver in place. Spreads into a fresh array first so converted code doesn't pick up a mutation side effect the original Perl never had.

  • sleep(seconds: number): Promise<void>

    Perl's sleep($seconds) blocks synchronously; JS has no synchronous sleep, so this is a setTimeout-backed Promise instead — every call site must await it (the converter's own sleep mapping in render.rs always emits the await, forcing the enclosing function async the same way an awaited ->get()/->post() Mojo call already does).

  • sprintf(
    format: string,
    ...args: unknown[]
    ): string

    A minimal sprintf covering the format-spec subset actually used across the legacy plugin corpus (%s, %d, %f with optional width/precision) — not a full reimplementation of Perl's considerably larger sprintf grammar.

  • trim(s: string | null | undefined): string

    LANraragi::Utils::String::trim — strips leading/trailing whitespace, returning "" for null/undefined instead of throwing (unlike JS's own String.prototype.trim, which requires a real string receiver).

  • userAgent(): LegacyUserAgent

    Mojo::UserAgent->new — see LegacyUserAgent's own docs for exactly what's covered. Every plugin using this must still declare its own real target host(s) in declared_permissions (constitution Principle IV); this shim runs inside that same already-permission-scoped process, so it's bound by whatever --allow-net grant the plugin itself was started with, same as a plugin calling fetch() directly would be.

v
PluginErrorException: { }

A plugin-authored, structured error — throw this (instead of a plain throw new Error(...)) to report {error_code, data} through an exception rather than a return {error}. error_code doubles as an i18n lookup key (see crates/lanrurugi-plugin/dispatcher/plugin-sdk.ts's PluginError for the full field-meaning docs — this class's fields mirror that interface exactly). The dispatcher's own catch (dispatcher.ts's handleRequest) uses real instanceof against this exact class to extract error_code/data for the Rust host.