@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.