@foir/sdk
v0.9.0
Published
Official Foir SDK — editor iframe client + server-side platform helpers
Readme
@foir/sdk
Official Foir SDK — client-agnostic typed GraphQL transport, customer auth, 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 customer auth — 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/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 four fields it replaces (schemaVersion, selections,
namespaceSelections, selectionUnions) still work and still win where both
are given, so an existing client keeps compiling and behaving exactly as it did.
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 pk_ key has no create to
call:
foir.post.create({ data }); // ✗ compile error under a pk_ 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 pk_ key, with no
customer 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, customers, 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 sk_ key and the draft schema channel.
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;
}Customer auth
import {
loginWithPassword,
authedRequest,
type SessionAdapter,
} from '@foir/sdk/auth';
import { MyProfileDocument } from './generated/graphql';
// SessionAdapter = { get, set, delete } over your session store (cookie, KV…).
const customer = await loginWithPassword(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 pk_ 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: 'pk_…' }}
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 customer = await getFoirSession(await cookies());
return <p>Signed in as {customer?.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
// ...
}License
MIT
