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

@grafana-coda/sandbox-client

v1.9.0

Published

Typed client for the grafana-coda-app v1 sandbox API: session lifecycle, Live terminal protocol, and exec.

Readme

@grafana/coda-client

Typed client for the grafana-coda-app v1 API: ephemeral sandbox VMs, exposed to any Grafana plugin as an interactive terminal.

The REST calls are the easy half. This package exists for the other half — the handful of details that are invisible, undocumentable, and cost a debugging session each:

  • Publishing must go over the socket. Without { useSocket: true } a publish becomes POST /api/live/publish, which can land on a Grafana node that is not running your stream. It fails silently on multi-node Grafana and works perfectly on a laptop.
  • A session is terminal on error. Handling error by showing a message and leaving the subscription open lets Grafana Live reconnect it, reopening a stream against a dead VM in a loop. You cannot document a negative, so CodaSession is one-shot instead and never exposes the raw observable.
  • The channel string is opaque. Split it, never construct it: an Alloy scenario id can contain /, which is exactly why intent travels in the request body.
  • Frames carry one JSON-encoded string field, because Grafana Live cannot carry arbitrary JSON. A frame that claims to be one and fails validation is dropped and reported once through onProtocolError — never rendered, and never fatal.
  • The availability probe is not always there. isAppPluginEnabled reached @grafana/runtime in Grafana 13.1, and the host serves that module, so what you built against proves nothing. Where it is absent the import is undefined and calling it throws synchronously — before a promise exists, so no .catch() sees it and the throw lands in your render. isAvailable() feature-detects instead, which means it answers false on a host older than 13.1 even where Coda is installed: the feature hides rather than crashing.

Use

Coda is optional. Detect it at runtime — never declare it as a plugin dependency.

import { CodaClient, isCodaUsable, isNotReady } from '@grafana/coda-client';

const coda = new CodaClient();
if (!(await coda.isAvailable())) {
  return; // Absent, or a Grafana too old to say: hide the feature, don't error.
}

const caps = await coda.getCapabilities();
if (!isCodaUsable(caps)) {
  // Not set up, or set up and no longer working: `caps.registered` only says a
  // credential was stored, and stays true of one Coda has stopped accepting.
  return;
}

const session = await coda.createSession({ template: 'vm-aws-sample-app', app: 'nginx' });

// Provisioning starts here, not at createSession — it can take minutes, which
// is why status arrives as events rather than blocking an HTTP call.
session.subscribe({
  onStatus: ({ message }) => showProgress(message), // render `message`, don't map `state`
  onConnected: () => terminal.focus(),
  onOutput: (data) => terminal.write(data),
  onError: (err) => showError(err.message),
  onClosed: () => teardown(),
});

terminal.onData((d) => session.write(d));

const result = await session.exec('test -f /etc/nginx/nginx.conf', { readyFile: '/tmp/my-setup-done' });
if (result.exitCode === 0) {
  pass();
}

await session.close(); // Ends the terminal. The VM survives for reuse.

Sending input

write() sends raw input with no newline appended — it is write, not writeln. The backend writes the payload to the PTY's stdin untransformed, so control sequences work as-is:

session.write('ls -la\n'); // The '\n' is yours to send — nothing appends it.
session.write('\x0f'); // Ctrl+O
session.write('\x1b[A'); // Up arrow

Three limits bind, and all three are silent:

  • Only after onConnected. An earlier write() is dropped client-side, matching the backend, which answers not-found when no terminal is attached yet.
  • 8 KB per publish (limits.maxPublishBytes); a larger payload is dropped backend-side. Chunk a large paste.
  • Rate-limited per user — burst 60, 30/s sustained. A human typing does not reach it; a synthetic "type this whole block" action must pace itself.

The payload is a JSON string, so it carries UTF-8 text rather than arbitrary bytes: C0 control characters and ANSI escapes are fine, a lone byte in 0x800xFF is not expressible.

Surviving transport loss

CodaSession is one-shot. When the transport breaks — a browser network drop, a laptop resuming, a Grafana or plugin restart, a relay rollout, a node going away — the session ends and cannot be revived. Use CodaTerminalLink when you want a terminal that comes back:

import { CodaTerminalLink } from '@grafana/coda-client';

const link = new CodaTerminalLink({ client: coda, intent: { template: 'vm-aws-sample-app', app: 'nginx' } });

link.start({
  onOutput: (data) => terminal.write(data),
  onConnected: ({ vmId, reattached }) => {
    if (reattached) {
      // A NEW shell on the SAME VM. Say so, or draw a rule — do not pretend
      // the old one continued.
      terminal.write('\r\n— reconnected, new shell —\r\n');
    }
  },
  onState: (state) => setBanner(state), // 'reconnecting' is your cue to render one
  onReconnect: ({ attempt, delayMs, outageMs, budgetMs }) => showRetry(attempt, delayMs, outageMs / budgetMs),
  onEnded: ({ reason, error }) => showEnd(reason, error?.message),
});

terminal.onData((d) => link.write(d));

It reattaches by asking for fresh authorized intent — a new POST /v1/sessions — and landing on the same VM, because the backend resolves VMs by owner. It never retries a consumed session id; those are single-use. Every reattach re-runs the whole authorization chain server-side, so nothing is carried forward from the previous session.

It renders nothing. No spinner, no banner, no copy — those are yours. What it gives you is a state, a closed-set reason and numbers.

Four things to design around:

  • A reattach is a new shell, not your old one. cwd, exported variables, a running top and an unsubmitted line are gone. The filesystem survives, because it is the same VM. Preserving live PTY state would need a multiplexer inside the VM image, which is a decision for the Coda service and has not been made — so do not build a UI that implies continuity. (The backend's ptyTerm is already the string tmux-256color. That is a TERM value, not tmux running.)
  • Nothing is replayed, ever. Input sent while disconnected is dropped and there is no queue; exec rejects with terminal_not_connected rather than waiting, and is never retried for you. A repeated exec is a repeated side effect on the user's VM.
  • The session id changes on every reattach. Key your state on link.vmID.
  • It gives up. After a five-minute outage budget it ends with recovery_exhausted, and vm_expired, vm_failed, role_forbidden and your own close() end it at once. isReattachable is exported if you want to predict that rather than re-deriving it.

Tune it with policybaseDelayMs, maxDelayMs, factor, outageBudgetMs. The defaults are in CODA_RECOVERY_DEFAULTS, with the reasoning next to them.

Measuring the lifecycle

Pass a sink and the client reports what happened, with no dependency on a metrics library and no opinion about where it goes:

const coda = new CodaClient({
  telemetry: (event) => {
    switch (event.type) {
      case 'phase': // one latency observation, `seconds`
      case 'session_end': // exactly one per session
      case 'terminal_end': // exactly one per CodaTerminalLink
    }
  },
});

phase covers reserve → first prompt (not merely reserve → VM active), provisioning, and each reconnect measured over the whole outage. path is warm or cold, because a reused VM and a fresh one differ by two orders of magnitude and averaging them together makes both numbers useless. session_end and terminal_end carry a closed-set reason derived from the wire code.

Two rules the types cannot enforce for you:

  • phase, outcome, path and reason are label-safe. seconds, attempt, reconnects and outageSeconds are values — put them in a histogram or a sum, never in a label.
  • Nothing here carries an identifier or a payload, deliberately: no session id, no VM id, no login, no command, no output, no token. Do not add them on the way out. Correlate in traces.

CODA_PHASE_BUCKETS_SECONDS is the recommended histogram boundary set, exported so a consumer and a dashboard cannot quietly disagree about what a p95 means.

Inspecting what the user already has

listVMs() returns the caller's own VMs — filtered by owner server-side, so it can never return anyone else's. It is the only place a remaining lifetime is available: sessions carry no expiry, so join on session.vmID to age the VM behind a live terminal.

import { isUsableVMState } from '@grafana/coda-client';

const vms = await coda.listVMs();
const used = vms.filter((vm) => isUsableVMState(vm.state)).length;
showQuota(used, caps.limits.maxVMsPerUser);

for (const vm of vms) {
  // createdAt/expiresAt are RFC 3339. A backend whose upstream omitted them
  // sends the zero time, so guard rather than rendering a 2000-year countdown.
  const expiry = new Date(vm.expiresAt);
  if (expiry.getUTCFullYear() > 1) {
    showCountdown(vm.id, expiry.getTime() - Date.now());
  }
}

await coda.deleteVM(vms[0].id, true); // Frees a quota slot; still `destroying` for a moment.

Use isUsableVMState rather than counting states yourself — it mirrors the backend's own quota arithmetic exactly, including how it treats a state it does not recognise.

listSessions() returns the caller's sessions, but note they live only in the backend's memory: a Grafana restart or a settings save empties that list while the VMs survive upstream.

Errors

Every failure is a CodaError with a code from a closed set. Branch on that, never on the message or the status — several distinct failures share a status:

try {
  await session.exec('...');
} catch (err) {
  if (isNotReady(err)) {
    // terminal_not_connected — wait for onConnected and retry.
    // terminal_disconnected — the VM is gone; make a new session.
  }
}

isRetryable, isUnavailable and isNotReady cover the common branches. The first two are disjoint on purpose — isRetryable is "waiting may help", isUnavailable is "an administrator must act" — and isUnavailable includes coda_auth_failed, the plugin's own stored Coda credential being rejected. That one arrives as a 401 and is not about the caller's session. Treat an unrecognised code as non-fatal and fall back to the status: new codes can appear within v1.

Two request behaviours follow from that, and both are invisible until they bite:

  • There is no toast. Every request sets showErrorAlert: false, so a failure you do not render is a failure the user never sees.
  • There is no re-auth prompt, and no retry. Every request presets Grafana's attempt counter (retry: 1), which opts out of core's global 401 handling. Without it a rejected plugin credential costs a spurious /api/login/ping and a second failing round trip, as though the user's session had expired. Nothing is retried on your behalf: back off yourself when isRetryable says to.

Versioning

The major tracks the API major, not the plugin version: 1.x speaks v1.

This client is compiled into your bundle at your build time, while the backend is whatever the operator installed — so being newer than the backend is normal. It never requires a field /v1/capabilities does not advertise, and falls back to V1_DEFAULTS.

Feature-detect rather than compare versions. A response field you do not get means "use the v1 default"; a request field the backend does not know is ignored, so its absence changes the answer while still returning 200. Anything of that kind is named in capabilities.features:

import { codaSupports } from '@grafana/coda-client';

if (!codaSupports(caps, 'exec.readyFile')) {
  // This backend may run the command ungated. Degrade deliberately.
}

There is deliberately no pluginVersion floor to check: it would refuse a backend that has the behaviour and admit one that has regressed it.

codaSessionEligibility(caps) answers the other half — whether this caller may spend VM quota — without spending a request to be told 403 role_forbidden. It returns 'unknown' on a backend too old to say, which means "attempt the call", not "assume yes".

Nothing here validates a response, on purpose. Rejecting a field this client does not recognise would break the backend's additive guarantee, and requiring one an older backend does not send would break the other direction — so the types are held against bytes the real handlers produced, in CI: pkg/plugin/contract_fixtures_test.go writes the goldens and src/contract/wire-contract.test.ts checks these interfaces against them. See docs/API.md.

Developing

Built with plain tsc, deliberately outside the plugin's webpack build (.config/ is tool-managed and scoped to src/). Publishing is documented in RELEASING.md. Releases go out under the interim name @grafana-coda/sandbox-client until @grafana/coda-client can be claimed — alias it back to the real specifier in one dependency line and no source file mentions the interim name.

npm run typecheck:packages   # from the repo root
npm run build:packages
npx jest packages/