@foir/sdk

中文文档
0.34.0发布于 2026/11/09MITTypeScriptES Module近 30 天下载 2477

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

原始使用文档
文档目录13 节
  1. Install
  2. Subpath exports
  3. Usage
  4. Typed data client (no GraphQL, no codegen step)
  5. Typed GraphQL from a server loader
  6. Signing your users in
  7. React
  8. Backend-less SPA (Foir-hosted sessions)
  9. Next.js (App Router, BFF)
  10. Codegen
  11. Editor extensions
  12. Live reads
  13. License

@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

ImportWhat it isPeers
@foir/sdkEditor iframe client — EditorProvider, useEditor, useAutoResize, createPlatformClient, host↔editor message protocolreact
@foir/sdk/clientClient-agnostic GraphQL transport for SSR/edge loaders — foirRequest, isFoirPreview, absolutiseFoirMediaUrlgraphql
@foir/sdk/dataTyped data client over /graphql — createFoirClient, per-key-shaped ops, include/omit, fetchFoirManifest; prints the document for you, so still no graphqlnone
@foir/sdk/authFramework-agnostic sign-in for your app's users: login/register/OTP/provider flows, token lifecycle (SessionAdapter), createFoirSessionClient (Foir-hosted cookie sessions), authedRequest, guardsgraphql
@foir/sdk/reactDrop-in React auth — <FoirProvider> (token or hostedSession mode), useFoirSession/useFoirUser/useFoirAuth, <SignIn>, <OAuthButtons>, <UserButton>react, graphql
@foir/sdk/nextNext.js App Router BFF auth: createFoirCookieAdapter, getFoirSession, foirSignIn*, startFoirProviderSignIn, finishFoirProviderSignIn, createFoirMiddlewaregraphql
@foir/sdk/serverServer-side helpers for extension/config API backends — createServerClient, verifyWebhookSignature, verifyScopedToken, callback client—
@foir/sdk/liveLive reads — the client half of Foir's invalidation graph: tag index, raw-payload store, seq ordering, record-change → tag mappingnone
@foir/sdk/live/react<FoirLive> — one socket, refcounted per model, wired to a query client you already havereact
@foir/sdk/mediaProgressive media — ProgressiveMedia, BlurhashCanvas, useProgressiveLoad, variant-tier adaptersreact (hls.js, blurhash optional)
@foir/sdk/contentContent-rendering primitives — createContentRenderer block dispatch + Lexical RichTextRendererreact
@foir/sdk/realtimeThin collaboration helper over y-websocket — canonical room addressing, presence, token refreshyjs, y-websocket
@foir/sdk/codegenBuild-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 and immediately closes the current client, even offline. After the next hosted sign-in redirects back, create a fresh client (or remount the provider). A closed or destroyed client never resumes exchanges. 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({
  // ...
  checkSchemaDrift: true,
  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

版本与发布记录

@foir/sdk 共发布 35 个版本,首次发布于 2026/06/24;过去 12 个月发布了 35 个版本。

版本发布日期距今
0.34.0latest2026/11/096 天前
0.33.02026/11/0213 天前
0.32.0next2026/10/2918 天前
0.31.02026/10/2521 天前
0.30.12026/10/2323 天前
0.30.02026/10/2323 天前

关于 @foir/sdk 的常见问题

@foir/sdk 是什么?

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

如何安装 @foir/sdk?

在项目目录运行 npm install @foir/sdk,或使用 pnpm add / yarn add / bun add。

@foir/sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@foir/sdk 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@foir/sdk 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@foir/sdk 可以商用吗?

@foir/sdk 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@foir/sdk 的下载量有多少?

根据 npm 官方下载统计,@foir/sdk 在 2026/10/15 – 2026/11/13 这 30 天内被下载 2,477 次,日均约 83 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@foir/sdk 还在维护吗?

最新版本 0.34.0 发布于 2026/11/09(6 天前);至今共发布 35 个版本,其中过去 12 个月发布了 35 个;首次发布于 2026/06/24。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。