@entrinsik/vite-plugin-informer
Vite plugin and deploy tool for Informer App development: a local dev server wired to a live Informer, and a deploy pipeline that assembles your project and ships it to an App's library.
Quick start
npm install -g @entrinsik/informer-cli
inf login <your-informer-url> # once: the sign-in dev and deploy use
npm create vite@latest my-app -- --template react
cd my-app
npm install -D @entrinsik/vite-plugin-informer
npx informer-init # wires vite config, informer.yaml, deploy scripts
npm run dev # local dev against the server you signed in to
npm run deploy # assemble + upload + deploy (or: inf deploy, which asks first)
No .env is needed: with no credential in the environment or .env, the plugin asks the Informer CLI (inf auth token) for the signed-in server and a current token, and a dev server renews it as it runs. INFORMER_URL with INFORMER_API_KEY (or INFORMER_USER and INFORMER_PASS) in .env still work, and win when set; INFORMER_URL alone picks which of your sign-ins to use.
informer-init leaves an existing @entrinsik/vite-plugin-informer entry alone, and writes one only when the dependency is missing — in which case it uses its own version (2.12.0 and later; before that it wrote ^1.0.0). A project scaffolded earlier still carries ^1.0.0, which resolves to 1.0.5 and predates everything below: bump it by hand.
What a deploy ships
The assembler uploads your built frontend plus these source trees, matching how an App's library is structured in Informer:
| Folder | Becomes |
|---|---|
server/ | API routes, dispatched under the App's /api surface |
webhooks/ | Publicly callable webhook routes |
tools/ | AI tools, private to the App's agents |
mcp/ | Tools exposed over the App's MCP endpoint |
channels/ | Live channel handlers: join / joined / leave and event-named handlers for channel.send(), or a channel actor (config.actor: one live instance per channel, with start / tick / stop / snapshot); npm run dev runs both locally, and the channels: block in informer.yaml declares relays only (each entry needs on) |
migrations/ | Workspace database migrations, run in order on deploy |
embeddings/ | Declarative embedding use cases (see below) |
lib/, shared/ | Modules importable by the trees above |
informer.yaml declares dependencies, raw API access, roles, and (optionally) a minimum Informer version.
Server compatibility
A deploy begins by asking the target server what it is (GET /api/about, one unauthenticated request) and adapts to the answer, so one version of this plugin can drive a fleet on mixed releases.
Everything below needs Informer 2026.1.3: App Channels (channels/, the channels: block, broadcast()), the embedding pump (embeddings/, embed()), staged uploads/downloads (uploads/downloads on the handler bag, __INFORMER__.upload()), and the platform descriptor itself.
Against an older server the CLI:
- Leaves
channels/andembeddings/on your disk. Those releases don't recognise the folders as server-side, so anything uploaded there is served to any App viewer — your workspace SQL and channel authorization logic included. The rest of the App deploys normally; the warning names what was skipped and why. - Names every feature that would be inert, so a green deploy can't be mistaken for a working one.
- Enforces
requires: { informer: … }itself. Releases before 2026.1.3 ignore the block, so without this an App that declared a floor would deploy clean onto a server that can't run it. The deploy is refused before the App's library is touched.
A server whose version isn't comparable (a dev build, or an /about behind a proxy) is treated as current and warned about — gating it would break deploys to builds made from source.
App Channels phase 2 (plugin ≥ 2.11.0: channel.send(), the joined export and event-named exports in channels/ files, wildcard subscriptions, frame seq and replay, connected) needs a 2026.1.3 server that carries I5-13027. The version probe cannot tell such a server from an earlier 2026.1.3 build, so the deploy is not gated: an earlier build refuses a channels/ file that exports joined or an event name, and accepts a channels: entry without on that a current server rejects. The dev server runs the phase 2 contract regardless; plugin 2.12.0 aligns its channel semantics with the server's (wildcard gating, replay expiry, and the per-user send budget), so on 2.11.0 the dev mirror has the phase 2 surface but not all of its behavior.
Channel actors (plugin ≥ 2.13.0: config.actor files, request.member, __INFORMER__.serverNow()) need a server that reports platform.capabilities.channelActors; on one that does not, the same file runs as an ordinary channel handler, one fresh module per message. Under npm run dev each concrete channel gets its own module instance, calls run one at a time, tick() runs on a timer, and the actor stops idleMs after its last page leaves. snapshot() is kept in memory, so editing a file under channels/, shared/, lib/ or server/ restarts the running actors on the new code from their snapshots with the same pages subscribed: a game in progress survives the edit. The server's cluster, leases, billing and broadcast limits have no dev counterpart.
Feature-detect at runtime with optional chaining, since platform is absent entirely before 2026.1.3:
if (platform?.capabilities?.channels) broadcast('tickets', { … });
A marketplace publish is unaffected: the archive always carries every source tree, because the server that installs it isn't known at publish time.
Embeddings
Apps can maintain vector embeddings over their own workspace data declaratively. You ship an embeddings/ folder with one file per use case; the Informer platform acts as an embedding pump that asks your App what is pending, chunks and embeds the content in billed batches, and hands the vectors back for your App to store in its own workspace tables. You never call an embedding provider yourself, and the platform never keeps a copy of your corpus — your own tables are the ledger.
Each use case file exports GET and POST (a missing handler fails the deploy) and, optionally, a config literal; omitted keys take the defaults below, and a use case with no triggers in its config only runs on deploy and on a manual run:
// embeddings/tickets.js
export const config = { chunking: 'prose', on: ['ticket.created'], cron: '0 * * * *', revision: 1 };
// GET: the rows that still need embedding. The query IS the watermark:
// never embedded, stale, or written under an older revision.
export async function GET({ query, batch }) {
return await query(`
SELECT t.id, t.subject || ' ' || t.body AS content
FROM tickets t
LEFT JOIN ticket_embeddings e ON e.ticket_id = t.id AND e.seq = 0
WHERE e.ticket_id IS NULL OR e.embedded_at < t.updated_at OR e.revision <> $2
ORDER BY t.id
LIMIT $1
`, [batch.limit, batch.revision]);
}
// POST: store the finished vectors wherever your migrations put them.
export async function POST({ query, batch }) {
for (const doc of batch.docs) {
await query('DELETE FROM ticket_embeddings WHERE ticket_id = $1', [doc.id]);
for (const c of doc.chunks) {
await query(
'INSERT INTO ticket_embeddings (ticket_id, seq, embedding, content, revision) VALUES ($1, $2, $3::vector, $4, $5)',
[doc.id, c.seq, JSON.stringify(c.embedding), c.content, batch.revision]
);
}
}
return { stored: batch.docs.length };
}
How it works, briefly:
- Your storage is the ledger. Because
GETanti-joins your own embedding table, the pump is idempotent and crash-tolerant: a run that dies mid-way finds the same rows pending next time. - Triggers converge. Deploy pokes each use case (initial backfill),
emit()events matchingon:poke,crondesugars into a scheduled poke, and the platform sweep runs whatever is poked. A manual run claims the same single-flight lease directly, so nothing double-embeds. - Failures are bounded. A run that fails for a reason the next attempt might not hit (a provider blip, a handler timeout) retries with a doubling delay and is parked after five consecutive failures; a run that cannot succeed as-is (a rejected key, an exhausted budget, a contract violation) is parked at once. Every park keeps the error on the use case's status row; the next trigger gives it one more attempt, and a successful run resets the count.
revisionis the invalidation. The string the pump hands you couples your config (chunking profile, budgets,config.revision) to the resolved platform embedding model. Store it beside each vector and compare it inGET: bumping the config, changing chunking, or an admin repointing the embedding model all surface as pending re-embed work automatically.- Unembeddable documents are tombstoned platform-side by content hash and re-reported with
skipped: trueon every run — stamp them in your own table and exclude them fromGET. - Workspace pgvector. Apps with the embeddings capability get the
vectorextension provisioned where the server may create extensions (otherwise the deploy log names the cause and a migration using thevectortype fails), so migrations can declare realvector(...)columns;ON DELETE CASCADEforeign keys are the cleanup story. - Query time. Every handler bag (
server/,webhooks/,tools/,mcp/) getsembed(name, text), which returns{ embedding, revision }.namemust be a declared use case. Filter on the revision as well as ordering by distance — an admin repointing the embedding model puts your query vectors in a new space while the stored rows are still in the old one, and an unfilteredORDER BYthen returns near-random neighbours with no error:
const { embedding, revision } = await embed('tickets', q);
const hits = await query(
`SELECT ticket_id, content
FROM ticket_embeddings
WHERE revision = $2
ORDER BY embedding <=> $1::vector
LIMIT 10`,
[JSON.stringify(embedding), revision]
);
- Billing. Embedding calls bill to the App, one usage entry per batch of up to 128 chunks regardless of how many provider requests run underneath; pump compute meters against the App's compute budget.
configis read as a literal. Deploy liftsexport const config = { … }out of the source and evaluates it with none of the file's imports in scope, so it must be self-contained: no imported constants, no spreads of imported objects, noexport { config }re-export. Other modules may import the literal from the use-case file; the reverse fails the deploy. The same self-contained-literal rule applies toexport const config,schema, anddescriptioninserver/,webhooks/,tools/, andmcp/; there aconfigviolation fails the deploy while aschemaordescriptionviolation is a deploy warning (nothing is published for that export).
Dev loop
npm run deploy uploads embeddings/ like any other source tree — there is no manifest block to add (plugin ≥ 2.10.0; earlier versions never upload the folder, so the use case silently does not exist on the server). npm run dev never runs the pump, and its embed() throws an explanation rather than embedding (the dev mirror reports platform.capabilities.embeddings: false), so a search route is exercised against a deployed App. Plugin ≥ 2.12.0 can opt in: informer({ mock: { platform: { capabilities: { embeddings: true } } } }) binds the dev embed() to the deployed App's _embed route on the configured server (addressed by package.json informer.id; the dev credentials need write access to that App, and each call is billed to it), so the query vector under npm run dev is the deployed one — same model, same revision. The corpus is not: query() still reads the dev workspace datasource, which the pump never writes to, so a search route compares a deployed vector against local rows. Feature-detect with platform?.capabilities?.embeddings — typeof embed === 'function' is true on both, since the binding exists either way. Then, in the App admin panel, the Embeddings tab lists each use case with its pump status (queued, running, up to date, last run, last error, skipped docs) and a Run now action; the same surface exists as API routes (GET /apps/{id}/embeddings, POST /apps/{id}/embeddings/{name}/_run). Deploy, run, read the error, fix, repeat.
Upgrading an App that already has an embeddings/ folder
embeddings/ is a server-side folder from Informer 2026.1.3 on, like server/: its files are uploaded with the library, never served to browsers, and scanned as pump handlers. An App that kept anything else there (assets, data files) loses those files in the browser after redeploying with plugin 2.10.0 or later. A .js file there that exports GET or POST but not both fails the deploy; one that exports neither is never scanned as a use case at all and lands as a warning on an otherwise successful deploy. Move such content elsewhere before upgrading; the deploy names every stray entry as a warning.
Older Informer releases
The embeddings/ folder, embed(), and the pump routes exist only on Informer releases that ship the feature. An App that must also run on older servers feature-detects with optional chaining (platform?.capabilities?.embeddings on the handler bag, window.__INFORMER__.platform?.capabilities?.embeddings in the browser) — releases before 2026.1.3 inject no platform descriptor at all, so a bare platform.capabilities.embeddings throws there instead of reporting false. It gates its vector DDL with a -- requires: embeddings header so the migration is skipped where pgvector is absent, and states a floor in informer.yaml (requires: { informer: '>=…' }) only if it cannot work without the feature.
Full reference: the Embeddings article under Developer → API → App in the Informer docs.