@repost/portal-js

The framework-agnostic TypeScript client under the hooks (typed resources over the Portal API, for any frontend).

@repost/portal-js is the foundation @repost/portal-react builds on: a typed client for the Portal API that works in any browser app, whether Vue, Svelte, vanilla, or React without the hooks.

pnpm add @repost/portal-js
import { createPortalClient } from "@repost/portal-js";
 
const portal = createPortalClient({
  getToken: () =>
    fetch("/api/repost-portal-token", { method: "POST" })
      .then((res) => res.json())
      .then(({ token }) => token),
});

The client caches the token, refreshes it through getToken before expiry, and replays a request once after a 401 with a fresh token. (A static token option exists for short-lived scripts.)

Surface

Resource namespaces mirror the Portal API one-to-one:

const context = await portal.context.get();
const { endpoints } = await portal.endpoints.list();
const created = await portal.endpoints.create({ url: "https://example.com/hooks" });
 
const page = await portal.logs.list({ query: "status:500", limit: 50 });
const histogram = await portal.logs.histogram({ dateFrom, dateTo });
const detail = await portal.deliveries.attempts(page.items[0].deliveryId);
 
const { count } = await portal.dlq.count();
await portal.deliveries.replay(deliveryId);
 
const { job } = await portal.replayJobs.prepare({ endpointId, mode: "recover", since, until });
await portal.replayJobs.confirm(job.id);

Cursor-paginated lists have for await iterators:

for await (const row of portal.logs.iterate({ endpointId })) {
  // every page, exhaustively
}

And the live feed rides a websocket, never polling:

const subscription = await portal.realtime.subscribeLogs({ endpointId }, {
  onDoc: (doc) => render(doc),
  onStatus: (status) => setLive(status === "live"),
  onDropped: (count) => note(count),
  onInvalidQuery: () => {},
});
// later: subscription.unsubscribe(); portal.realtime.close();

Types

Every request and response type is generated from the Portal API's OpenAPI contract and exported: Endpoint, LogRow, Delivery, Attempt, ReplayJob, EventType, PortalContext, ... They cannot drift from the wire: CI regenerates and diffs them against the contract.

Errors and rate limits

Non-2xx responses throw PortalApiError with the HTTP status, a machine-readable code (unauthorized, not_found, validation_error, rate_limited, ...), and issues on validation failures.

The client is a good citizen by construction:

  • identical concurrent reads coalesce into one request,
  • it paces itself from the API's RateLimit-* headers before ever hitting the wall,
  • 429s retry with jittered backoff honoring Retry-After,
  • in-flight concurrency is capped.

A runaway render loop in your app exhausts a local cache, not your rate budget.

Embedding

The package also ships the iframe embed as a framework-agnostic controller, so Vue, Svelte and vanilla hosts get exactly what <RepostPortal> gives React, including drawers and dialogs that cover the viewport rather than being clipped to the frame.

import { createPortalEmbed } from "@repost/portal-js";
 
const embed = createPortalEmbed({
  url: portalUrl,
  getPortalToken: () => fetch("/api/portal-token").then((r) => r.json()).then((d) => d.token),
  darkMode: prefersDark,
});
 
embed.mount(document.getElementById("portal")!);

mount(container) creates the frames and starts the handshake. update(options) applies new options: only a changed url or surface reloads the frame, so swapping the token callback or toggling the theme on every render is free. unmount() removes both frames, releases the scroll lock and drops every listener.

surface narrows the frame to one portal area ("endpoints", "event-catalog", "logs" or "dead-letter-queue"), with no portal header and no tabs, for hosts that want their own page structure (bare surfaces):

const logs = createPortalEmbed({
  url: portalUrl,
  getPortalToken: getToken,
  surface: "logs",
  onNavigationIntent: ({ surface }) => goTo(`/webhooks/${surface}`),
});

The frame stays inside that surface. A navigation that would leave it is suppressed and reported to onNavigationIntent instead, so your app can route its own page. The customer stays where they are either way.

Options are the same as the React props: url, getPortalToken, surface, darkMode, overlays, overlayZIndex, onNavigationIntent, plus onLoadedChange for driving your own loading state. Embed the portal documents each one, the CSP requirement, and the fallback behavior on older versions.