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

@foir/sdk

v0.30.1

Published

Official Foir SDK — editor iframe client + server-side platform helpers

Readme

@foir/sdk

Official Foir SDK — client-agnostic typed GraphQL transport, sign-in for your app's users, progressive media, content rendering, realtime collaboration, and editor/server helpers for building apps on the Foir platform.

The SDK is split into independent subpath exports so consumers only pull what they use. Everything heavy (react, graphql, yjs, hls.js, …) is an optional peer dependency gated to the subpath that needs it.

Install

pnpm add @foir/sdk
# plus the peers for the subpaths you use, e.g.:
pnpm add graphql            # for @foir/sdk/client + @foir/sdk/auth
pnpm add react              # for @foir/sdk (editor), /media, /content
pnpm add yjs y-websocket    # for @foir/sdk/realtime

Subpath exports

| Import | What it is | Peers | | --- | --- | --- | | @foir/sdk | Editor iframe client — EditorProvider, useEditor, useAutoResize, createPlatformClient, host↔editor message protocol | react | | @foir/sdk/client | Client-agnostic GraphQL transport for SSR/edge loaders — foirRequest, isFoirPreview, absolutiseFoirMediaUrl | graphql | | @foir/sdk/data | Typed data client over /graphql — createFoirClient, per-key-shaped ops, include/omit, fetchFoirManifest; prints the document for you, so still no graphql | none | | @foir/sdk/auth | Framework-agnostic sign-in for your app's users: login/register/OTP/provider flows, token lifecycle (SessionAdapter), createFoirSessionClient (Foir-hosted cookie sessions), authedRequest, guards | graphql | | @foir/sdk/react | Drop-in React auth — <FoirProvider> (token or hostedSession mode), useFoirSession/useFoirUser/useFoirAuth, <SignIn>, <OAuthButtons>, <UserButton> | react, graphql | | @foir/sdk/next | Next.js App Router BFF auth — createFoirCookieAdapter, getFoirSession, foirSignIn*, completeFoirProviderLogin, createFoirMiddleware | graphql | | @foir/sdk/server | Server-side helpers for extension/config API backends — createServerClient, verifyWebhookSignature, verifyScopedToken, callback client | — | | @foir/sdk/live | Live reads — the client half of Foir's invalidation graph: tag index, raw-payload store, seq ordering, record-change → tag mapping | none | | @foir/sdk/live/react | <FoirLive> — one socket, refcounted per model, wired to a query client you already have | react | | @foir/sdk/media | Progressive media — ProgressiveMedia, BlurhashCanvas, useProgressiveLoad, variant-tier adapters | react (hls.js, blurhash optional) | | @foir/sdk/content | Content-rendering primitives — createContentRenderer block dispatch + Lexical RichTextRenderer | react | | @foir/sdk/realtime | Thin collaboration helper over y-websocket — canonical room addressing, presence, token refresh | yjs, y-websocket | | @foir/sdk/codegen | Build-time graphql-codegen preset (foirCodegen) + exhaustive block-registry plugin | @graphql-codegen/cli, @graphql-codegen/client-preset (devDeps) |

Usage

Typed data client (no GraphQL, no codegen step)

@foir/sdk/data executes over the same /graphql endpoint every other consumer uses — one execution surface, one executor, one set of scope checks, one response cache. What you skip is writing the documents: the client loads the selection tree GET /v1/manifest publishes for that op, applies your include/omit, and prints one.

Printing a pre-resolved tree is string building, so the package still needs no graphql dependency at all — no graphql, no @graphql-typed-document-node/core, no @graphql-codegen/*, and no new peer.

import { createFoirClient } from '@foir/sdk/data';
// generated by `foir types` from GET /v1/manifest
import * as generated from './foir.generated';

const foir = createFoirClient({
  apiUrl: process.env.PLATFORM_API_URL!,   // a bare origin or the /graphql URL
  apiKey: process.env.PLATFORM_API_KEY!,
  generated,                               // the whole module: types, trees, version
});

const post = await foir.post.findUnique({
  naturalKey: 'hello',
  include: { author: true },   // relations opt in
  omit: ['bodyRichtext'],      // drop the heavy field
});

const posts = await foir.post.findMany({ first: 10 });   // a Relay connection
const hits  = await foir.ns.search.searchRecords({ query: 'shoes' });
const other = await foir.raw('{ designTokens { colors } }');  // any document, any root field

One import, no type parameter. generated carries both halves of what foir types writes — the FoirSchema type and the runtime selection trees the documents are printed from — so FoirSchema is inferred rather than restated, and an artifact added to the generator later costs your call site nothing. A module missing one of those exports is a compile error naming it, not a quiet degrade: pass it whole, or do not pass it.

The fields it replaces (surfaceVersion, surfaceHashes, selections, namespaceSelections, selectionUnions) still work and still win where both are given.

Drift is keyed on the surface. The module records the surfaceVersion it was generated from: a hash over exactly what foir types reads, which moves only when regenerating would write a different file. Pushing an operation, a role or a key moves the server's invalidation counter and not the surface, so it is not drift. Publishing a field on a model the types include is, and the client reports it once, naming the model.

Drift does not stop a call. A surface that has grown still answers every document the module prints. A typed call is refused, with FoirSchemaDriftError, only when something it depends on is no longer served as generated: a field its selection names, its op, one of its arguments, or the target of a relation it includes. The message names that part, and drift.incompatible lists every such part as a path (models.order.selection.status). The remedy is foir types; strictSchemaDrift: false never is.

Scalars are always complete, so post.title is typed present and is present — there is no partial-select footgun. include adds a relation key, omit removes a scalar, and polymorphic fields are discriminated unions on __typename that narrow under an exhaustive switch with no casts.

The client is shaped by your credential. The generated FoirSchema contains only what that key's manifest lists, so a read-only publishable key has no create to call:

foir.post.create({ data });   // ✗ compile error under a publishable key: not in its manifest

generated is optional. Without it — and without selections — the client fetches GET /v1/manifest once at first use and reads the same trees off it. A fallback, so a zero-codegen start works, at the cost of a round trip in front of the first query on every cold invocation. It says so, once, when it is built that way; silence it with announceManifestFallback: false.

Reads that can be shared are sent to GET /cdn/graphql with only the persisted-query hash in the URL, so a CDN in front of you can serve the next visitor without reaching origin. That is a query, on a publishable key, with no user bearer, on the published channel; everything else is a POST /graphql.

foir.raw() is the escape hatch, and it takes a GraphQL document — it reaches anything the credential can, including root fields no model or namespace claims. It returns the whole data object.

Namespaces (search, users, notifications) live under foir.ns because the client carries no runtime schema to tell a namespace from a model. ns and raw are the only reserved names.

Session-bound reads take a bearerToken, as a string or a function resolved per request:

const foir = createFoirClient({
  apiUrl,
  apiKey,
  generated,
  bearerToken: () => session.get('foir_access_token'),
});

Already have PLATFORM_API_URL / PLATFORM_API_KEY / FOIR_PREVIEW wired for the GraphQL path? createFoirClientFromEnv(env, { generated }) reuses them, including the preview key and the draft schema channel.

A product built on organizations names the one a request acts in with orgId, which the client sends as X-Org-Id on every request. This is for the PROJECT credential — the back-office, or the app that has just resolved a subdomain to an organization; a storefront keeps using the credential confined to its own organization, which orgId cannot steer elsewhere:

const foir = createFoirClient({ apiUrl, apiKey, generated, orgId });

Typed GraphQL from a server loader

foirRequest is a typed fetch wrapper, not a GraphQL client: it carries the Foir-specific transport concerns (x-api-key, preview schema channel, error shape) and runs generated TypedDocumentNodes. Bring-your-own-client consumers (Apollo/urql/react-query) use the generated documents directly and replicate those headers.

import { foirRequest, type FoirEnv } from '@foir/sdk/client';
import { HomePageDocument } from './generated/graphql';

export async function loader({ context }: LoaderArgs) {
  const env: FoirEnv = {
    PLATFORM_API_URL: context.env.PLATFORM_API_URL,
    PLATFORM_API_KEY: context.env.PLATFORM_API_KEY,
  };
  const data = await foirRequest(env, HomePageDocument, { slug: 'home' });
  return data.homePage;
}

Signing your users in

import {
  signInWithPassword,
  authedRequest,
  type SessionAdapter,
} from '@foir/sdk/auth';
import { MyProfileDocument } from './generated/graphql';

// SessionAdapter = { get, set, delete } over your session store (cookie, KV…).
const user = await signInWithPassword(adapter, env, { email, password });

// Bearer-authed request with automatic refresh-and-retry on expired sessions.
const { myProfile } = await authedRequest(adapter, env, MyProfileDocument);

React

@foir/sdk/react builds the reactive layer on top of @foir/sdk/auth. Wrap your app once, then read the session with hooks or drop in the components. The default session store is localStorage; pass your own adapter for cookies.

'use client';
import { FoirProvider, SignIn, UserButton, useFoirSession } from '@foir/sdk/react';

const env = {
  PLATFORM_API_URL: process.env.NEXT_PUBLIC_PLATFORM_API_URL,
  PLATFORM_API_KEY: process.env.NEXT_PUBLIC_PLATFORM_API_KEY, // a publishable key
};

export function App() {
  return (
    <FoirProvider env={env}>
      <Header />
      <SignIn afterSignInUrl="/dashboard" />
    </FoirProvider>
  );
}

function Header() {
  const { isAuthenticated, isLoading } = useFoirSession();
  if (isLoading) return null;
  return isAuthenticated ? <UserButton afterSignOutUrl="/" /> : null;
}

For a custom form, use useFoirAuth() (signIn, register, signInWithOtp, requestOtp, signInWithProvider, signOut, getToken) and useFoirUser().

Backend-less SPA (Foir-hosted sessions)

By default <FoirProvider> stores tokens in localStorage. If your SPA has no backend but you have a verified custom login domain on the same registrable domain (app.acme.com + login.acme.com), prefer hostedSession: the durable credential becomes an HttpOnly cookie Foir sets on your domain, and the 15-minute access token lives only in memory. Nothing authentication-related is reachable from JS, so XSS has nothing to exfiltrate — the BFF security posture without a backend.

<FoirProvider
  env={{ PLATFORM_API_URL: 'https://api.foir.dev/graphql', PLATFORM_API_KEY: 'foir_pub_…' }}
  hostedSession={{ loginOrigin: 'https://login.acme.com' }}
>
  <App />
</FoirProvider>

Sign-in is a redirect, not a form — useFoirAuth().hostedSignInUrl() gives you the URL (the password/OTP actions throw in this mode). getToken() exchanges the cookie and re-exchanges it silently before expiry; signOut() revokes the session server-side. Everything else — useFoirSession, useFoirUser, <UserButton> — behaves the same.

Framework-agnostic equivalent:

import { createFoirSessionClient } from '@foir/sdk/auth';

const session = createFoirSessionClient({ loginOrigin: 'https://login.acme.com' });
const token = await session.getToken(); // null when signed out

Next.js (App Router, BFF)

@foir/sdk/next keeps the tokens in HttpOnly cookies server-side. Protect routes with the middleware, read the session in a server component, and sign in from a route handler.

// middleware.ts
import { createFoirMiddleware } from '@foir/sdk/next';
export const middleware = createFoirMiddleware({ signInUrl: '/sign-in' });
export const config = { matcher: ['/dashboard/:path*'] };
// app/dashboard/page.tsx (Server Component)
import { cookies } from 'next/headers';
import { getFoirSession } from '@foir/sdk/next';

export default async function Dashboard() {
  const user = await getFoirSession(await cookies());
  return <p>Signed in as {user?.email}</p>;
}
// app/sign-in/route.ts (Route Handler)
import { cookies } from 'next/headers';
import { foirSignInWithPassword } from '@foir/sdk/next';

export async function POST(req: Request) {
  const { email, password } = await req.json();
  await foirSignInWithPassword({ env, store: await cookies(), email, password });
  return Response.json({ ok: true });
}

Codegen

// codegen.ts
import { foirCodegen } from '@foir/sdk/codegen';

export default foirCodegen({
  apiUrl: process.env.PLATFORM_API_URL!,
  apiKey: process.env.PLATFORM_API_KEY!,
  // documents: 'app/graphql/foir/**/*.graphql' (default)
  // outputDir: './app/gql/foir/' (default)
});

Editor extensions

import { EditorProvider, useEditor } from '@foir/sdk';

function App() {
  return (
    <EditorProvider>
      <MyEditor />
    </EditorProvider>
  );
}

function MyEditor() {
  const { init, client } = useEditor(); // record context + platform GraphQL client
  // ...
}

Live reads

@foir/sdk/live binds a cached read to the server's own dependency graph. Every cacheable response already tells you which records and collections it depends on, and the change stream already tells you when one of them moved; this is the join.

import { FoirLive, useFoirLive, useFoirLiveStatus } from '@foir/sdk/live/react';

<FoirLive
  queryClient={queryClient}
  subscribe={(model, options, handlers) => foir[model].onChange({ ...handlers, ...options })}
>
  <ProductList />
</FoirLive>;

function ProductList() {
  useFoirLive('product');                       // watch, for as long as this is mounted
  const { newCounts } = useFoirLiveStatus();    // "3 new products — show"
  // ...
}

An adapter, not a store: you keep the query library you have, and it owns your data. What this owns is which entries a change invalidates — matched on the record, not the model, so one edit does not refetch every list.

Live is opt-in per model. Nothing subscribes because the provider is mounted: the shared CDN lane is a major asset, and blanket liveness pushes read traffic off it onto the origin.

What it is not, and will not become

No local mutation log, no offline mode, no conflict resolution, no optimistic rollback. The server is authoritative: mutations go over the wire and the invalidation comes back.

That is a product boundary rather than a phase. Adding optimistic mutations with rollback would make this a different category of library, with its own roadmap and a permanent support surface. If you need one, use one — this is not it, and its absence is not a bug.

Bursts, and the deploy that changes what a response means

A bulk import or a publish sweep fires a burst on one model. <FoirLive> coalesces it: fifty invalidations of one query inside a 25ms window are one refetch, at most four refetches run at once, and a query invalidated again mid-refetch is owed exactly one more. Tune with coalesceWindowMs and maxConcurrentRefetches.

Schema drift is separate and easy to miss, because nothing else catches it. A deploy that renames a field leaves every cached entry current and quietly wrong — no record changed, nothing is out of order, and the bytes describe a schema that no longer exists. Wire it up once:

import { createFoirDriftHandler } from '@foir/sdk/live';

createFoirClient({
  // ...
  onSchemaDrift: createFoirDriftHandler({
    resync: () => {
      store.resync();
      queryClient.clear();
    },
  }),
});

Known limitation: mutable sort keys

A query ordered by a mutable key — updatedAt, or a position a user drags — can have a record move between pages when it is updated, so it may appear twice or vanish until the next full refetch.

There is deliberately no detection for it: that needs client-side knowledge of the sort fields, and the fully correct alternative turns a routine edit into an N-page refetch on most feeds. Pass mode="notify" on screens where it matters and refetch on the user's terms.

Pagination, and why the three cases differ

Foir is pure Relay cursor pagination with no offset, so loaded pages keep their order and endCursor stays correct. Only three things break, and each gets the treatment the data supports:

  • deleted — the row is removed by id, with no fetch. The event carries the id, so this is instantly correct.
  • created — a count, not a refetch. A create cannot be spliced (record events carry no body, and you could not place the row without knowing the sort), and refetching the head page prepends under a reader mid-scroll.
  • updated — the queries holding that record refetch. Membership is unchanged, so cursors hold.

totalCount is flagged approximate rather than adjusted: it is the server's count under the query's filter, and guessing would make a stale number into a wrong one.

License

MIT