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

@spicyapi/proxy

v0.2.0

Published

Server-side proxy for SpicyAPI: let browser, mobile and desktop apps call the API without ever holding an API key.

Readme

@spicyapi/proxy

Let a browser, mobile or desktop app call SpicyAPI without ever holding an API key.

A key compiled into a client is a public key. Anyone can decompile the app or watch one request, and then spend your balance until it runs out — while your dashboard just shows a busy day. The fix is not obfuscation. It is to never put the key there: the app calls your server, and your server adds the credential.

This package is that server-side hop.

Install

npm install @spicyapi/proxy

Set SPICY_API_KEY in the server environment. It is never sent to the client.

Next.js (App Router)

// app/api/spicy/proxy/route.ts
export { GET, POST, PUT, PATCH, DELETE } from "@spicyapi/proxy/nextjs";
export const runtime = "nodejs";

runtime = "nodejs" is not optional. Edge runtimes do not see server-only environment variables, so the proxy would answer 500 in production while working perfectly on your machine.

To pass options, build the handlers yourself instead of re-exporting the defaults:

// app/api/spicy/proxy/route.ts
import { createRoute } from "@spicyapi/proxy/nextjs";

export const { GET, POST, PUT, PATCH, DELETE } = createRoute({ timeoutMs: 60_000 });
export const runtime = "nodejs";

Express

import { createExpressHandler } from "@spicyapi/proxy/express";

app.all("/api/spicy/proxy", createExpressHandler());

Mount it before any body parser. express.json() consumes the request body, and the proxy would then forward an empty one — which surfaces upstream as a missing-parameter error that points nowhere near your middleware order.

Any fetch runtime

import { createProxyHandler } from "@spicyapi/proxy";

const handle = createProxyHandler();
// handle(request: Request) => Promise<Response>

Options

| Option | Default | Meaning | | ---------------- | ----------------------------- | ------------------------------------------------------------------ | | apiKey | process.env.SPICY_API_KEY | A string, or a function read on every request (for rotation) | | allowedOrigins | ["https://api.spicyapi.ai"] | Origins it may forward to; widen only for local development | | timeoutMs | 120000 | How long to wait for the upstream response headers (see below) |

How a client uses it

Point the client at your own route and put the real target in x-spicy-target-url:

await fetch("/api/spicy/proxy", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-spicy-target-url": "https://api.spicyapi.ai/api/v1/jobs/createTask",
    "idempotency-key": crypto.randomUUID(),
  },
  body: JSON.stringify({ model, input }),
});

Any path and method on the allow-listed origin is forwarded — the media task routes under /api/v1, the chat routes under /v1 and /v1beta, uploads, and whatever the API adds next. The proxy decides where a request may go, not which calls your users may make; see Limits.

Streaming chat

Chat models stream through the same route. The response body is passed on as it arrives, unbuffered:

const response = await fetch("/api/spicy/proxy", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-spicy-target-url": "https://api.spicyapi.ai/v1/chat/completions",
    "idempotency-key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    model, // a chat model from GET /v1/models?modality=text
    stream: true,
    messages: [{ role: "user", content: "Explain a rainbow in one sentence." }],
  }),
});
// response.body is the server-sent event stream; read it as it arrives.

timeoutMs covers only the wait for the response headers. Once a stream has started it runs until the model finishes or the client disconnects, however long the model reasons. That is deliberate: SpicyAPI bills a stream the caller abandons for the usage the model reports, so a proxy that cut long streams off would make users pay for text they never saw. If something in front of this route (a reverse proxy, a CDN) buffers or times out responses, configure it to pass streams through.

Uploading a file from the browser

An upload is three requests, and only two of them go through the proxy:

async function viaProxy(target: string, body?: unknown) {
  const response = await fetch("/api/spicy/proxy", {
    method: "POST",
    headers: { "content-type": "application/json", "x-spicy-target-url": target },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
  const json = await response.json();
  if (json.code !== 200) throw new Error(json.msg);
  return json.data;
}

// `file` is a File, for example from <input type="file">.
// 1. A ticket, through the proxy. contentType must be a type the API accepts (image/png, video/mp4, …).
const ticket = await viaProxy("https://api.spicyapi.ai/api/v1/common/upload-url", {
  contentType: file.type,
  bytes: file.size,
});

// 2. The bytes, straight to storage - NOT through the proxy.
const headers = new Headers(ticket.headers);
headers.delete("Content-Length"); // browsers derive it from the File
const put = await fetch(ticket.uploadUrl, { method: ticket.method, headers, body: file });
if (!put.ok) throw new Error(`upload failed with HTTP ${put.status}`);

// 3. Commit, through the proxy. Its `uri` (spicy://f/...) goes into task input or a chat media part.
const { uri } = await viaProxy(`https://api.spicyapi.ai/api/v1/files/${ticket.fileId}/commit`);

Step 2 must not go through the proxy, and the proxy refuses it anyway: the presigned uploadUrl points at a storage host, not at https://api.spicyapi.ai, and it is a credential in its own right. Adding your SpicyAPI key to that request would hand the key to the storage host.

The committed uri works wherever a model takes a file: in a media task's input, or in a chat message part such as {"type": "image_url", "image_url": {"url": uri}} for chat models whose schema accepts images (some also accept video_url and audio_url parts).

GET /v1/videos/{id}/content answers with a redirect to a signed download link. The proxy passes the redirect back as it is, so the browser downloads the file directly from storage, without your key.

What it refuses

| Situation | Answer | | -------------------------------- | ------ | | No x-spicy-target-url | 400 | | Target origin not allow-listed | 403 | | No key configured on the server | 500 | | Upstream could not be reached | 502 | | Upstream sent no headers in time | 504 |

Refusals use the platform's {code, msg, request_id} envelope, so a client needs no separate parsing path for them.

The allow-list is the whole point. Forwarding to whatever the header says would mean a stranger can make your server hand your key to a host they control — one request, and the traffic looks entirely normal because it came from you. The comparison is on the exact origin, never a prefix: https://api.spicyapi.ai.attacker.example starts with our domain too.

Client-supplied authorization, x-api-key, x-goog-api-key and cookie headers are dropped before forwarding, so the proxy cannot be turned into an open relay for someone else's credentials.

Headers it rewrites

  • The client's accept-encoding is not forwarded. The server-side fetch negotiates compression itself and decodes the reply, so the upstream content-encoding is not passed back either — it would label an already decoded body as compressed, and the browser would fail to read it.
  • Hop-by-hop headers (connection, transfer-encoding, host, content-length and the like) are recomputed for each hop rather than copied.
  • Everything else — content-type, idempotency-key, x-spicy-retention, accept-language and the response headers such as x-spicy-task-id and retry-after — passes through unchanged.

Limits

This hop authenticates to SpicyAPI. It does not authenticate your users — anyone who can reach the route can spend your balance. Put your own session check, rate limit and per-user quota in front of it, exactly as you would for any endpoint that costs money.