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

v0.1.1

Published

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

Downloads

28

Readme

@vmlts/angular

Read a prompt, upload a file and generate an image from an Angular application, with one API key. The operator changes the words in the dashboard, and your application sees them without a deploy. Angular 13 to current, standalone or NgModule.

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/angular      # or: npm i @vmlts/angular

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 Nest 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 body names the model and the bucket, so the route is where you decide who may spend a generation and on what.

A generation holds the connection for as long as the model takes, so give that route a timeout of at least a minute. A proxy that gives up at 30 seconds turns every slow picture into a failure nobody can name.

3. Provide the SDK, next to an HttpClient. baseUrl is the path on your own origin, not the service URL. An HttpClient is not optional: the SDK sends through it, so your interceptors apply to a prompt request like any other.

Each tool provides itself, and every one hands back a plain Provider[], so an application spreads the arrays for the tools it uses side by side and the same call works in either world.

// Standalone.
bootstrapApplication(App, {
  providers: [
    provideHttpClient(),
    ...providePrompt({ baseUrl: "/api/vmlts/prompt" }),
    ...provideFilestore({ baseUrl: "/api/files", bucket: "photos" }),
    ...provideGen({ baseUrl: "/api/vmlts/gen" }),
  ],
});

// NgModule, before Angular 15.
@NgModule({
  imports: [HttpClientModule],
  providers: [
    ...providePrompt({ baseUrl: "/api/vmlts/prompt" }),
    ...provideFilestore({ baseUrl: "/api/files", bucket: "photos" }),
    ...provideGen({ baseUrl: "/api/vmlts/gen" }),
  ],
})
export class AppModule {}

4. Read a prompt.

import { Component } from "@angular/core";
import { PromptService } from "@vmlts/angular";
import { Observable, map } from "rxjs";

@Component({
  selector: "app-greeting",
  template: `<p *ngIf="greeting | async as text">{{ text }}</p>`,
})
export class Greeting {
  readonly greeting: Observable<string>;

  constructor(prompter: PromptService) {
    this.greeting = prompter
      .use("support/greeting")
      .pipe(map((prompt) => prompt.render({ name: "Ada" }).text));
  }
}

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.

prompter.use("support/greeting", { unit: userId });
prompter.use("support/greeting", { version: 3 });        // or "v3"
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.

The Observable

use() is cold, like every HttpClient call: the request goes out when someone subscribes, and letting go of the subscription cancels it. The async pipe, takeUntil() and takeUntilDestroyed() all work on it as they do on anything else.

prompter.use("support/greeting").subscribe((prompt) => {
  prompt.content;      // the text, as it was served
  prompt.version;      // which version answered
  prompt.id;           // the answer a rating names
});

Two subscribes are two requests. Nothing is held between them unless you ask for a cacheTtl.

feedback is the one exception in the package, and it is eager: see Rating what you were served.

When the prompt is off

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

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

It fails 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.

prompter.use("support/greeting").subscribe({
  next: (prompt) => (this.text = prompt.content),
  error: (failure: VmltsError) => (this.problem = failure.code),
});

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.

Rating what you were served

The operator's whole reason for splitting traffic is to see which version earned better ratings. Rate the prompt you hold, and the SDK names the answer for you.

prompter.use("support/greeting").subscribe((prompt) => {
  this.text = prompt.render({ name: "Ada" }).text;
  this.rate = (verdict: "good" | "bad") => prompt.feedback(verdict);
});

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

A rating is eager, and it is the only eager Observable here. It goes out on the call, not on a subscribe, so prompt.feedback("good") on a line of its own sends the rating. Cold would mean a caller who walked away rated nothing at all, silently, and the operator read an empty dashboard instead of an error.

Subscribe when you want the row back. It replays, so a subscriber that arrives after the answer still receives it, and unsubscribing cancels nothing.

prompt.feedback("good").subscribe((row) => (this.rated = row!.createdAt));

Nobody has to subscribe, so a refused rating needs somewhere to go. Pass onError and every refusal reaches it, whether you subscribed or not.

providePrompt({ baseUrl: "/api/vmlts/prompt", onError: (failure) => log(failure.code) });

A prompt the operator turned off has no version to rate: feedback then sends nothing and answers null, so you write no branch for it.

A caller who rendered its own text, or whose prompt crossed a boundary as JSON, names the version itself:

prompter.feedback({ versionId, rating: "bad", requestId });
prompter.feedback({ key: "support/greeting", version: 3, rating: "good", requestId });

requestId is your own idempotency key: the same one twice returns the first row and writes nothing. Without one the rating is not retried, because a second attempt would be a second rating.

Rating as the page closes

beacon: true sends every rating with navigator.sendBeacon, which the browser delivers even after the page is gone. It reports nothing back, so feedback answers null instead of the row — that is the trade, and it is why the setting is off by default.

providePrompt({ baseUrl: "/api/vmlts/prompt", beacon: true });

Where the browser has no beacon, or will not queue this one, the rating goes through HttpClient as usual. Reading a prompt always does.

A beacon sets no headers, so it cannot carry a token and the forwarding route is the only thing that can attach the key. beacon beside a token is refused at construction.

Uploading a file

put takes a File off an input and hands back a cold Observable. Each event says how far the upload has got; the last one carries the stored object, and then it completes.

@Component({
  selector: "app-uploader",
  template: `
    <input type="file" (change)="upload($event)" />
    <progress [value]="percent" max="100"></progress>
  `,
})
export class Uploader implements OnDestroy {
  percent = 0;
  url: string | null = null;
  private running: Subscription | null = null;

  constructor(private files: FilestoreService) {}

  upload(event: Event): void {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (!file) return;
    this.running = this.files.put(file).subscribe({
      next: (progress) => {
        this.percent = progress.percent;
        if (progress.result) this.url = progress.result.url;
      },
      error: (failure: VmltsError) => (this.problem = failure.code),
    });
  }

  // Unsubscribing abandons the upload, and the parts already sent are dropped.
  ngOnDestroy(): void {
    this.running?.unsubscribe();
  }
}

The percentage is Angular's own: the SDK asks HttpClient for progress events, so the bytes are counted by the browser rather than guessed.

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 put(file, { key: "avatars/ada.jpg" }).

Beyond put there is list(prefix?), which follows every page and emits once, delete(key), and url(key, image?), which builds a read URL without asking the service:

this.files.url("ada.jpg", { view: "thumb" });
this.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 put 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 and no bucket registry here. Minting a signature needs the API key, which the browser must not hold, and a registry is the operator's job — both live in @vmlts/node, on your server.

Generating a picture

image names a model and the words, and hands back a cold Observable that completes with the picture: an id, the model that answered, the file on your own domain and where the time went.

@Component({
  selector: "app-poster",
  template: `<img *ngIf="url" [src]="url" alt="" />`,
})
export class Poster implements OnDestroy {
  url: string | null = null;
  private running: Subscription | null = null;

  constructor(private gen: GenService) {}

  make(): void {
    this.running = this.gen
      .image({ model: "bfl/flux-2-pro", prompt: "a red bicycle", size: "1024x1024" })
      .subscribe({
        next: (made) => (this.url = made.output.files[0].url),
        error: (failure: VmltsError) => (this.problem = failure.code),
      });
  }

  // Unsubscribing abandons the generation.
  ngOnDestroy(): void {
    this.running?.unsubscribe();
  }
}

The words may name a prompt instead of writing themselves, and the answer says which wording made the picture:

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

made.prompt?.text;      // the words the model was sent
made.prompt?.useId;     // rate that wording: prompter.feedback({ ... })

One subscribe is one generation. The Observable is cold, so two subscribers buy two pictures and the async pipe in two places buys two. Share an answer with shareReplay(1), or take a promise with firstValueFrom. The SDK does neither for you: guessing which you meant spends money either way.

A generation is not retried

Everywhere else in this package a 500 or a 502 is tried again. A generation is not. gen holds the connection while the provider works and has already retried that provider behind it, so a second request is not a second try at a failed thing: it is a second picture, and it may be a second charge.

this.gen.image({ model, prompt, attempts: 3 });   // the ordinary loop, if you can afford it

models() is a read that spends nothing, so it retries as usual.

It runs for as long as gen does

gen's own budget for one request is 50 seconds, and it answers 504 itself when that runs out. So a generation here is given 60 seconds, above that budget, and nothing shortens it: a client that gave up first would turn an answer it could have read into a transport failure, while the picture is made, stored and billed anyway.

Give up sooner by passing your own signal, or deadlineMs on the service or on the call. deadlineMs: 0 leaves a generation without one.

Words, the roster and a preset

this.gen.text({
  model: "openai/gpt-5.1-mini",
  messages: [{ role: "user", content: "say hello" }],
});                                     // said.output.text, said.output.tool_calls

this.gen.models();                      // what this key may invoke, with each model's limits
this.gen.custom("necklace", { style: "studio" });

A conversation stores nothing, so it names no bucket and no key. models() is filtered by your key's policies rather than refused against them: a model missing from the list is not an error, and an empty list is an answer.

custom runs a preset an operator wrote and named. The model, the prompt and the placement stayed with them, and you post the values their parameters declare; the dashboard is where those are documented.

gen also streams a conversation, as named events. This package does not read them, and refuses stream rather than sending a request it cannot read back.

Where the picture lands

With nothing named it lands in the gen bucket under a key minted from the id and the type. Name a bucket, a prefix and a key on the call, or set the first two on the provider and let every picture land in one place.

...provideGen({ baseUrl: "/api/vmlts/gen", bucket: "renders", prefix: tenantId }),
this.gen.image({ model, prompt, bucket: "posters", key: "hero.png" });

A bucket and a prefix set on the provider ride on image() alone. A conversation stores nothing, and a preset was stored with the placement its operator chose.

Settings

providePrompt:

| 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 | | | beacon | off | send a rating with navigator.sendBeacon, which outlives the page | | onError | none | where a refused rating goes when nobody subscribed |

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.

provideFilestore:

| 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 service 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 |

A put may name its own key, ttl, contentType, partSize, concurrency and ensureBucket, and each beats the service's.

provideGen:

| 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 | where every picture lands | | prefix | none | the tenant, under that bucket | | attempts | 1 for a generation, 3 for models() | | | deadlineMs | 60000, above gen's own 50 s budget | 0 leaves a generation without one |

An image may name its own bucket, prefix, key, attempts and deadlineMs, and each beats the service's.

There is no environment to read in a browser, so every setting is passed to the provider.

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, sending over HttpClient.

| | Why | |---|---| | It sends through HttpClient, not fetch | Your interceptors — auth, CSRF, tracing — apply to a vmlts request like any other, and fetch cannot report how far a request body has gone. | | A 4xx or a 5xx does not reject the wire | HttpClient calls every one of those an error. The retry loop above the wire needs the status to decide whether to try again. | | The provider functions return Provider[], and no class carries @Injectable | The package is built by tsup rather than by the Angular compiler, so a decorated class would carry no ɵfac and would only resolve under JIT. A plain class behind a factory provider needs no compiler and no EnvironmentProviders, so one array serves every Angular from 13 up. | | 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. | | A generation is not retried at all, and runs for a minute | It is the one call here that costs money to send twice, and the one that legitimately takes tens of seconds. gen answers 504 itself at 50 s, so a client deadline under that would throw away an answer it could have read. |