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

@samyx/preview-stacks-client

v0.45.0

Published

Typed API client for a pstack control plane: deployments, jobs, readiness, containers, logs, notifiers.

Readme

@samyx/preview-stacks-client

Typed API client for a pstack control plane — deployments, jobs, readiness, containers, logs, notifiers and accounts, plus webhook verification for the receiving end.

Zero dependencies. Works in Node, Bun, Deno and the browser (it uses fetch and WebCrypto, nothing else).

bun add @samyx/preview-stacks-client   # or npm / pnpm / yarn

Use it

import { createClient } from '@samyx/preview-stacks-client';

const pstack = createClient({
  baseUrl: 'https://api.preview.example.com',
  token: process.env.PSTACK_TOKEN,          // machine token, or a personal pstack_pat_…
});

// Submit a spec and deploy it. Variables travel with EVERY call — `down` needs the same ones as
// `up`, or teardown targets a different stack than deploy created.
await pstack.deployments.put(`pr-${pr}`, { specName: 'shopfront', vars: { PR: String(pr) } });
const job = await pstack.deployments.up(`pr-${pr}`, { PR: pr });

// Wait for the commands to finish…
const finished = await pstack.waitForJob(job.id);
if (finished.state !== 'ok') throw new Error(`deploy ${finished.state}`);

// …and then for the app to actually be serving, which is a different question.
const ready = await pstack.waitForReady(`pr-${pr}`, { vars: { PR: pr } });
if (ready.state !== 'ready') {
  console.error(ready.containers.filter((c) => c.failed).map((c) => `${c.name}: ${c.reason}`));
}

Errors throw

Every non-2xx becomes a PstackError carrying the status and the server's own parsed body, so a script either works or stops:

import { PstackError } from '@samyx/preview-stacks-client';

try {
  await pstack.deployments.down('shared-services');
} catch (e) {
  if (e instanceof PstackError && e.status === 409) {
    // Refused: tearing down a `kind: shared` stack destroys volumes every preview depends on.
    await pstack.deployments.down('shared-services', { force: true });
  } else throw e;
}

The two waiters are the exception: waitForJob and waitForReady return a failed job or an unready stack rather than throwing. The state is the answer, and a CI step usually wants to branch on which one it got. They throw only when the wait itself fails.

Containers

const rt = await pstack.deployments.runtime('pr-7', { PR: 7 });
for (const c of rt.containers.filter((c) => c.restartCount > 2)) {
  await pstack.containers.restart('pr-7', c.name, { vars: { PR: 7 } });
}
const logs = await pstack.deployments.logs('pr-7', { service: 'web', tail: 500, timestamps: true, vars: { PR: 7 } });

Sleep and wake

A stack that is only looked at now and then can be put to sleep — its compose project goes down, its volumes and axes stay — and comes back on the next request to any of its hostnames, or on wake. (The spec can do this on a timer with a sleep: block; see the pstack docs.)

const job = await pstack.deployments.sleep('pr-7', { PR: 7 });
await pstack.waitForJob(job.id);
const d = await pstack.deployments.get('pr-7', { PR: 7 });
d.asleep;          // { since, reason, hosts, rules } while asleep, null otherwise
await pstack.deployments.wake('pr-7', { PR: 7 });   // the same as `up`, recorded as a wake

Share a read-only link

Hand someone the logs of one deployment without creating an account for them. The link carries a signed token that reaches exactly the views you name, on that deployment, until it expires (7 days by default, 30 at most). The token is returned once and stored nowhere; rotating the server's PSTACK_TOKEN is the only revocation.

const link = await pstack.deployments.share('pr-7', { views: ['logs'], ttl: '24h' });
console.log(link.url);   // https://control.preview.example.com/deployments/pr-7/public-logs-view?token=…

// The token also works as this client's bearer, for the reads the link allows:
const viewer = createClient({ baseUrl, token: link.token });
await viewer.deployments.logs('pr-7');          // ok
await viewer.deployments.up('pr-7');            // PstackError 403

The swarm

On a host that runs previews as swarm stacks, the nodes and the material a new worker needs:

const swarm = await pstack.swarm.info();        // { reachable, active, managerAddr, nodes, ports, … }
const script = await pstack.swarm.join({ format: 'script' });   // a SECRET: treat like a password
// formats: 'token' | 'command' | 'script' | 'cloud-config' (+ distro for cloud-config)

Accounts and roles

Every account holds one of four ordered roles. Each includes everything below it:

| Role | What it adds | |---|---| | viewer | every read — deployments, jobs, logs, specs, routing, notifiers, the swarm, the user roster | | developer | stacks: up / down / verify / sleep / wake, container actions, specs, the terminal | | maintainer | host configuration: host vars, registries, routing files, notifiers, the swarm join material | | admin | people, and anything that can mint them: accounts, roles, SSO configuration, sealed config |

PSTACK_TOKEN is above all four (me().root === true) and holds no account. A share-link token is not a role at all — it reaches the views its link names, on its one deployment.

const me = await pstack.me();
if (!me.root && me.user?.role !== 'admin') throw new Error('this script mints accounts');

const ci = await pstack.users.create({ username: 'ci-bot', password, role: 'developer' });
await pstack.users.setRole(ci.id, 'maintainer');   // takes effect on that account's next request
await pstack.users.remove(ci.id);
const roster = await pstack.users.list();          // a viewer may read this

An omitted role means viewer. users.create used to produce an administrator every time; a script that relied on that must now say role: 'admin' and mean it. user.role is typed string, not Role, on the way back: the column is one an operator can repair by hand, and the server ranks a value it does not recognise below viewer — read an unfamiliar role as less, never as more.

Single sign-on

The sign-in itself is two browser redirects (/api/auth/sso/start → the provider → /api/auth/sso/callback); what an SDK can do is configure it.

const { callbackUrl, presets } = await pstack.sso.config();   // register callbackUrl with your provider
await pstack.sso.save({ mode: 'oidc', issuer: 'https://accounts.google.com',
                        clientId: '…', clientSecret: '…', label: 'Google' });
// The issuer is fetched while saving, so a typo throws here rather than failing someone's login.

config() returns the client secret as a mask — there is no route that returns the real one, and submitting the mask back keeps what is stored.

Verify a webhook

The half that lives in your receiver. It prevents the two mistakes that make a correct signature look wrong: re-serialising the body before hashing, and skipping the staleness check the signed timestamp exists for.

import { verifyWebhook } from '@samyx/preview-stacks-client';

Bun.serve({
  async fetch(req) {
    const rawBody = await req.text();               // the untouched bytes, never a re-stringify
    const v = await verifyWebhook({ secret: process.env.HOOK_SECRET!, rawBody, headers: req.headers });
    if (!v.ok) return new Response(v.reason, { status: 401 });

    const event = JSON.parse(rawBody);
    if (v.redelivery) console.log('replay of', event.id);   // dedupe on event.id
    return new Response('ok');
  },
});

What it does not do

  • No log streaming. deployments.logs() fetches a tail; following is an SSE endpoint (/api/deployments/:id/logs/stream) and is left to EventSource or your own reader.
  • No terminal. That is a WebSocket carrying a session cookie, which belongs to a browser.
  • No spec authoring helpers. A spec is YAML you own; this client submits it verbatim.
  • No password changes. PUT /api/users/:id/password signs that account out of everything it has; it is a person's job at a keyboard, not a script's.

Anything not wrapped is still reachable with the same auth and error handling:

await pstack.request('GET', '/api/routing/live');

Versioning

Released in lockstep with the server. A client is compatible with any server of the same minor or newer — routes and payload fields are added, never renamed (the same contract the webhook events carry). Full API reference: docs/usage.md.