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/react

v0.1.1

Published

vmlts from React: files, prompts and generations on one key

Readme

@vmlts/react

Read a prompt, upload a file and generate a picture from a React application, with one API key. The operator changes the words in the dashboard, and your application sees them without a deploy. React 18 and up.

Day 0

One thing comes from whoever runs vmlts for you: an API key. It names your app, and it stays on your server.

1. Install.

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

2. Add one route to your backend, which forwards to prompter.

The browser never holds the API key, so the browser never talks to prompter directly. It talks to your own origin, and your backend attaches the key.

  browser                            your backend              prompt Worker
  ───────                            ────────────              ─────────────
  GET /api/vmlts/prompt/v1/resolve/support/greeting
    ├──── same origin ────────────►  add x-api-key
    │     no token                   ├── GET /v1/resolve/... ──►  the prompt
    ◄──────────────────────────────  ◄── {key, content, ...} ──

Anything under /api/vmlts/prompt/v1/ forwards unchanged: same paths, same query parameters.

// One route. Hono, Express and a Next.js route handler all shape the same.
app.all("/api/vmlts/prompt/v1/*", (c) => {
  const url = new URL(c.req.url);
  url.host = "prompts.vmlts.com";
  url.pathname = url.pathname.replace("/api/vmlts/prompt", "");
  return fetch(new Request(url, c.req.raw), {
    headers: { ...c.req.header(), "x-api-key": process.env.VMLTS_TOKEN! },
  });
});

That route is also where you decide which prompts a visitor may read: the key is the leading path segment, so it is a string comparison.

Filestore takes the same route against cdn.vmlts.com, on a path of its own. There the leading segments are the bucket and the tenant, so the same comparison decides where a visitor may write and what they may name it.

gen takes it against gen.vmlts.com, and there the route is where you decide who may spend a generation, on which model and into which bucket: the model and the placement are named on the body's envelope, so each is a string comparison too.

3. Wrap the tree once. baseUrl is the path on your own origin, not the service URL. Each tool has a provider of its own, so an application nests the providers for the tools it uses and reaches each with the one key.

import { FilestoreProvider, GenProvider, PromptProvider } from "@vmlts/react";

createRoot(document.getElementById("root")!).render(
  <PromptProvider baseUrl="/api/vmlts/prompt">
    <FilestoreProvider baseUrl="/api/files" bucket="photos">
      <GenProvider baseUrl="/api/vmlts/gen">
        <App />
      </GenProvider>
    </FilestoreProvider>
  </PromptProvider>,
);

4. Read a prompt.

import { usePrompt } from "@vmlts/react";

function Greeting({ userId }: { userId: string }) {
  const { prompt, loading, error } = usePrompt("support/greeting", { unit: userId });

  if (error) return <p>{error.code}</p>;
  if (loading || !prompt) return <p>…</p>;
  return <p>{prompt.render({ name: "Ada" }).text}</p>;
}

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 render until the operator moves the split.

usePrompt("support/greeting", { unit: userId });
usePrompt("support/greeting", { version: 3 });        // or "v3"
usePrompt("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, and reaches you as an error with the code selector_invalid.

Write the selector as an object literal if you like. The hook reads it field by field, so a new object every render costs no request: only a changed key, unit, version or label resolves again.

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.

Outside a component

The provider holds one client, and usePromptClient() hands it to you for a call no hook covers — an event handler, a loader, a useEffect of your own.

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

new PromptClient({ baseUrl }) builds one without a tree around it.

When the prompt is off

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

prompt.content;   // the fallback text. Always something to render.

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

code is why it was refused, in one word: prompt_not_found, version_not_found, selector_invalid, unauthorized, forbidden, rate_limited, internal, unavailable. Branch on it rather than on the status — seven of them share a status with another — or on the sentence, which can change. A code the service adds later still arrives whole.

requestId is the service's own name for the request that failed, and it is the last attempt's: each retry is its own request with an id of its own. It travels in a header the browser may not read, x-vmlts-request-id, so your forwarding route either passes it back under access-control-expose-headers or leaves it to the failure body, which carries the same id. Both reach this field.

Saying whether it worked

A prompt has versions, and the dashboard shows which of them earns "good". The prompt the hook handed you rates itself, and names the version for you.

function Greeting({ userId }: { userId: string }) {
  const { prompt } = usePrompt("support/greeting", { unit: userId });

  return (
    <>
      <p>{prompt?.content}</p>
      <button onClick={() => prompt?.feedback("good")}>Good</button>
      <button onClick={() => prompt?.feedback("bad", "it named the wrong product")}>Bad</button>
    </>
  );
}

A rating is fire and forget. Nothing about it belongs in what you render, so unmounting the moment after the click sends the verdict anyway.

useFeedback is for a component that wants to show one going out, or say that it was refused. rate never rejects — a click handler awaits nothing and has no catch to write — so the refusal reaches you as error, a VmltsError with the same code a resolve carries.

const { prompt } = usePrompt("support/greeting", { unit: userId });
const { rate, sending, error } = useFeedback(prompt);

<button onClick={() => rate("good")} disabled={sending}>Good</button>;
{error && <p>{error.code}</p>}

A prompt the operator turned off has no row to rate: the rating sends nothing and answers null, and you write no branch for it.

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 cacheTtl above 0 serves one answer to everyone inside the window, so the second rating against it is that conflict.

A component that rendered its own text holds no prompt. It names the version instead, through the client the provider holds, and adds a requestId so a retry of its own request does not count the rating twice.

const prompter = usePromptClient();
await prompter.feedback({ versionId, rating: "bad", requestId, ran: { model, output } });

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

Letting the browser deliver it

A verdict given as the page closes has nothing waiting for it, so hand the delivery to the browser: beaconWire posts the rating with navigator.sendBeacon, which outlives the page.

<PromptProvider baseUrl="/api/vmlts/prompt" wire={beaconWire}>

It is opt-in because a beacon reports nothing back: feedback then answers null rather than the row, and a service that refuses the rating cannot tell you. Everything else still goes over fetch, and so does the rating on a browser with no beacon and one that will not take the body.

Uploading a file

useUpload drives one upload and holds its state. upload takes a File off an input, progress says how far it has got, result carries the stored object once it lands, and cancel abandons it.

import { useUpload } from "@vmlts/react";

function Uploader() {
  const { upload, cancel, state, progress, result, error } = useUpload();

  return (
    <>
      <input
        type="file"
        onChange={(event) => {
          const file = event.target.files?.[0];
          if (file) void upload(file).catch(() => {});
        }}
      />
      {state === "uploading" && (
        <>
          <progress value={progress?.loaded ?? 0} max={progress?.total ?? 1} />
          <button onClick={cancel}>stop</button>
        </>
      )}
      {result && <img src={result.url} />}
      {error && <p>{error.code}</p>}
    </>
  );
}

The bytes are counted by the browser, not guessed: the SDK sends the upload through XMLHttpRequest, which reports how far a request body has gone. Unmount the component mid-upload and the upload is abandoned, and the parts already sent are dropped.

A file with no key of its own gets one that keeps the filename, prefixed with twelve random characters so two people uploading photo.jpg do not collide. Name it yourself with upload(file, { key: "avatars/ada.jpg" }).

useObjects lists what is there. It follows every page, lists again when the prefix changes and not when anything else does, and remove drops a key from the service and from the list.

const { objects, loading, error, refresh, remove } = useObjects("holiday/");

useFilestoreClient() hands you the client the provider holds, for a call no hook covers: delete(key), and url(key, image?), which builds a read URL without asking the service.

const files = useFilestoreClient();
files.url("ada.jpg", { view: "thumb" });
files.url("ada.jpg", { width: 400, height: 400, fit: "cover", format: "webp" });

url needs the app slug, the leading segment of every read URL, which no write ever spells. Every upload and every list already carries the URL back, so url is for a key you hold and have not asked about.

There is no signed URL here. Minting a signature needs the API key, which the browser must not hold, so the mint lives in @vmlts/node, on your server.

Generating a picture

useGeneration fires one generation and holds its state. A picture takes tens of seconds, so state is what you render while it is made.

import { useGeneration } from "@vmlts/react";

function Poster({ season }: { season: string }) {
  const { image, cancel, state, generation, error } = useGeneration();

  return (
    <>
      <button
        onClick={() =>
          void image({
            model: "openai/gpt-image-2",
            prompt: { key: "poster/hero", variables: { season } },
            size: "1024x1024",
          }).catch(() => {})
        }
        disabled={state === "generating"}
      >
        Make it
      </button>
      {state === "generating" && <button onClick={cancel}>stop</button>}
      {generation?.kind === "image" && <img src={generation.output.files[0]!.url} />}
      {error && <p>{error.code}</p>}
    </>
  );
}

Nothing generates on a render: a picture costs money, and a component that made one while rendering would spend on every keystroke above it. image, text and custom are what you call from an event handler, and each returns the answer as well as putting it in generation.

The prompt is the words, or the key of a prompt prompter holds — the same key usePrompt reads, resolved by gen at the moment of the generation. generation.prompt then names the version that made this picture, and its useId is what PromptClient.feedback rates.

generation is whichever kind the model made, so branch on kind before reading output. text takes messages instead and stores nothing.

const { text } = useGeneration();
const said = await text({
  model: "openai/gpt-5.1-mini",
  messages: [{ role: "user", content: "say hello" }],
});
said.output.text;

One hook drives one generation: firing again abandons the one in flight, and so does unmounting the component. Cancelling stops you waiting; it does not stop gen, which makes the picture and bills for it either way.

useModels() lists the models this key may invoke, with the limits of each. The list is filtered by the key's policies, so an empty one is an answer and not a failure.

const { models, loading, error } = useModels();

useGenClient() hands you the client the provider holds, for a generation with no component waiting on it.

Settings

There is no environment to read in a browser, so every setting is a prop on the tool's provider.

PromptProvider:

| Setting | Default | What it does | |---|---|---| | baseUrl | https://prompts.vmlts.com | the path on your own origin that forwards | | token | none | refused here; the forwarding route holds it | | unit | none; each call draws again | the unit every call sends unless it names its own | | cacheTtl | 0 seconds, no cache | serve one answer out of memory for that many seconds | | attempts | 3 tries, the first one included | a rating with no requestId takes one try whatever this says | | wire | fetch | how a request goes out; beaconWire hands a rating to the browser |

A cacheTtl above 0 serves one answer out of memory, 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.

FilestoreProvider:

| Setting | Default | What it does | |---|---|---| | baseUrl | https://cdn.vmlts.com | the path on your own origin that forwards | | token | none | refused here; the forwarding route holds it | | bucket | none; keys then spell their own | the bucket every key is relative to | | prefix | none | the tenant, under that bucket | | slug | none; url() fails without it | your app, the leading segment of every read URL | | ttl | none; the bucket's own default, or forever | how long every object this client writes lives | | ensureBucket | off | make the bucket on a write that finds it missing | | partSize | measured from the file | bytes per part, and the size above which a file splits | | concurrency | measured from the link | parts in flight at once |

An upload may name its own key, ttl, contentType, partSize, concurrency and ensureBucket, and each beats the provider's.

GenProvider:

| Setting | Default | What it does | |---|---|---| | baseUrl | https://gen.vmlts.com | the path on your own origin that forwards | | token | none | refused here; the forwarding route holds it | | bucket | none; gen writes into gen | the bucket every picture lands in | | prefix | none | the tenant, under that bucket | | attempts | 3 tries for useModels; a generation takes 1 | a generation says so per call, and raising it buys a second picture | | deadlineMs | 60000, and 0 for none | how long one generation may run |

A call may name its own bucket, prefix, key, attempts and deadlineMs, and each beats the provider's. The bucket and the prefix ride on image alone: a conversation stores nothing, and a preset was stored with the placement its operator chose.

The forwarding route holds the connection for as long as the generation takes, so give it a timeout above the deadline. A gateway that gives up at 30 seconds turns every slow picture into a failure you cannot name.

Why the token never reaches the browser

/v1 sends no CORS headers and answers no preflight. A browser cannot reach it on another origin, and a token would not help: a token in a browser is a token every visitor holds, and it reaches every tool your app opted into.

So the SDK refuses a token beside a base URL that is not your own origin, and it refuses it at construction rather than at the first request. Point baseUrl at your own origin and keep the key on the server.

Under the bar

The same engine @vmlts/node runs. A prompt goes over fetch and an upload over XMLHttpRequest.

| | Why | |---|---| | Each tool has a provider of its own, rather than one for both | An application that only reads prompts never names FilestoreProvider, so the upload engine leaves its bundle. | | The provider holds the client in a useMemo over the settings themselves | A re-render above it would otherwise build a second client, and every request in flight would start over. | | The hook's effect watches unit, version and label, not the selector object | A selector written inline is a new object on every render, and watching it would resolve the prompt on every render. | | An upload sends through XMLHttpRequest, not fetch | fetch cannot say how far a request body has gone, so there is nothing for a progress bar to read. | | Progress is held in a ref and written to state once a frame | Bytes arrive faster than a screen redraws, and a setState each time would render the page hundreds of times a second. | | Unmounting cancels the request | The answer has nowhere to land, and a setState after unmount is a warning you did not earn. Mid-upload it also deletes the parts already sent, which nothing else knows about. | | Unmounting does not cancel a rating | The verdict was given. Withdrawing it because the component went is losing what the operator asked for, and there is nothing half-sent to clean up. | | A generation is one attempt, where every other read is three | gen holds the connection while the provider works and has already retried it behind that connection. A second request is a second picture, and may be a second charge. | | A generation waits 60 seconds, where an upload waits on the transport | gen's own budget is 50 seconds, so a client that gave up first would throw away an answer it could have read, while the picture is made and billed anyway. | | A file over 64 MiB goes up in parts, and the pool widens itself | The Worker exposes multipart as four independent requests and remembers nothing between them, so the split, the retry and the cleanup are the client's. No browser reports bandwidth, so the SDK measures the link by timing each generation of parts. | | A 4xx other than 408, 425 and 429 is not retried | The service read the request and refused it, so an identical second one earns an identical refusal. A code narrows that and never widens it. |