Limits and engine quirks¶
Every number in one place¶
| What | Limit |
|---|---|
| Manifest / entry file / icon | 16 KB / 1 MB / 128 KB |
| Memory / stack, per plugin | 64 MB / 1 MB |
| Time per call | search 15 s; home, browse, episodes, resolve 20 s each (resolve of a plugin Kino itself generates, from a Nuvio scraper or a Stremio addon: 75 s); liveCategories, liveChannels, guide 20 s each; liveSearch 15 s; subtitles 10 s; track 10 s (apiVersion 7); segments 8 s (apiVersion 7); meta 6 s (apiVersion 6; past it, no answer); section, categories 20 s each (apiVersion 6); migrate 10 s; sign 1.5 s (and 3 s counting its wait); counting all your fetches and sleeps together, but not the time the person spends answering a host question for that call |
| Loading the module (its top level) | 10 s |
| Idle sandbox | closed after 5 minutes without calls |
| Consecutive timeouts | 3 in a row and Kino disables the plugin ("No responde") |
kino.fetch |
https only (or the person's own server as typed, or http on a host declared insecureHttp); 15 s default, 30 s maximum; response body at most 5 MB; the request (URL, headers and body) at most 1,048,576 characters; at most 60 requests per call, every hop counted, refused ones included (250 for a plugin converted from a Nuvio scraper); at most 6 fetches in flight at once; at most 3 host questions per call; at most 10 redirects per request |
| Cookies | 50 per domain, 64 KB in total per plugin |
kino.storage |
256 KB per plugin; an entry's optional ttlMs is 1..2,592,000,000 ms (30 days) |
kino.sleep |
0 to 5,000 ms per call |
kino.meta (Kino 0.9.53) |
at most 30 calls a minute per plugin; 8 s at most (each other meta plugin 6 s), inside your call's own limit; the query at most 4,096 characters; the answer at most 1,000,000 characters; cached 30 minutes |
kino.tmdb (Kino 0.9.53) |
at most 40 calls per 10 s per plugin; on Kino's own key at most 20 per 10 s per plugin and 60 per 10 s for all plugins (then the person's key, else the cache or rate_limited); 15 s per call; a body at most 2 MB; at most 20 params of at most 500 characters; cached 10 minutes (bodies up to 512 KB); not counted in kino.fetch's requests per call |
kino.crypto |
data at most 5 MB per call; PBKDF2 at most 100,000 iterations and 64-byte keys; randomBytes at most 1,024 |
kino.log / console.* |
2,000 characters per message; when a call of a plugin whose manifest declares telemetry fails, its last 30 lines (each cut at 300 characters, scrubbed, 2,048 characters in all) go with the failure report |
| What a function returns | at most 2,000,000 characters once turned into JSON |
| Results | search 100 items; home 20 rows of 60; browse 100 per page; episodes 5,000 (and 50 seasons); ref 4,096 characters; next 2,048 characters; id matches ^[A-Za-z0-9._~-]{1,128}$ |
| Live channels (apiVersion 3) | liveCategories 200; liveChannels 500 per page, 10 pages at first and 5 more per scroll, 10,000 channels (200 pages) per category; liveSearch 100 channels, asked from 2 characters; guide 50 channels and 24 h per call, 100 entries per channel; number 1..9999 |
| Tracking (apiVersion 7) | progress at most every 5 minutes of playback; watched once, with 3 minutes or less left and at least 90% played; at most 200 events waiting per plugin; an event not delivered within 7 days is dropped; a retryable failure waits 30 s, doubling up to 6 h, 12 tries at most |
| Settings | at most 12 with a value, plus at most 16 section/status/action (apiVersion 6); text 500, url 2,048, password 500 characters |
| Error messages | your kino.error message is a detail for the log, cut at 200 characters; a userMessage for the person is at most 160 |
hosts |
at least 1 entry, no upper limit from Kino 0.9.45 (only the manifest's 16 KB; Kino 0.9.44 and older refuse more than 20); from apiVersion 2, none ([]) when a url setting exists |
secrets (apiVersion 4) |
at most 16; names match ^[A-Za-z][A-Za-z0-9_]{0,31}$; a value is 1..4,096 bytes (1..8,192 from apiVersion 6); from apiVersion 6 a cipher key may be typed: { seal, use: "cipher-key", encoding: "hex" | "base64" }, 16/24/32 bytes |
The 60 requests of kino.fetch count every hop, refused ones included (a plugin converted from a
Nuvio scraper gets 250); at most 6 of your fetches are in flight at once, and one call
asks the person about at most 3 hosts (details).
From apiVersion 6, also:
| What | Limit |
|---|---|
| Stream | alternatives 8 (lazy and concrete together); label 48 characters; a lazy copy's ref 512 characters; alternateHosts 6; signContext 4,096 characters; 3 resolve retries per signed playback |
| Lazy copies | the automatic fallback waits at most 20 s for one copy's resolve; a copy the person picked gets the whole resolve limit; a download's copy choice probes within 30 s in all |
Hidden browser ("browser": true or "pages") |
resolve 75 s; one page at a time in the whole app; kino.browser.capture timeoutMs 1..25,000 (default 18,000), at most 8 media and 10 subtitles, 12 headers per media; kino.browser.page ("pages" only) timeoutMs 1..25,000 (default 15,000), 20 reads a minute per plugin, HTML at most 2,000,000 characters. See Hidden browser |
subtitles() (any apiVersion) |
10 s; 30 tracks kept, 15 listed per plugin; label 60 characters |
meta() |
6 s per plugin; the first answer in install order is kept 30 minutes; from Kino 0.9.51, ratings 6 (one per source), cast 20 (name and character 60 characters) |
segments() (apiVersion 7) |
8 s, and Kino waits at most 12 s; 100 entries read, 10 kept; each at least 1 s long, with an end at most 5 s past the length; the answer kept for the session per title, episode and length (10 s); asked again after 2 minutes when every plugin failed. See segments |
| Settings form | settingsStatus 10 s, action 30 s, validateSettings 20 s; status 200 characters, an action's message 300, confirm 120, a field error 200; clearSettings 12 keys |
| Setting fields (every apiVersion) | key ^[a-z][a-zA-Z0-9_]{0,31}$; label 40 characters; hint 80 (300 on a section from Kino 0.9.51); select 1..20 options, each value and label 40; list (apiVersion 4) max 1..50 entries (default 20), 1..4 fields of type text or url. See The settings form |
| Section and categories | section label 20 characters; 8 tabs of 24 characters; hero text 300; categories 24 tiles with 40-character titles |
kino.crypto key pairs |
64 live private keys per runtime; a signature at most 512 bytes |
kino.log.report |
one report per plugin and area an hour, 3 per plugin until Kino restarts; area at most 24 characters |
telemetry: "verbose" |
60 events per plugin until Kino restarts, one a minute per area |
How your code lives¶
- One call at a time. Calls to the same plugin run one after another. The sandbox is reused
between calls, but Kino throws it away after 5 idle minutes, after a timeout, when a call is
cancelled (for example a newer search replaces an older one), and when the plugin is updated or
disabled. Module-level variables are a cache at best: keep anything that must survive in
kino.storage. - Load-time code. When it installs your plugin, Kino loads the module once in a throwaway sandbox without network access, to check that every declared capability is an exported function. Keep the top level to declarations: a network call there fails, and the install with it.
- Errors reach people. If your function throws, that call fails and the person sees an error that
names your plugin, and the text of your
Errorcan be part of it. Write those messages for a person, in Spanish, short. - Runaway code. Running out of memory or stack fails the call. A synchronous infinite loop
(
while (true) {}) cannot be interrupted: at the time limit Kino stops waiting for the call and discards the sandbox, but the loop keeps spinning on its own thread until it ends, which for a real infinite loop means until the app is closed. Three timeouts in a row disable the plugin. - App closed during a call. A crash inside the engine, or being killed for memory, can take the whole app down mid-call, and nothing in-process can catch that. Kino notices at the next start: whichever plugins were mid-call at that moment each get an unclean exit counted against them — including a healthy plugin that simply happened to be running at the same time, not only the one that actually caused the crash. Two unclean exits in a row for the same plugin, with no call finishing normally in between, switch it off ("No responde") exactly like three timeouts in a row; a call that completes normally resets its count.
The engine is not Node and not a browser¶
Plugins run in QuickJS. It handles modern JavaScript: async/await, classes with fields, ?. and
??, regular expressions with lookbehind, named groups and \p{L} under the u flag, template
literals, spread, replaceAll, Array.prototype.at and flat, Object.fromEntries,
Promise.allSettled, Map, Set, BigInt. It does not have the platform around it:
- Missing globals (
typeofis"undefined"inside Kino):setTimeout,setInterval,setImmediate,queueMicrotask,Buffer,process,require,fetch,AbortController,structuredClone,performance,crypto,WeakRefandIntl. Usekino.sleepto wait,kino.fetchinstead offetchandkino.cryptoinstead ofcrypto.URL,URLSearchParams,atob,btoa,TextEncoder,TextDecoderandconsoledo exist: Kino provides them. - Node has almost all of those, so code that runs fine under the Node kit can still fail in Kino. Before you publish, search your file for the names above.
- Locale-aware methods do not localize:
localeCompareignores its locale and options (so{ numeric: true }and{ sensitivity: "base" }do nothing; it compares code units), and(1234.5).toLocaleString("es-CO")gives"1234.5". Write the comparison you need; the reference plugin has a smallnatural()for numbered names. - Keep function names short. A function name of millions of characters makes the engine's
native code crash the whole app. As a best-effort guard,
kino.*,console.*, the web globals and the other functions Kino provides are frozen, and on any functionObject.defineProperty,Object.defineProperties,Reflect.definePropertyand__defineGetter__/__defineSetter__refuse to setnameto a string longer than 1000 characters, to a getter or setter, or to make it writable: they throw aTypeError(Reflect.definePropertyreturnsfalse). The guard is not airtight (a huge computed key still names a function); a plugin that crashes the app anyway is switched off (see "App closed during a call" above). Settingnameon ordinary objects, andthis.name = "MyError"in anErrorsubclass, work as usual.
Splitting your code across files¶
Kino loads exactly one file (the manifest's entry), and the engine has no require and no
module resolver, so an import from plugin.js to a second file has nothing to resolve against on
the device. That does not mean you must write the whole plugin in one file -- just that the file you
publish has to be the finished, single-file result.
Write it split, normally, then bundle it before you publish:
src/
animeav1.js a helper module
plugin.js the entry point; imports from animeav1.js
kino-plugin.json
package.json
// src/animeav1.js
export async function searchAnimeAV1(query) {
const res = await kino.fetch(`https://animeav1.com/api/search?q=${encodeURIComponent(query.q)}`);
if (!res.ok) throw new Error("animeav1 respondió " + res.status);
return res.json().results.map((r) => ({ id: r.slug, ref: r.slug, title: r.title, kind: "series", poster: r.image }));
}
// src/plugin.js -- this import is fine: it runs through the bundler, never on the device
import { searchAnimeAV1 } from "./animeav1.js";
export async function search(query) {
return searchAnimeAV1(query);
}
Bundle with esbuild (npm i -D esbuild), targeting ES module output
(Kino runs the published file as one):
npx esbuild src/plugin.js --bundle --format=esm --outfile=plugin.js
plugin.js at the repo root is what comes out of that command, with src/animeav1.js inlined into
it and its export async function search intact -- that is the file entry names and the one Kino
fetches. Add it as an npm script
("build": "esbuild src/plugin.js --bundle --format=esm --outfile=plugin.js") and run it before
every sdk/ test or publish. Rollup and webpack work the same way; esbuild needs the least
configuration for a plugin this size.
The trap: a rejection nobody is listening to yet (Kino 0.9.49 and older)¶
From Kino 0.9.50 a rejection behaves as in Node: a throw inside an async function is caught
by the caller's try/catch, .catch(), Promise.all or Promise.allSettled, however early it
happens -- even before the function's first await, and even when the handler is attached a few
awaits later. What still fails the call is a rejection nobody ever handles: one still without a
handler once the plugin is only waiting on Kino (a kino.fetch, a kino.sleep, ...). For example a
helper called without await that throws, or a promise you keep and only await after a
kino.fetch. The call then fails with that error, as Node would stop with an unhandledRejection.
Await what you start, or give it a .catch() right away.
Kino 0.9.49 and older abort the whole call when a promise is rejected before anything has a
handler on it, even if your code is inside try/catch. The Node kit cannot show you this. If your
plugin must work there too (people update late), follow these rules:
- Aborts the call: a
throwinside anasyncfunction before its firstawait, while the caller is wrapped intry/catch. A.catch()on that call, orPromise.all/Promise.allSettledaround it, do not rescue it either. Also aborts:new Promise((_, reject) => reject(e))rejected right away, andreturn Promise.reject(e)from anasyncfunction. - Is caught normally: a
throwafter anyawait(evenawait null;), a rejection coming fromkino.fetchorkino.sleep(for example a refused host), andawait Promise.reject(e)orPromise.reject(e).catch(...)(those builds delayPromise.rejectby one tick so a handler can attach in time). - A synchronous
kino.*call that fails (kino.storage.setover 256 KB, akino.cryptoerror, a selectorkino.html.selectrefuses) is caught normally from Kino 0.9.50. Kino 0.9.49 and older ended the whole call there, even insidetry/catch(Errors your code can catch). - If nobody catches the error anyway, it is harmless: the call fails with that error either way, and
a
kino.errorcode still reaches the person correctly. - Nuvio-converted scrapers get a workaround: when Kino converts a Nuvio scraper it
rewrites the async helpers bundlers emit (esbuild's
__async, TypeScript's__awaiter, Babel's_asyncToGenerator) so a transpiled function's body starts one tick later, and a throw before its firstawaitis caught normally. A nativeasyncfunction (yours, or an untranspiled scraper's) still needs theawaitbefore anything that can throw.
So, for those builds, in a helper that a caller may wrap in try/catch, do the await first and
validate afterwards:
// Wrong before Kino 0.9.50: this throw is NOT caught by the caller's try/catch; it aborts the whole call.
async function getJson(url) {
if (!url.startsWith("https://")) throw new Error("dirección inválida");
const r = await kino.fetch(url);
return r.json();
}
// Right everywhere: the first await comes before anything that can throw.
async function getJson(url) {
const r = await kino.fetch(url);
if (!r.ok) throw new Error("archive.org respondió " + r.status);
return r.json();
}
(If a helper has nothing to await, start it with await null;, or check the input in the caller
before it calls the helper. The examples on this site do, so they run on older builds too.)