npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vmlts/node

v0.1.1

Published

vmlts from Node and Bun: files, prompts and generations on one key

Readme

@vmlts/node

Files, prompts and pictures from Node or Bun, on the one key your app already holds. Read a prompt the operator changes without a deploy, say whether it worked, store a file behind a URL on your own domain, and generate an image or some words from the prompt you just read.

Day 0

One thing comes from whoever runs vmlts for you: an API key. It names your app, it is the only way in, and it reaches every tool your app opted into.

1. Install.

bun add @vmlts/node      # or: npm i @vmlts/node

2. Put the key in the environment.

export VMLTS_TOKEN=k_a3f9...

3. Read a prompt.

import { PromptClient } from "@vmlts/node";

const prompter = new PromptClient();
const prompt = await prompter.use("support/greeting", { unit: userId });

prompt.content                        // the text, as it was served
prompt.render({ name: "Ada" }).text   // with its placeholders filled
prompt.id                             // the answer a rating names

4. Store a file.

import { FilestoreClient } from "@vmlts/node";

const files = new FilestoreClient({ slug: "blog", bucket: "photos" });
const { url } = await files.put("./holiday.mp4");

console.log(url);   // https://cdn.vmlts.com/blog/photos/f7Kq2xBn9pWv/holiday.mp4

The bucket is a namespace inside your app and has to exist already; make one in the dashboard, or with createBucket, or let a write make its own with ensureBucket.

5. Generate a picture.

import { GenClient } from "@vmlts/node";

const gen = new GenClient();
const { output } = await gen.image({
  model: "bfl/flux-2-pro",
  prompt: { key: "poster/hero", variables: { season: "winter" } },
  size: "1024x1024",
});

console.log(output.files[0].url);   // https://cdn.vmlts.com/blog/gen/gj_k3np9dq2rt.png

Prefer settings in code? Every client takes its settings directly, and anything you pass wins over the environment.

Choosing a version

Without a selector the prompt's rollout decides, and unit keeps one caller on one version: pass a user id, a session id, an account id — anything stable — and that caller sees the same wording on every request until the operator moves the split.

await prompter.use("support/greeting", { unit: userId });
await prompter.use("support/greeting", { version: 3 });        // or "v3"
await prompter.use("support/greeting", { label: "concise" });

A pin bypasses the rollout, so a unit beside it is ignored rather than refused. Naming both a version and a label is refused before any request.

Filling the placeholders

A prompt declares the {{placeholders}} its text may use. render fills each one with the value you passed, then the variable's default, then "" when the variable is optional.

// "Hi {{name}}, you are on {{plan}}."   name required, plan defaulting to "free"
prompt.render({ name: "Ada" });   // { text: "Hi Ada, you are on free.", missing: [] }
prompt.render({});                // { text: "Hi {{name}}, you are on free.", missing: ["name"] }

A required variable with neither a value nor a default keeps its placeholder and is named in missing. Hi , you are on free. reads like working text; {{name}} does not.

Saying whether it worked

A prompt has versions, and the dashboard shows which of them earns "good". Rate the answer you were served, from the object you already hold.

const prompt = await prompter.use("support/greeting", { unit: userId });

await prompt.feedback("good");
await prompt.feedback("bad", "it named the wrong product");

prompt.feedback names the answer for you, so nothing in your code carries an id around. When the prompt is off there is nothing to rate: the call sends nothing and answers null, and you write no branch for it.

Rendered your own text, or read the prompt in another process? Name the version yourself, and add requestId so a retry of your own request does not count the rating twice.

await prompter.feedback({ useId, rating: "good" });
await prompter.feedback({ key: "support/greeting", version: 3, rating: "bad", requestId });
await prompter.feedback({
  versionId,
  rating: "good",
  comment: "the caller replied in one message",
  unit: userId,
  variables: { name: "Ada" },
  ran: { model: "openai/gpt-5", output: text },
});

ran is what you ran the words through and what came back, so the dashboard shows the answer beside the verdict.

One answer takes one verdict: the same one again is the rating you already sent, and a different one is refused as rating_conflict. A rating carrying a requestId is retried like any other call; one without it is sent once, because a second attempt would be a second rating.

When the prompt is off

An operator can turn a prompt off, and a prompt can have nothing rolled out. Either way use answers rather than throwing: you get the prompt's fallback text with enabled: false, version: null and id: null.

const prompt = await prompter.use("support/greeting");
prompt.content   // the fallback text. Always something to send.

It throws for a key no prompt has, a key the service refused, and a transport failure. Every failure is a VmltsError carrying code, status, message and requestId — quote the requestId when you ask what happened.

Uploading

put takes what a server holds: a path on disk, bytes in memory, a Blob, or a source you built yourself. A file past 64 MiB is split into parts and sent several at a time, and the pool widens while a wider one still moves bytes faster.

await files.put("./holiday.mp4");                             // streamed off disk
await files.put(pdf, { key: "reports/2026-03.pdf" });         // a Uint8Array
await files.put(await answer.blob(), { key: "cat.jpg" });

await files.put("./holiday.mp4", {
  onProgress: ({ loaded, total }) => bar(loaded / total),
  signal: stopping.signal,
});

With no key one is generated: a random prefix and the filename, so two people uploading invoice.pdf do not overwrite each other. put returns once the object is readable at the URL it hands back, and cancelling abandons the upload rather than leaving parts behind.

ttl gives an object a lifetime in seconds, on the client or on one put. ttl: 0 says forever out loud, overriding whatever default the bucket carries.

The rest of the bucket

for await (const object of files.list({ prefix: "2026/" })) console.log(object.key);
await files.listAll();                    // the same objects, collected
await files.delete("cat.jpg");

await files.listBuckets();
await files.createBucket("invoices");
await files.deleteBucket("invoices");     // refused while it still holds files

Keys are relative to the bucket in both directions: it goes in front on the way out and comes off what comes back. prefix is a second setting under it, for the tenant, so a client holding one cannot reach a neighbour's objects.

Linking to a file

url builds the public URL for a key without asking the service, and takes the read path's image options. signedUrl is the same URL with a signature on the end, which opens a private object for that many seconds — hand one to a page, and the page reads it like any other URL.

files.url("cat.jpg");
files.url("cat.jpg", { width: 400, fit: "cover", format: "webp" });
files.url("cat.jpg", { view: "thumb" });          // a named size the service expands

await files.signedUrl("2026-03.pdf", 60);

Both need slug, the app your key belongs to, because it is the first segment of every read URL. Signing needs keyId beside the token: the id of the key the token is the secret of, off the same row in the dashboard.

Generating

image makes a picture and stores it; text answers words and stores nothing. Both hold the connection while the provider works, so the answer you get back is the finished thing: no handle to poll, no webhook to receive.

const picture = await gen.image({
  model: "bfl/flux-2-pro",
  prompt: "a necklace on white",
  size: "1024x1024",
  n: 2,
  output_format: "webp",
});

const said = await gen.text({
  model: "openai/gpt-5.1-mini",
  messages: [{ role: "user", content: "say hello" }],
  system: "be brief",
  max_tokens: 64,
});

Every answer names the model that made it, where the bytes went and where the time went. The five phases of timings add up to total_ms exactly, so you can show a breakdown without an "other" bucket.

picture.output.files[0].url       // on your own domain, and it does not expire
picture.timings.generating_ms     // the provider working
said.output.text
said.output.tool_calls            // what the model asked you to run

gen runs no tool. It hands the calls back whole, and you run them and send the results as another ordinary request: the assistant turn with its tool_calls, then a tool turn per tool_call_id. There is no session.

The prompt behind the picture

prompt takes the words, or the prompter prompt they live in. gen resolves it itself, so the rollout is decided in one place and there is no second request.

await gen.image({
  model: "bfl/flux-2-pro",
  prompt: { key: "poster/hero", variables: { season: "winter" }, unit: userId },
});

The same object goes wherever words go, content on a conversation turn included. version, label, variables and unit mean what they mean on PromptClient.use.

The answer names the wording it used, so you can rate it:

const made = await gen.image({ model, prompt: { key: "poster/hero" } });

made.prompt.text          // the rendered words, exactly as the provider read them
await prompter.feedback({ useId: made.prompt.useId, rating: "good" });

A conversation answers a list instead, one entry per turn that named a prompt, each carrying the at of its turn. A request that wrote its own words answers no prompt at all.

gen re-resolves nothing, so freeze prompt.text and send a plain string when the next turn needs the same wording.

A prompt the operator switched off is refused as prompt_disabled, and never generated from its fallback: prompter serves fallback text because text costs nothing to send, and a picture of a fallback is billed.

Where a picture lands

The picture goes into filestore's namespace, under your app, and filestore's read path serves it.

  https://cdn.vmlts.com / blog / renders / tenant-1 / posters/hero.png
                          app    bucket    prefix     key
const gen = new GenClient({ bucket: "renders", prefix: tenantId });
await gen.image({ model, prompt, key: "posters/hero.png" });

All three are optional: the bucket defaults to gen and is made if your app has none, and the key is minted from the generation's id and the type the provider said the bytes are. A bucket or prefix on the client rides on image alone — a conversation stores nothing, and a preset was stored with the placement its operator chose — and whatever the call names beats it.

Which models you may reach

for (const model of await gen.models()) console.log(model.id, model.kind, model.limits);

The roster is filtered by your key's policies, not refused against them: it is what this key may invoke, and an empty list is an answer rather than an error. The limits are what each model will take, so nothing here carries a copy of them.

Running a preset

A preset is a gen body an operator wrote once and named, so what your app generates can change without a deploy of your app. You post the values it declares and nothing else; the model, the prompt, the size and the placement stayed with them.

const made = await gen.custom("necklace", { style: "studio" });
if (made.kind === "image") console.log(made.output.files[0].url);

Which kind it makes was decided when it was written, so branch on kind. The dashboard is where the parameters and their descriptions are read.

When a generation fails

A generation is sent once. gen has already retried the provider behind the connection, so a retry from here is not a second try at a failed thing: it is a second generation, and may be a second charge for one picture. Ask for more only when you can afford the picture twice.

await gen.image({ model, prompt, attempts: 3 });

Every failure is a VmltsError carrying code, status, message and requestId — quote the requestId when you ask what happened. code is what to branch on: provider_refused is the provider reading your ask and saying no, provider_failed is it not answering at all, not_permitted is a model or a bucket your key's policies do not allow, and daily_limit_reached is this key out of generations for the day.

A mistake the request alone shows — a field neither shape has, a size that is not a pair of edges, a prompt reference naming both a version and a label — is refused before anything goes out, so a typo costs no round trip.

deadlineMs gives up on a generation, and defaults to 60 seconds: above gen's own 50-second budget, so its answer wins the race. Cut it shorter and a picture you are billed for becomes a failure you cannot look up, because you never learned its id.

Settings

Each client takes its own, and all three read the key from VMLTS_TOKEN.

| PromptClient | Default | Environment variable | |---|---|---| | baseUrl | https://prompts.vmlts.com | VMLTS_PROMPT_URL | | token | none; required unless the base URL forwards | VMLTS_TOKEN | | unit | none; each call draws again | none | | cacheTtl | 0 seconds, no cache across requests | none | | attempts | 3 tries, the first one included | none |

baseUrl may be a path on your own origin that forwards to vmlts with the key attached. A service that already routes its outbound calls through a gateway holds the key in one place and passes none here.

A cacheTtl above 0 serves one answer out of memory for that many seconds, prompt.id included. Rate what you resolved: two ratings against one id are one rating. enabled is a kill switch, so nothing is cached by default.

| FilestoreClient | Default | Environment variable | |---|---|---| | baseUrl | https://cdn.vmlts.com | VMLTS_FS_URL | | token | none; required unless the base URL forwards | VMLTS_TOKEN | | slug | none; url() fails without it | none | | keyId | none; signedUrl() fails without it | VMLTS_FS_KEY_ID | | bucket | none; keys then spell their own | VMLTS_FS_BUCKET | | prefix | none; the tenant, under the bucket | VMLTS_FS_PREFIX | | ensureBucket | off | VMLTS_FS_ENSURE_BUCKET | | ttl | none; the bucket's default, or forever | VMLTS_FS_TTL | | partSize | worked out from the file | VMLTS_FS_PART_SIZE | | concurrency | measured while the upload runs | VMLTS_FS_CONCURRENCY |

slug has no environment name because it is the one setting the service never sees: every write reads the app off the key, and only url needs to spell it.

| GenClient | Default | Environment variable | |---|---|---| | baseUrl | https://gen.vmlts.com | VMLTS_GEN_URL | | token | none; required unless the base URL forwards | VMLTS_TOKEN | | bucket | none; the picture lands in gen | none | | prefix | none; the tenant, under the bucket | none | | attempts | 1 for a generation, 3 for models() | none | | deadlineMs | 60000, and 0 for none | none |

The bucket and the prefix take no environment name: where a picture lands is what a generation is for and the call names it, and the client setting is for an app that writes every picture into one place. The other two are behaviour rather than an address.