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

@r3pos/ecom-core

v0.1.0

Published

Isomorphic (browser + Node 20+) HTTP core for the R3 Ecom Platform API: API-key auth, retries, idempotency, typed errors, cursor pagination, and webhook signature verification. Zero runtime dependencies — Web-standard globals only.

Readme

@r3pos/ecom-core

The HTTP layer the R3 Ecom Platform SDKs are built on: API-key auth, retries, timeouts, idempotency, typed errors, cursor pagination, and webhook signature verification. It owns transport and nothing else — no resource methods, no business logic, and it never reads the environment.

It is isomorphic: the same code runs unmodified in Node 20+ and in a browser, because it uses only fetch, AbortController, crypto.subtle, URLSearchParams and setTimeout. It has zero runtime dependencies.

Which package do you actually want?

Most integrations should not install this one directly:

| You are writing | Install | Key | | ----------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------- | | Server code (Node, Deno, Bun, an edge function) | @r3pos/ecom | secret, r3_sk_… | | Browser code (a customer-facing shop) | @r3pos/ecom-storefront | publishable, r3_pk_… |

Both of those depend on this package and re-export everything from it, so an integration installs exactly one package. Reach for @r3pos/ecom-core directly when you are building your own client over a route the SDKs do not wrap yet, or when you want the webhook verification helpers without the rest of the server SDK.

Install

npm install @r3pos/ecom-core

Ships ESM and CommonJS, with TypeScript declarations and source maps for both.

Quickstart

import { createTransport, isRateLimitError, type Page } from '@r3pos/ecom-core';

const transport = createTransport({
  apiKey: process.env.R3_SECRET_KEY!, // r3_sk_live_…
  baseUrl: 'https://api.r3pos.com', // origin only — /ecom/v1 is added for you
});

// GET, with query params
const page = await transport.get<Page<unknown>>('/catalog/products', {
  query: { limit: 50, q: 'latte' },
});
console.log(page.data.length, page.hasMore);

// POST, made replay-safe with an idempotency key
try {
  const order = await transport.post(
    '/orders',
    { items: [{ productId: 'prod_123', quantity: 2 }] },
    { idempotencyKey: crypto.randomUUID() },
  );
  console.log(order);
} catch (err) {
  if (isRateLimitError(err)) {
    console.warn(`rate limited; retry in ${err.retryAfterSeconds ?? 60}s`);
  } else {
    throw err;
  }
}

// Cursor pagination, one round trip at a time
for await (const product of transport.paginate('/catalog/products', { q: 'latte' })) {
  console.log(product);
}

createTransport takes apiKey and baseUrl plus optional fetch, timeoutMs (default 30000), maxRetries (default 2), userAgent, defaultHeaders, and the retry/backoff knobs (retryBaseDelayMs, retryMaxDelayMs, maxRetryAfterMs). delay, random and now are injection points that make a test suite deterministic and instant.

Key classes: secret vs publishable

An R3 Ecom API key is r3_<sk|pk>_<live|test>_<43 URL-safe base64 chars>.

  • r3_sk_… — secret. Every scope: catalog:read, inventory:read, orders:read, orders:write, webhooks:manage. Server only. It must never reach a browser bundle, a mobile app, or a commit.
  • r3_pk_… — publishable. Browser-safe. Limited to catalog:read, inventory:read and orders:write, plus a per-key origin allowlist enforced by the API.

createTransport validates the format eagerly but deliberately does not enforce the kind — that is the job of the SDK above it, via the two guards this package exports:

import { assertSecretKey, assertPublishableKey, redactApiKey } from '@r3pos/ecom-core';

assertSecretKey(key); // throws R3EcomInvalidKeyError on a r3_pk_… key
assertPublishableKey(key); // throws R3EcomInvalidKeyError on a r3_sk_… key

console.log(redactApiKey(key)); // "r3_sk_live_AbCd…" — safe to log

If you mix them up: a publishable key in server code keeps working for catalog reads and order creation, then 403s on orders:read and webhooks:manage — often weeks later, looking like a permissions bug. A secret key in browser code is worse, because nothing fails at all: the shop works perfectly while every visitor can read the merchant's entire order history out of the JS bundle and re-point their webhooks. That is why both SDKs call the matching guard at construction. A secret key that has ever been served to a browser is compromised — rotate it. Only a hash is stored server-side, so a key is rotated, never recovered.

Error handling

Every failure is an R3EcomError or one of its subclasses, each carrying a literal kind, the HTTP status (0 when no response was received), the server's machine-readable code, and the X-Request-Id when there was one.

import {
  isR3EcomError,
  isValidationError,
  isRateLimitError,
  isRetryableError,
} from '@r3pos/ecom-core';

try {
  await transport.get('/orders/ord_123');
} catch (err) {
  if (isValidationError(err)) {
    // 400 / 422 — the request itself is wrong; resending it unchanged will not help
    return badRequest(err.message);
  }
  if (isRateLimitError(err)) {
    // the transport already retried; this means the budget ran out
    return scheduleRetry(err.retryAfterSeconds ?? 60);
  }
  if (isR3EcomError(err)) {
    console.error(`${err.kind} ${err.status} ${err.code} req=${err.requestId ?? '-'}`);
  }
  throw err;
}

| Class | kind | When | | ---------------------------------- | ------------- | ----------------------------------------------------------------------------------- | | R3EcomAuthError | auth | 401 — key missing, malformed, revoked, expired | | R3EcomForbiddenError | forbidden | 403 — tenant not licensed for ecom, missing scope, or a disallowed browser origin | | R3EcomNotFoundError | not_found | 404 — no such resource in this tenant | | R3EcomValidationError | validation | 400 / 422 | | R3EcomConflictError | conflict | 409 — state moved, or an idempotency key replayed with a different body | | R3EcomRateLimitError | rate_limit | 429 — carries retryAfterSeconds | | R3EcomServerError | server | 5xx | | R3EcomTimeoutError | timeout | exceeded timeoutMs, aborted client-side | | R3EcomNetworkError | network | fetch rejected: DNS, TLS, reset, or a browser CORS block | | R3EcomInvalidKeyError | invalid_key | malformed key, or the wrong key class for this SDK | | R3EcomSignatureVerificationError | signature | a webhook failed verification; carries reason | | R3EcomPaginationError | pagination | the server repeated a cursor, so the walk refused to loop |

kind is a discriminated union, so a switch over it is exhaustive with no default branch. isRetryableError(err) answers the "is it worth another attempt" question in one call.

Retries and idempotency

The transport retries 429, 500, 502, 503 and 504 with full-jitter exponential backoff and honours Retry-After (up to maxRetryAfterMs, default 60s — beyond that it fails fast so the caller can reschedule rather than parking a connection).

Status is only half the decision; the method is the other half. GET, PUT and DELETE are replayed freely. A POST is replayed only when it carries an Idempotency-Key, because without one a replay could create a second order. Supply one on every write you care about:

await transport.post('/orders', body, { idempotencyKey: `cart:${cart.id}` });

Deriving the key from something stable (a cart id) also collapses your own retries — a double-tapped button, a replayed job — into one order. A random UUID per call protects only against the transport's internal replays.

Webhook verification

import { constructEvent, isSignatureVerificationError } from '@r3pos/ecom-core';

const raw = await request.text(); // RAW body — see the warning below

try {
  const event = await constructEvent({
    payload: raw,
    header: request.headers.get('r3-signature'),
    secret: process.env.R3_WEBHOOK_SECRET!,
  });
  // de-duplicate on event.id: delivery is at-least-once
} catch (err) {
  if (isSignatureVerificationError(err)) {
    // err.reason: 'malformed_header' | 'timestamp_out_of_tolerance'
    //           | 'no_matching_signature' | 'invalid_payload'
    return new Response('bad signature', { status: 400 });
  }
  throw err;
}

⚠️ Pass the raw body string. The signature covers the exact bytes the platform sent, so verifying JSON.stringify(await request.json()) fails every time — key order and whitespace do not survive the round trip.

Answer a failed verification with a 4xx. A 2xx marks the delivery as accepted and stops the platform retrying it.

Pagination

import { collectPages, type Page } from '@r3pos/ecom-core';

// lazily, one round trip at a time — `limit` is clamped into range for you
for await (const row of transport.paginate('/orders', { limit: 500 })) {
  /* … */
}

// whole pages, when you want to batch the work
for await (const page of transport.paginatePages('/orders')) {
  await db.insertMany(page.data);
}

// or eagerly, with a hard ceiling so a runaway list cannot exhaust memory
const categories = await collectPages(
  (cursor) => transport.get<Page<unknown>>('/catalog/categories', { query: { cursor } }),
  1_000,
);

autoPaginate throws R3EcomPaginationError if the server hands back a cursor it has already handed back, rather than silently truncating — a silent stop is indistinguishable from "no more data" and would hide missing rows.

Subpath exports

Everything is available from the root, and each module is also importable on its own if you want a narrower surface:

import { R3EcomError } from '@r3pos/ecom-core/errors';
import { assertSecretKey } from '@r3pos/ecom-core/keys';
import { autoPaginate } from '@r3pos/ecom-core/pagination';
import { createTransport } from '@r3pos/ecom-core/transport';
import { constructEvent } from '@r3pos/ecom-core/webhooks';
import { ECOM_SCOPES } from '@r3pos/ecom-core/contract';

The build is code-split across those entry points in both ESM and CJS, so there is exactly one R3EcomError class no matter which mix of subpaths you import — instanceof and the is*Error guards work across them.

Reference

The wire contract — routes, scopes, error codes, pagination, idempotency and the webhook event catalogue — is documented in docs/ecom/CONTRACT.md.

License

MIT © R3 Lab