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

@sixtyninecommerce/core

v0.10.0

Published

Transport, tenant context, error and pagination primitives shared by the sixtynine-commerce SDKs

Readme

@sixtyninecommerce/core

Transport, tenant context, errors and pagination for the sixtynine-commerce SDKs.

This package is the shared floor under @sixtyninecommerce/client (storefront) and @sixtyninecommerce/admin (back office). It talks to the three services a tenant runs on — the commerce engine, the CMS and the plugins service — and hides every wire detail behind one object:

  • TenantContext — which channel (or organization), which language, which order scope, which location, on whose behalf. The SDK stamps whatever header each service wants; consumers never see a header name.
  • Transport — GraphQL and REST calls with header stamping, timeouts, linked abort signals, retries that only ever fire on idempotent work, and channel re-resolution when a channel is renamed underneath you.
  • PlatformError — one error hierarchy over three wire dialects: RFC 9457 problem bodies, Vendure ErrorResult unions delivered at HTTP 200, and GraphQL errors[].
  • Page<T> — cursor pagination with .next() and for await (const item of page).
  • Idempotency keys (UUIDv7), W3C traceparent, rate-limit budgets, deprecation notices and snake/camel case conversion.

Zero runtime dependencies. It runs anywhere fetch and Web Crypto do: Node 20+, Bun, Deno, browsers, edge runtimes.

Install

npm install @sixtyninecommerce/core

Most consumers install @sixtyninecommerce/client or @sixtyninecommerce/admin instead and get this package as a dependency.

Entry points

The package root exports everything. Each group also has its own subpath, so a module that needs only the error classes does not load the transport, the events client, hooks and streams with them:

| Import | What it holds | | --- | --- | | @sixtyninecommerce/core/errors | PlatformError and its subclasses, and the readers that build them from a response: errorFromResponse, errorFromProblem, errorFromGraphQLErrors, errorFromErrorResult | | @sixtyninecommerce/core/transport | createTransport, tenancy (channelTenant, TenantContext), channel resolution, per-service headers, retries, rate limits, conditional reads, shared reads in flight, deprecation notices, the AI agent header (AGENT_HEADER, AgentInfo, parseAgentHeaderValue, formatAgentHeaderValue) | | @sixtyninecommerce/core/pagination | Page<T>, createPage, the cursor helpers | | @sixtyninecommerce/core/case | snake/camel case conversion | | @sixtyninecommerce/core/idempotency | idempotencyKey(), IDEMPOTENCY_KEY_HEADER | | @sixtyninecommerce/core/trace | W3C traceparent minting and parsing | | @sixtyninecommerce/core/events | the event envelope and payload types, verifyEventDelivery, createEventHandler, the event feed and stream, toCloudEvent | | @sixtyninecommerce/core/hooks | app hooks: the request and response contracts, createHookHandler, verifyHookRequest | | @sixtyninecommerce/core/streams | progress streams (openProgressStream) and their event types | | @sixtyninecommerce/core/apps | the app manifest JSON schema |

A subpath hands out the very bindings the root does: an error class imported from @sixtyninecommerce/core/errors is the same class as the root's, so instanceof holds whichever one each side imported. Subpaths resolve through package.json exports, so TypeScript needs moduleResolution node16, nodenext or bundler, which the root already requires.

Usage

import {
  channelTenant,
  createChannelResolver,
  createTransport,
  NotFoundError,
} from '@sixtyninecommerce/core';

const urls = {
  vendureUrl: 'https://engine.sixtyninecommerce.com',
  cmsUrl: 'https://cms.sixtyninecommerce.com',
  pluginsUrl: 'https://plugins.sixtyninecommerce.com',
};

const transport = createTransport({
  urls,
  channels: createChannelResolver({ cmsUrl: urls.cmsUrl }),
  tenant: channelTenant('velvet-thread', { language: 'nl' }),
  principal: { kind: 'bearer', token: process.env.API_TOKEN ?? '' },
});

try {
  const data = await transport.graphql<{ product: { name: string } | null }>({
    api: 'admin',
    query: 'query Product($id: ID!) { product(id: $id) { name } }',
    variables: { id: '42' },
  });
  console.log(data.product?.name);
} catch (error) {
  if (error instanceof NotFoundError) {
    // `error.code` is stable; `error.problem` is the raw RFC 9457 body when there was one.
  }
  throw error;
}

Errors

Every failure is a PlatformError: ValidationError (400), AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), ConflictError (409), BusinessRuleError (422), RateLimitedError (429), UpstreamError (5xx) and TransportError (no response at all). Branch on error.code — it is stable across releases — and read error.details for field-level reasons, error.retryAfterSeconds for a wait hint and error.traceId to find the request in the platform's audit trail.

On the GraphQL API a server-side failure is a 5xx whatever HTTP status carried it: SERVICE_UNAVAILABLE (the engine could not reach its database) is a retryable UpstreamError 503 with retryAfterSeconds, and INTERNAL_SERVER_ERROR, INTERNAL_ERROR or an error naming no code at all (GRAPHQL_ERROR) is a non-retryable UpstreamError 500. Neither is a BusinessRuleError: that class is for a refusal the caller can act on.

Any other database failure arrives with a generic sentence, never the database's own text. One the caller can act on carries the engine's code: DUPLICATE_RECORD is a ConflictError (409), INVALID_DATA a ValidationError (400), RECORD_IN_USE a BusinessRuleError (422). A server-side database fault is INTERNAL_SERVER_ERROR. On a write each one is flagged outcomeUnknown (below).

error.outcomeUnknown is true when a write may or may not have taken effect. Four cases set it:

  • The engine flags it. SERVICE_UNAVAILABLE with extensions.outcomeUnknown: the engine lost its database while the write may have run. Any other database failure of a write carries the same flag, whatever its code. The engine sends it at HTTP 200, never 503. A SERVICE_UNAVAILABLE error's status is 503 whatever the HTTP status was.

  • A write went out and no answer came back. A write is a GraphQL mutation, or a REST POST, PUT, PATCH or DELETE to the CMS or plugins that the transport does not repeat. A keyed write on a client with retryIdempotentWrites is repeated under its key instead. The cases are:

    • a timeout (REQUEST_TIMEOUT);
    • your own signal aborting it after it was sent (REQUEST_ABORTED): a Cancel button, an unmount, AbortSignal.timeout(n), a forwarded request.signal. A signal that was already aborted when you made the call is not flagged, because nothing was sent;
    • a dropped connection (NETWORK_ERROR);
    • a 5xx no platform component coded, such as a proxy's 502 or 504, or an empty 503;
    • a 2xx body nobody can read (GraphQL only: a REST 2xx is the service's success).

    The error keeps its class, code and status. One exception: a network failure that provably happened before the request left, such as a DNS failure or a refused connection, stays retryable. Only Node's fetch can prove that.

  • IDEMPOTENCY_OUTCOME_UNKNOWN: a spent idempotency key whose first call may have moved money.

  • POS_CARD_OUTCOME_UNKNOWN: a till's card charge that the payment provider may have taken without answering. The terminal may still be prompting the customer, and the charge left no payment on the order, so reading the order back cannot settle it. Look at the terminal and at the provider before taking another card payment for that sale.

Such an error is never retryable and carries no wait hint. Read the resource back and reconcile before doing anything again. Repeating the call blindly is how a write lands twice. A query is never flagged, because asking again cannot write twice.

A write sent with an idempotency key is the exception to "reconcile first". When its answer never came back, the key is not spent and the first call may still be running, so repeat it under the SAME key: the platform answers with the first call's result, IDEMPOTENCY_IN_PROGRESS (wait, then repeat), or IDEMPOTENCY_OUTCOME_UNKNOWN. Only that last code means the key is spent: reconcile, and use a NEW key only once you know the first write did not land. A new key after a timeout can apply the write twice.

The flag follows each code's own contract, not its spelling. INVOICING_OUTCOME_UNKNOWN is NOT flagged. It means "the document is not settled in the ERP yet: repeat the same action later". That is safe, because the platform asks the ERP first and never books twice.

Pagination

for await (const item of await listSomething()) {
  // walks every page; `page.next()` is there when you want one page at a time
}

Tenancy

TenantContext says which channel (or organization) a call is addressed to, in which language, on whose behalf; the SDK turns it into whatever each service reads it from — a header on one, a query parameter on another. A call may override the client's default for itself.

UNSCOPED_TENANT is the exception: a session login, and the me that reads back which channels the session may address, happen before the caller knows its tenant. Those go out with no tenant header at all, and must opt in with allowUnscoped. Every other call on an unscoped tenant throws TENANT_REQUIRED — an administrator with several channels has to say which one they mean, and the server never guesses.

A channel code becomes the channel's id and token through the transport's channels resolver. createChannelResolver asks the CMS (GET {cms}/channels/by-code/:code, cached for 5 minutes). A client that already holds the list of channels it may address, and must not depend on the CMS, uses createChannelListResolver instead: an admin app hands it its session's me().channels.

import { createChannelListResolver } from '@sixtyninecommerce/core';

const channels = createChannelListResolver({ load: async () => (await me()).channels });

The list is loaded once for every code and again after an invalidate, or for a code it does not name. A code that a fresh list does not name either is CHANNEL_NOT_FOUND. When a service answers CHANNEL_NOT_FOUND for a resolved channel, the transport invalidates, loads the list again and repeats the call once. The answer's language, currency, tax display and custom fields are '', '', false and {} unless the list carries them, so read those from the store's settings.

Case conversion

The REST services speak snake_case and this SDK speaks camelCase, so request and response bodies are converted. Some values are data rather than structure — a form's settings, an integration's credentials, a field's validation — and converting those renames a merchant's own keys. Name them in preserveKeys and their subtree is left alone — the key itself is still renamed, because the key is DTO shape and only what is inside the bag is the merchant's:

import { PLATFORM_PASSTHROUGH_KEYS } from '@sixtyninecommerce/core';

transport.rest('cms', call, { preserveKeys: PLATFORM_PASSTHROUGH_KEYS });

PLATFORM_PASSTHROUGH_KEYS lists the platform's stored-verbatim columns. It is not applied automatically: a name means different things on different endpoints, so the module that owns an endpoint decides.

Retries

Only idempotent work is retried: GET requests and GraphQL query documents. Network failures, 502/503/504 (a GraphQL SERVICE_UNAVAILABLE too, even beside partial data at 200, unless it says outcomeUnknown) and 429 are retried — after the service's wait hint when it sent one (Retry-After, or the error's own retryAfterSeconds), capped at maxDelayMs, otherwise with exponential backoff. Nothing else ever is, and an aborted request never is.

A REST write is not retried by default, even with an idempotencyKey. CMS and plugins do deduplicate on the header — a repeat under the same key replays the first response, and the same key with a different body is refused with 409 — but only on the routes marked @Idempotent(): today POST /forms, PUT /forms/by-slug/:slug and POST /redirects. Everywhere else the key is recorded on the wire and acts on nothing, so a retry there is a second write: a second row, a second submission, a second notification email. This transport cannot tell the two apart, so pass retryIdempotentWrites: true only for a client whose writes all land on routes that honour it.

A GraphQL mutation is never retried, idempotency key or not. On a Shop API call the idempotencyKey goes out as an Idempotency-Key header, and addPaymentToOrder deduplicates on it — but an engine that predates that ignores the header, so a transport retry there could replay a payment the PSP already took. The Admin API gets no header: its key is a mutation argument, so pass it in variables if the mutation accepts one. Either way, drive the repeat from the module that knows it is safe.

Deadlines and traces per call

Every call has a deadline: timeoutMs on the transport (15 seconds unless configured), or on the call itself, which wins. It covers the request and reading its answer; a value that is not above 0 and at most MAX_TIMEOUT_MS is refused (TIMEOUT_INVALID) before anything is sent. A call cut by its deadline is REQUEST_TIMEOUT, on React Native's expo/fetch too.

Every call also carries a W3C traceparent, minted per call unless the call names one. Name the same one on every call of a user flow, and the platform's audit trail shows them as one trace:

import { mintTraceparent } from '@sixtyninecommerce/core';

const flow = mintTraceparent();
await transport.graphql(refund, { traceparent: flow, timeoutMs: 30_000 });
await transport.graphql(readBack, { traceparent: flow });

A traceparent that is not a valid header is refused (TRACEPARENT_INVALID) before anything is sent.

Conditional reads

createTransport({ conditionalReads: true }) remembers each public REST read's ETag and body, sends If-None-Match the next time, and answers a 304 from what it remembered (spec §6.8). Off by default; @sixtyninecommerce/client turns it on for its CMS/plugins transport.

  • Only an anonymous REST GET is eligible. A call with a bearer skips the cache entirely, and no GraphQL call is ever cached.
  • The key is everything the request sends except traceparent, Idempotency-Key and x-buyer-ip: the full URL (query sorted), every tenant, language, head, actor and storefront-token header, and the fetchInit passthrough. A fetchInit that is not plain data is never cached. Stored entries are shared between shoppers; requests are not (a request carries its shopper's rate-limit budget).
  • true shares one process-wide cache per fetch function (500 entries / 5 MB, least recently used first out); { maxEntries?, maxBytes? } gives the transport a cache of its own.
  • Stored: a 200 with an ETag, a JSON body, no no-store and no Vary: *. Errors never are. Every hit is parsed afresh, and identical concurrent reads share one request (each caller keeps its own signal and deadline; the shared request stops when the last caller leaves, or at the deadline of the caller that started it).
  • It sits ABOVE the fetch it is given. A fetch with a cache of its own that keys on request headers (Next.js's data cache) misses whenever If-None-Match is added: turn one of the two off.

Read admission

createTransport({ readAdmission: { limiter, key } }) caps the GraphQL reads in flight per key and queues the rest before anything is sent. Off by default.

import { createReadAdmission, createTransport } from '@sixtyninecommerce/core';

// One limiter for the process: every transport handed it shares its caps.
const shopReads = createReadAdmission({ limit: 8, maxQueueMs: 30_000 });

const transport = createTransport({
  urls, // { vendureUrl, cmsUrl, pluginsUrl }
  // …principal, tenant, channels
  readAdmission: {
    limiter: shopReads,
    // Signed-in reads wait per session; anonymous ones go out at once.
    key: ({ api, bearer, tenant }) =>
      api === 'shop' && bearer ? JSON.stringify([urls.vendureUrl, tenant.scope, bearer]) : null,
  },
});
  • Only reads wait. The transport asks key about each attempt of a GraphQL query, with the bearer that attempt carries. Mutations, uploads and sign-ins (startsSession) are never asked and never wait. REST calls and event streams are not admitted. A null key sends the read at once.
  • Queue time is not request time. A read is admitted before its deadline is armed, so a wait never counts toward timeoutMs and never triggers a retry. Slots go out in the order the reads were made.
  • A slot covers one attempt. It is held for the request and reading its answer, and comes back however the attempt ends: an answer, a refusal, a timeout or an abort. A retry backs off holding no slot, then waits behind the reads already queued.
  • Waiting is bounded. A read still queued after maxQueueMs (30 seconds unless set) is refused with REQUEST_QUEUE_TIMEOUT, a non-retryable TransportError. Nothing was sent, so it is never outcomeUnknown. A signal that fires while the read is queued is REQUEST_ABORTED, and nothing is sent then either.
  • Keys are shared through the limiter. Hand the same limiter to every transport whose reads should share a cap, such as one client per request for one session. A key with nothing in flight or waiting is forgotten. The limiter keeps keys in memory only and never puts one in an error. A key built from a bearer is a credential, so never log it yourself.
  • limit must be a whole number of at least 1 (default 8). maxQueueMs must be above 0 and at most 2³¹ − 1 (default 30 s). Anything else, and a readAdmission that is not { limiter, key }, is refused with READ_ADMISSION_INVALID. A key function that answers something other than a string or null fails the call with READ_ADMISSION_INVALID, and nothing is sent.

Shared reads in flight

createTransport({ dedupeReads: true }) lets identical GraphQL queries in flight share one request: a page whose layout, body and metadata each ask for the same product sends it once. Off by default; anything but true or false is refused when the transport is built (DEDUPE_READS_INVALID).

  • Two queries share only when their requests are the same in everything the server reads. That covers the endpoint, the language and currency on the URL, the document and its variables byte for byte, the bearer, the channel token, every other header except a minted traceparent, and the fetchInit passthrough. Two shoppers, channels, languages or currencies never share.
  • Every transport built on the same fetch shares, process-wide: a client built per request shares with the client built for another part of the same render.
  • Each caller parses its own copy, keeps its own signal, retries on its own, and gets its own error (with the trace id of the request that went out). The shared request is aborted when every caller has left, or at the deadline of the caller that started it. A caller that joined with a longer deadline then fails at that earlier one: REQUEST_TIMEOUT where fetch rejects an abort as an AbortError (Node, browsers), its message still naming its own timeoutMs. Nothing is kept: a query after the answer was read sends again.
  • Never shared: mutations, uploads, sign-ins (startsSession), calls with an idempotency or order access key, and a fetchInit that is not plain data.
  • A write through the transport (a mutation or a REST write) stops its session's reads in flight from being joined when it goes out and again when it is answered. A read made after a write never gets an answer from before it.
  • A session token that the answer to a read sent with no bearer hands out (Vendure mints one for activeOrder and other Owner-allowed reads) goes only to the caller who sent the request. Every other caller gets the same answer without it, and mints a session of its own on its next Owner-allowed call, so two guests are never handed one session.
  • The key is what the transport sends. A fetch that adds a credential per caller (a cookie it forwards) must be one function per caller, or keep this off.
  • A caller that joins never calls fetch, so a fetch that records each render's requests sees only the request that went out. On Next.js's patched fetch, a render that joins another render's request records none of that request's cache tags (fetchInit.next.tags). A revalidateTag of a tag only that request carried then never reaches the joining page, which keeps the old answer until it is revalidated some other way. Keep this off for pages cached by tag.
  • With read admission on, a shared request holds one slot: the slot of the caller that started it, until the request settles, even when that caller leaves first. A caller that joins one in flight neither queues nor holds a slot.

Verifying webhook deliveries

Every platform-event delivery is signed twice: with the Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature) and with the older x-webhook-signature: sha256=<hex>. verifyEventDelivery checks whichever set arrived — the standard one wins when present — then the replay window and the envelope shape:

import { verifyEventDelivery } from '@sixtyninecommerce/core';

export async function POST(request: Request) {
  const raw = await request.text(); // the exact bytes: never parse and re-stringify
  const result = await verifyEventDelivery(raw, request.headers, process.env.EVENTS_SECRET!);
  if (!result.ok) return new Response(result.reason, { status: result.status });
  // result.envelope.type, result.envelope.data …
  return new Response(null, { status: 204 });
}

The secret is the one shown when the subscription was created. Its Standard Webhooks spelling is whsec_ + base64 of that string's UTF-8 bytes, so a stock Standard Webhooks library verifies the same deliveries; verifyEventDelivery accepts either spelling, and a list of secrets for a rotation overlap. webhook-id is the event id and stays the same across retries: deduplicate on it.

Rotating the secret. admin.events.subscriptions.rotateSecret(id) answers a new secret once. For 24 hours after (previousExpiresAt) every delivery is signed with both: webhook-signature carries v1,<new> v1,<old>, and the legacy x-webhook-signature stays on the old secret. Deploy the receiver with [newSecret, oldSecret], then drop the old one after previousExpiresAt.

Receiving events

createEventHandler is a whole webhook endpoint as one (request: Request) => Promise<Response>. It verifies the delivery (see above) and calls the handler registered for the event's type, with data typed by that type's payload contract:

import { createEventHandler } from '@sixtyninecommerce/core';

export const POST = createEventHandler({
  secret: process.env.EVENTS_SECRET!,
  on: {
    'order.status_changed': async (event) => {
      if (event.data.payment_status === 'paid') await fulfil(event.data.order_id);
    },
  },
  onUnknown: (event) => console.info('unhandled', event.type),
});

It answers 200 once your handler resolves, 401/400 for a delivery that does not verify, and 500 when your handler throws, so the platform retries. Every payload type is exported (PlatformEventPayloadByTypeV1, OrderStatusChangedV1, …); isEventType(event, type) narrows an envelope you got some other way.

To pull instead of being pushed to, use createEventsClient(transport) (or admin.events in @sixtyninecommerce/admin), with a token holding ReadPlatformEvents. poll({ after }) reads one page forward, poll({ from: 'now' }) starts at the head (it needs a platform that serves GET /events/head, and throws NotFoundError on one that does not), pollAll() is a never-ending async iterator, and replay({ fromLogSeq }) rereads from a position. Store nextCursor after every page.

To have events pushed down one connection instead, use stream() (Server-Sent Events):

const stream = events.stream({ after: savedCursor, types: ['order.*'] });
for await (const event of stream) {
  await handle(event);
  await save(stream.cursor); // a stream cursor is a feed cursor: poll({ after }) resumes from it
}

It reconnects by itself from the last event it yielded and never yields one twice. A credential's live streams are limited (one by default): opening another closes the oldest with EventStreamClosedError (superseded). A revoked credential ends it with EventStreamClosedError (revoked). Neither case is retried. Events reach the log through relays that tick once a minute, so they arrive up to about a minute after the change. For a native EventSource, which cannot send Authorization, streamTicket() returns a single-use, 60-second ticket and a ready url.

File uploads

transport.rest() sends multipart/form-data when the call carries multipart instead of body:

await transport.rest('cms', {
  method: 'POST',
  path: '/assets/upload',
  multipart: { fields: { folder_id: folderId }, files: [{ field: 'file', file }] },
});

Field names, values and file names go out exactly as given (no case conversion), fetch sets the boundary, and the call is never retried. A file part that is not a Blob or File is refused before anything is sent (MULTIPART_FILE_INVALID). Unless the call names a timeoutMs, it gets MULTIPART_TIMEOUT_MS (5 minutes).

Progress streams

openProgressStream() follows one long-running job over its server-sent event stream. You name a kind from a closed set (PROGRESS_STREAM_KINDS) and the job's id, never a path:

for await (const event of openProgressStream(
  transport,
  { kind: 'price-import-ai-conversion', id: conversionId },
  { signal }
)) {
  if (event.type === 'progress') show(event.message);
  else if (event.type === 'result') use(event.result);
  else fail(event.code, event.message);
}

The iteration ends after the terminal event. It throws PROGRESS_STREAM_STALLED after idleTimeoutMs (45 s) of silence, PROGRESS_STREAM_CLOSED if the stream ends before a terminal event, and REQUEST_ABORTED when signal fires.

| Kind | Id | Events (terminal in bold) | Reconnects | |---|---|---|---| | price-import-ai-conversion | the upload's conversionId | progress, result, error | no: the job lives in the connection | | pipeline-run | a pipeline run's id | progress (per-entity counts), complete, error, cancelled, paused | yes | | pipeline-discovery | a discovery's id | progress (a percentage), complete, error | yes |

A kind that reconnects reopens a connection that closed, broke, stalled or could not open (a network error, 5xx, 429) before its terminal event: at most maxReconnects times in a row (default 5; an event received resets the count), waiting reconnectDelayMs (1 s) doubling to 30 s, telling onReconnect each time. The server answers an ended job with its ended state on every connection, so a reopen still reads the outcome; only once the reopens are spent does the iteration throw PROGRESS_STREAM_CLOSED / PROGRESS_STREAM_STALLED. Do not wrap it in a reconnect loop of your own. A refusal is never retried: 401, 403, 404 (the plugins service's answer for a run or discovery outside the caller's store), and PROGRESS_STREAM_REFUSED, the same refusal as an older plugins service sends it, inside the opened stream.

License

MIT