@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/realtimeSubpath 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 fieldOne 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 manifestgenerated 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 outNext.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
