@fytkit/sdk

中文文档
5.1.2发布于 2026/11/12UNLICENSEDTypeScriptES Module近 30 天下载 2180

Vanilla TypeScript SDK for fytkit's public API

原始使用文档
文档目录14 节
  1. Install
  2. 30-second quickstart
  3. Joining a studio and launching
  4. Trainer preview
  5. Tracking a workout
  6. Two-factor sign-in
  7. 2FA enrollment & account security
  8. Refusals
  9. Live chat
  10. Notification preferences
  11. Push notifications
  12. What's included in this preview
  13. Storage trade-off
  14. Coming next

@fytkit/sdk

Vanilla TypeScript SDK for the fytkit public API. Build a custom trainee-facing app on top of your fytkit programs, workouts, recipes, and Mux-protected video.

v0.1.x is a pre-1.0 release line. The shape of every exported type is documented in etc/sdk.api.md; breaking changes are recorded in CHANGELOG.md.

Install

pnpm add @fytkit/sdk @orpc/client
# or
npm install @fytkit/sdk @orpc/client

Pin to an exact version while the SDK is pre-1.0:

pnpm add @fytkit/[email protected] @orpc/client

Full install + version-pinning guidance: docs/developer/installation.md. Semver policy: docs/developer/concepts/versioning.md.

30-second quickstart

import { createFytkitClient } from "@fytkit/sdk";

const fytkit = createFytkitClient({
  clientId: "fk_app_xxxxxxxxxxxxxxxxx", // from /dashboard/developer
});

// Sign a trainee in (your own auth UI calls this)
const session = await fytkit.auth.signIn({
  email: "[email protected]",
  password: "their-password",
});

// Fetch their workouts
const { items } = await fytkit.workouts.list({ limit: 20 });

// Subscribe to auth state (e.g. to redirect on sign-out)
const off = fytkit.auth.onAuthStateChange((state) => {
  if (state.type === "signed_out") window.location.assign("/sign-in");
});

// Sign out — revokes the server-side session and clears local storage
await fytkit.auth.signOut();
off();

Joining a studio and launching

A Fytkit Coach style app starts from a studio's join code (its QR code or join link) and reads the studio's launch settings once per launch:

import { createFytkitClient, resolveJoinCode, resolvePaymentMode } from "@fytkit/sdk";

// Once, when the member scans or types the code: keep the client id.
const studio = await resolveJoinCode(scannedCode);
const fytkit = createFytkitClient({ clientId: studio.clientId });

// Every launch: revalidate the stored settings with their ETag.
const stored = await loadStoredBootstrap(); // { data, etag } | null, your storage
const result = await fytkit.app.bootstrap({ etag: stored?.etag });
if (result.status === "ok") await saveStoredBootstrap({ data: result.data, etag: result.etag });
const settings = result.status === "ok" ? result.data : stored?.data;
// settings.studio (name, logoUrl), settings.theme (palette, variants),
// settings.featureOverrides, settings.minAppVersion, settings.links,
// settings.realtime, settings.paymentPolicy

// "Subscribe" (a link out to the studio's checkout) only where the store allows it.
const { mode } = resolvePaymentMode(settings.paymentPolicy, {
  platform: "ios",
  storefront: storefrontCountryCode, // StoreKit's Storefront.current?.countryCode
});

app.bootstrap resolves to { status: "unchanged" } (a 304, no body) while the etag you sent is current. It replaced appConfig.get in 4.0.0: app.bootstrap, with the field-by-field migration. resolveJoinCode needs no client. resolvePaymentMode is plan info ("What your plan includes") for every storefront the policy doesn't list: Subscribe or what your plan includes.

Trainer preview

A trainer can't sign in to a member app: staff accounts are refused there (first_party_account). Instead, "Preview in app" on App → Theme in the dashboard shows a QR code for a link such as https://www.trainerlane.com/app-link/preview?client=<clientId>&token=<token>. TrainerLane Coach redeems it for a read-only preview of the studio with no member (unreleased; ships in the next minor):

import { createFytkitClient, createMemoryStorageAdapter, refusalData } from "@fytkit/sdk";

// Start from the link's `client` and `token`. Use a dedicated client: the
// phone may be bound to no studio, or to another one, and a preview never
// shares a client with a signed-in member.
const url = new URL(scannedLink);
const preview = createFytkitClient({
  clientId: url.searchParams.get("client") ?? "",
  storage: createMemoryStorageAdapter(),
});
const session = await preview.auth.startPreview({ token: url.searchParams.get("token") ?? "" });
// session: { kind: "preview", accessToken, expiresAt }: show a Preview banner.

// Studio content works as for a member on any plan: app.bootstrap, programs,
// workouts, exercises, articles, recipes, nutrition, media, plans.
const programs = await preview.programs.list({});

// Anything a member owns (profile, progress, logs, check-ins, habits,
// messages, health) and every write is refused with `preview_mode`:
// show the screen's empty state.
try {
  await preview.progress.getStreak();
} catch (err) {
  if (!refusalData(err, "preview_mode")) throw err;
  showEmptyState();
}

// End it (signOut does the same while previewing).
await preview.auth.endPreview();
  • Once, within 15 minutes. A QR token redeems once, within 15 minutes of being shown. An unknown, expired, used or other studio's token rejects with preview_token_invalid (not_found): ask the trainer for a new code. A studio's own app (not Fytkit Coach) gets preview_unavailable.
  • 60 minutes, memory only. The preview lasts 60 minutes, is never refreshed, and never reaches storage, so a reload ends it. It ends at expiresAt, on endPreview or signOut, on any 401 (the studio turned Coach off, say), and when a member signs in on the same client. Each end but the last emits signed_out; onPreviewChange reports every start and end.
  • Not alongside a member. startPreview rejects locally with member_session_active (conflict) while a member is signed in on that client.

React: usePreviewSession, useStartPreview and useEndPreview in @fytkit/react (unreleased). Every method: auth.startPreview.

Tracking a workout

Log a workout ad-hoc (no program enrollment needed), record sets, complete it, then read back progress:

// Start an ad-hoc log against any workout — or pass { sessionId, enrollmentId }
// instead to log a scheduled program session.
const { data: log } = await fytkit.sessionLogs.create({ workoutId });

await fytkit.sessionLogs.addSet({
  id: log.id,
  exerciseId,
  setNumber: 1,
  reps: 8,
  weightKg: "80.00",
});

await fytkit.sessionLogs.update({ id: log.id, completedAt: new Date().toISOString() });

// Workout-by-workout history for that workout
const { data: history } = await fytkit.sessionLogs.list({ workoutId });

// Stored progress: streak, PR history, aggregate summary
const { data: streak } = await fytkit.progress.getStreak({});
const { data: prs } = await fytkit.progression.listPrHistory({ exerciseId });
const { data: summary } = await fytkit.progress.getSummary({ period: "12w" });

Two-factor sign-in

auth.signIn returns a discriminated result. When the trainee has 2FA enabled, complete the challenge with whichever method they choose:

const result = await fytkit.auth.signIn({ email, password });

if (result.kind === "two_factor_required") {
  const { challenge } = result; // challenge.availableMethods: "totp" | "backup_code" | "email_otp"

  // TOTP from their authenticator app…
  const session = await fytkit.auth.verifyTotp({ challenge, code: "123456" });

  // …or a one-time backup code:
  // await fytkit.auth.verifyBackupCode({ challenge, code });

  // …or an emailed code:
  // await fytkit.auth.requestEmailOtp({ challenge });
  // await fytkit.auth.verifyEmailOtp({ challenge, code });
}

Each verify method caches the issued session exactly like a plain sign-in (signed_in fires on onAuthStateChange).

2FA enrollment & account security

All of these require a signed-in session:

// Enroll: render totpUri as a QR code; show backupCodes exactly once.
const { totpUri, backupCodes } = await fytkit.auth.enableTotp({ password });
await fytkit.auth.verifyTotpSetup({ code: "123456" }); // SDK swaps the rotated token for you

const status = await fytkit.auth.getSecurityStatus(); // { twoFactorEnabled, totpEnabled, hasBackupCodes }
await fytkit.auth.regenerateBackupCodes({ password });
await fytkit.auth.disableTwoFactor({ password });

// Password change (verifies the current password server-side). With
// revokeOtherSessions every other session of the account ends, in any app,
// and the SDK adopts the rotated token transparently.
await fytkit.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });

// Email change — a confirmation link is emailed to the new address;
// nothing changes until the trainee opens it.
await fytkit.auth.changeEmail({ newEmail });

// Resend the sign-up verification email to the trainee's own address (there
// is no email input). Resolves to { status: "sent" } or, when the address is
// already verified and nothing is sent, { status: "already_verified" }.
// Throttled per account (one a minute, five an hour): over the limit it
// rejects with { kind: "rate_limited", retryAfter } (milliseconds).
const { status } = await fytkit.auth.resendVerification();

// Devices signed in through this app (plus the calling one); the trainee's
// sessions elsewhere are never listed. revokeId is an opaque id (never the
// bearer token).
const sessions = await fytkit.auth.listSessions();
await fytkit.auth.revokeSession({ revokeId: sessions.find((s) => !s.isCurrent)!.revokeId });
await fytkit.auth.revokeOtherSessions(); // "sign out of your other devices" on this app

// Permanently close the account (re-verifies email + password). On success the
// SDK clears the session and emits signed_out.
await fytkit.auth.closeAccount({ confirmEmail, password });

A wrong password or one-time code is a { kind: "server", status: 400 } error with code: "invalid_password" / "invalid_code"; a change that needs a fresh sign-in is { kind: "forbidden", code: "reauth_required" }. None of these sign the trainee out — only a 401 on an authenticated call means the session is gone. A wrong password on signIn (for example, signing in again after reauth_required) is a plain auth_required and leaves the current session alone. An account that also runs or works at a trainer on Fytkit can't sign in, sign up or complete two-factor through an app, and an existing app session can't change its password or email, resend its verification email or close it: { kind: "forbidden", code: "first_party_account" }; point it to Fytkit's own sign-in. Enrolling twice in a program is { kind: "conflict" } with the existing enrollmentId.

Refusals

When the server says no for a reason your app can act on (a coaching feature the plan lacks, a check-in already sent, a messaging limit, a studio that is full), the error carries refusal: { reason, data } beside its usual fields. Branch on refusal.reason, never on message text; refusalData reads a known reason's data typed:

import { refusalData } from "@fytkit/sdk";

try {
  await fytkit.messages.send({ body, clientRequestId });
} catch (err) {
  const plan = refusalData(err, "entitlement_required");
  if (plan) return showUpgrade(plan.entitlement);
  const daily = refusalData(err, "daily_limit");
  if (daily) return keepDraftUntil(daily.retryAt);
  throw err;
}

Every reason and its data: docs/developer/reference/errors.md.

Live chat

subscribeToMessages keeps a conversation screen current over Supabase Realtime: it fetches a token (messages.realtimeToken) and fytkit's public connection settings (app.bootstrap), joins the trainee's own private channel (and joins it afresh after Realtime closes it), renews the token before it expires, and reports when to poll instead. Events carry no message content: onChange means "read the conversation and the unread count again".

The SDK has no dependency on supabase-js. Add @supabase/supabase-js to your app (npm install @supabase/supabase-js@^2.97.0) and pass a factory that creates a client for the subscription alone. It must be 2.87.1 or later: the helper sets the trainee's token on the socket by hand, and earlier 2.x clients swap it for the anon key on the next heartbeat, which leaves the subscription in fallback. Keep the auth options below: the client carries the trainee's token only, and must not keep or refresh a session, or read a sign-in from your page's URL that would replace it.

import { createClient } from "@supabase/supabase-js";
import { MESSAGES_FALLBACK_POLL_MS, subscribeToMessages } from "@fytkit/sdk";

const unsubscribe = subscribeToMessages(fytkit, {
  realtime: ({ url, anonKey }) =>
    createClient(url, anonKey, {
      auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
    }),
  onChange: () => refreshConversation(), // messages.list + messages.unreadCount
  onStatus: (status) => {
    // "live": stop polling. "connecting" / "fallback": poll the conversation
    // every MESSAGES_FALLBACK_POLL_MS.conversation (10 s) and the count every
    // MESSAGES_FALLBACK_POLL_MS.unreadCount (60 s). "refused": no trainee to
    // listen for (signed out); the subscription has stopped.
    setPolling(status === "connecting" || status === "fallback");
  },
});

// On leaving the screen or signing out:
unsubscribe();

To show "Sam is writing…" while someone on the trainee's coaching team writes a reply, pass onCoachTyping. It reports { kind: "typing", names } (display names, as the conversation shows them on their messages) and { kind: "idle" }. It is live only: a name drops out by itself 8 seconds after its last sign (COACH_TYPING_EXPIRY_MS), so a lost "stopped" never leaves it showing, it goes idle when the channel is lost, and while you poll it never shows.

subscribeToMessages(fytkit, {
  realtime,
  onChange: () => refreshConversation(),
  onCoachTyping: (typing) =>
    setWritingLine(typing.kind === "typing" ? `${firstName(typing.names[0])} is writing…` : null),
});

Polling spends your app's per-minute request limit, which all its users share, so stop as soon as the status is live. The full guide: messages.realtimeToken and the recipe Chat screen with live updates.

Notification preferences

Per-member toggles stored server-side (defaults apply until first write):

const { data: prefs } = await fytkit.preferences.getNotifications();
await fytkit.preferences.updateNotifications({ streakAlerts: false }); // partial patch

coachMessages, checkInReminders and coachFeedback are the push toggles (on until the member turns them off); "Program assigned" pushes under newPrograms.

Push notifications

Fytkit Coach registers the device's Expo push token for the signed-in member. The token is bound to the session it was registered on, so signing out, revoking that session or closing the account removes it on the server; call unregister first anyway, so the phone stops getting pushes at once.

await fytkit.push.register({ token: expoPushToken, platform: "ios" }); // or "android"
await fytkit.push.unregister({ token: expoPushToken }); // idempotent

A studio's own developer app is refused with { kind: "forbidden" }: only Fytkit Coach receives pushes. Every push's data is a PushNotificationData ({ v: 1, clientId, kind, href }, no message text); ignore one whose clientId isn't the bound studio. The contract: docs/developer/guides/push-notifications.md.

What's included in this preview

  • resolveJoinCode (a studio's join code to its client id, name, logo and app theme, with no client) and app.bootstrap (the launch read, revalidated with its ETag), with resolvePaymentMode ("Subscribe" link-out or "What your plan includes", per storefront)
  • auth.signUp / signIn / signOut / getSession / onAuthStateChange
  • auth.signInWithIdToken (Sign in with Apple or Google, Fytkit Coach only: pass the raw nonce; Apple gets its SHA-256 hex, Google the raw nonce)
  • Trainer preview: auth.startPreview / getPreview / onPreviewChange / endPreview (a read-only, member-less preview of the studio from a "Preview in app" QR code; see Trainer preview)
  • auth.requestPasswordReset / confirmPasswordReset / confirmEmail / resendVerification
  • Two-factor: auth.verifyTotp / verifyBackupCode / requestEmailOtp / verifyEmailOtp (sign-in), enableTotp / verifyTotpSetup / disableTwoFactor / regenerateBackupCodes / getSecurityStatus (management)
  • Account security: auth.changePassword / changeEmail / confirmEmailChange / listSessions / revokeSession / revokeOtherSessions / closeAccount
  • Data namespaces: workouts, programs, recipes, nutrition, nutritionLog, profile, preferences, push, articles, enrollments, sessionLogs, progression, progress, bodyMetrics, bodyMeasurements, progressPhotos, checkIns, messages, timeline, health, habits, media, subscription
  • Workout tracking: program session logs and ad-hoc workout logs (sessionLogs.create with workoutId), set logging, per-workout history (sessionLogs.list({ workoutId }))
  • Progress: progress.getSummary / getAdherence / getStreak (stored streaks) / getVolume (last week's hard sets per muscle group), progression.getPrSummary / listPrHistory (stored PR history) / getE1rmTrend (estimated 1-rep max per session, per lift) / getHistory, bodyMetrics.list / record / update / delete
  • Coach feedback on logged sessions: sessionLogs.getFeedback / markFeedbackSeen / unseenFeedbackCount
  • Check-ins: checkIns.current / submit / update / list / get / markReplySeen / requestPhotoUpload (weekly or fortnightly check-ins a coach reviews; needs a plan with coaching_checkins)
  • Messages: messages.list / send / requestAttachmentUpload / markRead / delete / unreadCount / realtimeToken (the trainee's conversation with their coaches; starting one needs a plan with coaching_messages, answering a coach does not; requestAttachmentUpload reserves a photo to send with a message)
  • Live chat: subscribeToMessages (joins the trainee's private Realtime channel with messages.realtimeToken, renews the token, and says when to poll instead; see Live chat)
  • Automatic header injection (X-TrainerLane-Client-Id, Authorization: Bearer …)
  • Proactive refresh ~60 s before token expiry (retried with backoff when offline) + reactive 401 retry; only a server-rejected refresh signs out
  • Pluggable token storage (localStorage default, in-memory + cookie for SSR)

Storage trade-off

Access tokens land in localStorage by default, namespaced by clientId. Any script running on your origin can read them — ship a strict CSP and treat the SDK like any other auth-bearing dependency. A token is a server session that lasts up to 7 days; signing out, closing the account or revoking the developer app ends it server-side.

Use createMemoryStorageAdapter() for SSR + tests.

Coming next

  • @fytkit/react — hooks + <FytkitVideo>
  • examples/sdk-react — runnable sample app
  • docs/sdk/quickstart.md — full guide

版本与发布记录

@fytkit/sdk 共发布 27 个版本,首次发布于 2026/05/19;过去 12 个月发布了 27 个版本。

版本发布日期距今
5.1.2latest2026/11/123 天前
5.1.12026/11/114 天前
5.1.02026/11/114 天前
5.0.02026/11/079 天前
4.5.02026/11/0114 天前
4.4.02026/10/3115 天前

关于 @fytkit/sdk 的常见问题

@fytkit/sdk 是什么?

Vanilla TypeScript SDK for fytkit's public API

如何安装 @fytkit/sdk?

在项目目录运行 npm install @fytkit/sdk,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@orpc/client(^1.13.5)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@fytkit/sdk 支持 TypeScript 吗?

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

@fytkit/sdk 支持 ESM 还是 CommonJS?

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

@fytkit/sdk 可以商用吗?

@fytkit/sdk 使用 UNLICENSED 许可。作者声明为 UNLICENSED,即不授予公开使用许可(与公共领域的 Unlicense 不同)。使用前需获得作者授权。以上为概括,不构成法律意见,请以许可原文为准。

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

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

@fytkit/sdk 还在维护吗?

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