@classic-homes/auth

中文文档
1.47.0发布于 2026/11/10MITTypeScriptES Module零依赖近 30 天下载 1736

Authentication services and Svelte bindings for Classic Theme apps

原始使用文档
文档目录34 节
  1. Features
  2. Installation
  3. Quick Start
  4. 1. Initialize Authentication
  5. 2. Login with Auto-Set Auth
  6. 3. Use the Auth Store (Svelte)
  7. 4. SSO Login with Redirect URLs
  8. 5. MFA Challenge Verification
  9. 6. Protect Routes
  10. Roles
  11. Displaying a role
  12. Managing another user's roles
  13. Token refresh
  14. API errors and access debugging
  15. Access probe
  16. API Reference
  17. Core Exports
  18. Svelte Exports
  19. Configuration Options
  20. Auth Actions
  21. Auth Store State
  22. Using with @classic-homes/theme-svelte
  23. Automatic Token Refresh
  24. Testing Utilities
  25. Installation
  26. Quick Start
  27. Testing Exports
  28. Mock Fetch
  29. Mock Auth Store
  30. Pre-defined Scenarios
  31. Custom Assertions
  32. Isolated Test Context
  33. User Fixtures
  34. License

@classic-homes/auth

Framework-agnostic authentication core with Svelte bindings for the Classic Theme design system.

Features

  • JWT-based authentication with automatic token refresh
  • SSO (Single Sign-On) support with configurable providers and logout
  • Multi-factor authentication (MFA/TOTP) support with type guards
  • Auto-set auth state on successful login
  • Pluggable storage adapter (localStorage, sessionStorage, or custom)
  • Svelte reactive stores for authentication state
  • Route guards for protected pages
  • TypeScript-first with full type safety

Installation

npm install @classic-homes/auth

Quick Start

1. Initialize Authentication

In your app's entry point (e.g., hooks.client.ts for SvelteKit):

import { initAuth } from '@classic-homes/auth';
import { goto } from '$app/navigation';
import { base } from '$app/paths';

initAuth({
  baseUrl: 'https://api.example.com',
  storage: {
    getItem: (key) => localStorage.getItem(key),
    setItem: (key, value) => localStorage.setItem(key, value),
    removeItem: (key) => localStorage.removeItem(key),
  },
  // ⚠️ SECURITY: localStorage (also the default when `storage` is omitted)
  // persists the long-lived refresh token where any XSS can read it.
  // Safer options exported from '@classic-homes/auth/core':
  //   storage: createSessionStorage()  // per-tab, cleared on close
  //   storage: createMemoryStorage()   // never persisted, lost on reload
  // or keep tokens out of JS entirely with an httpOnly-cookie flow.
  // SSO configuration (optional)
  sso: {
    enabled: true,
    provider: 'authentik',
  },
  // Callback when auth errors occur
  onAuthError: (error) => {
    console.error('Auth error:', error);
  },
  // Callback when tokens are refreshed
  onTokenRefresh: (tokens) => {
    console.log('Tokens refreshed');
  },
  // Callback when user is logged out
  onLogout: () => {
    goto(`${base}/auth/login`);
  },
});

2. Login with Auto-Set Auth

The authService.login() method automatically sets the auth state on successful login:

import {
  authService,
  isMfaChallengeResponse,
  getMfaToken,
  getAvailableMethods,
} from '@classic-homes/auth/core';
import { goto } from '$app/navigation';

async function handleLogin(email: string, password: string) {
  const response = await authService.login({
    username: email,
    password: password,
  });

  // Check if MFA is required
  if (isMfaChallengeResponse(response)) {
    const mfaToken = getMfaToken(response);
    const methods = getAvailableMethods(response);
    // Redirect to MFA challenge page
    goto(`/auth/mfa-challenge?token=${mfaToken}&methods=${methods.join(',')}`);
    return;
  }

  // Auth state is automatically set - redirect to dashboard
  goto('/dashboard');
}

Email-MFA accounts get their code by email: send it with the challenge token, then verify with method: 'email'. A cooldown (one code a minute, five an hour) throws MFAChallengeEmailError with retryAfterSeconds.

import { authService, MFAChallengeEmailError } from '@classic-homes/auth';

try {
  const { email } = await authService.sendMFAChallengeEmail(mfaToken); // "jo***@example.com"
} catch (error) {
  if (error instanceof MFAChallengeEmailError && error.retryAfterSeconds) {
    // Show "Resend in Ns"
  }
}

await authService.verifyMFAChallenge({ mfaToken, code, method: 'email' });

To disable auto-set auth (for manual control):

const response = await authService.login(credentials, { autoSetAuth: false });
// Manually set auth state
authActions.setAuth(response.accessToken, response.refreshToken, response.user);

3. Use the Auth Store (Svelte)

<script lang="ts">
  import { authStore, authActions, isAuthenticated, currentUser } from '@classic-homes/auth/svelte';

  // Using derived stores
  // $isAuthenticated - boolean
  // $currentUser - User | null

  async function handleLogout() {
    // SSO-aware logout
    const result = await authActions.logoutWithSSO();
    if (result.ssoLogoutUrl) {
      // Redirect to SSO provider logout
      window.location.href = result.ssoLogoutUrl;
    } else {
      goto('/auth/login');
    }
  }
</script>

{#if $isAuthenticated}
  <p>Welcome, {$currentUser?.firstName}</p>
  <button onclick={handleLogout}>Logout</button>
{:else}
  <a href="/auth/login">Login</a>
{/if}

4. SSO Login with Redirect URLs

import { authService } from '@classic-homes/auth/core';

function handleSSOLogin(redirectUrl: string) {
  // Specify where to redirect after SSO callback
  authService.initiateSSOLogin({
    callbackUrl: `${window.location.origin}/auth/sso-callback`,
    redirectUrl: redirectUrl, // Final destination after auth
  });
}

initiateSSOLogin also sends a random client_state, kept in this tab's sessionStorage (single-use, 10-minute TTL). The API echoes it in the callback hash (client_state, in both the token and the MFA-challenge variants); check it before storing any tokens from the hash, so a crafted callback URL can't sign the user into someone else's account (login CSRF):

import { verifySsoClientState } from '@classic-homes/auth/core';

const params = new URLSearchParams(window.location.hash.slice(1));
const { accepted, result } = verifySsoClientState(params); // 'ok' | 'mismatch' | 'missing'
if (!accepted) {
  window.history.replaceState({}, '', window.location.pathname);
  throw new Error('This sign-in link was not started from this browser tab. Please sign in again.');
}

Set sso.requireClientState: true once the API echoing client_state is deployed to that environment. Until then (the default) every result is accepted, so log result to watch the rollout.

5. MFA Challenge Verification

import { authService } from '@classic-homes/auth/core';

async function handleMFAVerify(mfaToken: string, code: string, trustDevice: boolean) {
  // Auto-sets auth state on success
  const response = await authService.verifyMFAChallenge({
    mfaToken,
    code,
    method: 'totp',
    trustDevice,
  });

  // Auth state is automatically set - redirect to dashboard
  goto('/dashboard');
}

6. Protect Routes

// src/routes/dashboard/+page.ts
import { checkAuth, requireRole } from '@classic-homes/auth/svelte';
import { redirect } from '@sveltejs/kit';
import { browser } from '$app/environment';

export function load({ url }) {
  if (browser) {
    const result = checkAuth();
    if (!result.allowed) {
      throw redirect(302, `/auth/login?redirect=${encodeURIComponent(url.pathname)}`);
    }
  }
  return {};
}

// For role-based access:
export function load({ url }) {
  if (browser) {
    const result = checkAuth({ roles: ['admin', 'manager'] });
    if (!result.allowed) {
      if (result.reason === 'not_authenticated') {
        throw redirect(302, `/auth/login?redirect=${encodeURIComponent(url.pathname)}`);
      }
      if (result.reason === 'missing_role') {
        throw redirect(302, '/unauthorized');
      }
    }
  }
  return {};
}

Roles

A user holds a set of roles (user.roles). Their permissions are the union across all of them, so a user's access is broader than any single role.

import { authStore } from '@classic-homes/auth/svelte';

// A manager who is also an agent
authStore.user?.roles; // ['manager', 'agent']

authStore.hasRole('manager'); // true
authStore.hasRole('agent'); // true — secondary roles count
authStore.hasAnyRole(['admin', 'agent']); // true
authStore.hasAllRoles(['manager', 'agent']); // true

Role checks are exact membership. Holding admin does not imply agent:

authStore.user?.roles; // ['admin']
authStore.hasRole('agent'); // false

This mirrors the API, so the UI can't offer something the server will reject. To let admins through as well, list the roles you accept: checkAuth({ roles: ['admin', 'agent'] }).

Displaying a role

computePrimaryRole resolves the highest-tier role for display. It's a label, not a capability — never gate on it:

import { computePrimaryRole, getEffectiveLevel } from '@classic-homes/auth/core';

computePrimaryRole(['user', 'admin']); // 'admin'
computePrimaryRole([]); // 'guest'
getEffectiveLevel(['user', 'manager']); // 3

// Or format the whole set
import { formatUserRoles } from '@classic-homes/auth/core';
formatUserRoles(user); // 'Manager, Agent'
formatUserRoles(user, { max: 1 }); // 'Manager +1 more'

Managing another user's roles

Requires users:read to list and users:update to change:

import { authApi } from '@classic-homes/auth/core';

const { roles, primaryRole } = await authApi.getUserRoles(userId);

await authApi.grantUserRole(userId, 'agent'); // idempotent
await authApi.revokeUserRole(userId, 'agent');
await authApi.setUserRoles(userId, ['manager', 'agent']); // replaces the set

Two server rules to design around:

  • A role change doesn't affect that user's live session. Permissions are computed when a token is minted, so a grant takes effect on their next token refresh — not their next request.
  • The server refuses to remove a user's last role, or to grant a role at or above the acting user's own level. Both come back as errors.

Token refresh

Access tokens are short-lived and refresh tokens rotate (single-use), so a refresh must never run twice for the same token. refreshSession() is the one refresh path — the client's own 401 retry uses it, and so should any code that refreshes proactively:

import { refreshSession } from '@classic-homes/auth/core';

// Before a route guard reads claims (roles/permissions live in the JWT):
const outcome = await refreshSession({ onlyIfExpired: true });
// 'fresh'       — token still valid, nothing sent
// 'refreshed'   — tokens stored and the Svelte store already updated
// 'rejected'    — session cleared (handleSessionExpired/onSessionExpired/onLogout ran)
// 'unavailable' — server unreachable/5xx even after retries; session kept, try later

Guarantees: single-flight per tab (concurrent callers share one call), serialized across tabs with a Web Lock and reconciled if another tab rotated first, and transient failures retried with refreshRetryDelays backoff (default [1000, 3000]). Do not call authApi.refreshToken + updateStoredTokens yourself for this — that bypasses all of the above and can orphan a rotated token.

Call initCrossTabSync() once at app start so a refresh or sign-out in another tab rehydrates this tab's Svelte store.

API errors and access debugging

A non-2xx response from apiRequest / api.* throws an ApiError. Its message is the server's message (as before), plus status, the CHAPI code, details, method and endpoint:

import { isApiError } from '@classic-homes/auth/core';

try {
  await api.get('/homes', true);
} catch (e) {
  if (isApiError(e) && e.status === 403) {
    console.log(e.details?.required); // e.g. ['homes:read']
  }
}

Access probe

Hooks for troubleshooting tools such as the Access Inspector. They are inert until installed and do nothing on the server.

  • setAccessProbe(fn) receives every access decision: core hasRole*, store has*, checkAuth (source: 'route'), filterByAccess/canAccess ('nav') and API 403s ('http', path only, never the query string). Each event carries the requirement, allowed and what was missing.
  • While a probe is installed, requests to the API origin (never other hosts) send X-Access-Debug: 1. CHAPI answers admin sessions with X-Access-Redacted: taxId=vendors:read:sensitive, notes=*, reported as an informational source: 'redacted' event (allowed: true, path only) with fields: { name, permission }[]; * means no permission is configured (default-deny). It reflects the real token, never "view as". No header, no event. The custom header makes cross-origin API requests preflighted.
  • setAccessSubject(userId, { roles, permissions }) previews the UI as another role set ("view as") for that user only. Checks for other users are unaffected, authStore.user is never changed, and the preview ends on any login, logout or session expiry. Sign-in role gating (allowedRoles) always uses the real roles.
  • evaluateAccess(subject, requirement) is the shared any/all decision checkAuth uses; checkAuth results now include missing on denial.

These change UI decisions only. The API authorizes every request with the real token, so a preview can never grant access.

API Reference

Core Exports

import {
  // Initialization
  initAuth,
  getConfig,
  isInitialized,

  // Service
  authService,
  AuthService,

  // API
  authApi,

  // MFA Guards
  isMfaChallengeResponse,
  isLoginSuccessResponse,
  getMfaToken,
  getAvailableMethods,

  // JWT Utilities
  decodeJWT,
  isTokenExpired,
  getTokenRemainingTime,

  // Roles
  ROLE_NAMES,
  ROLE_HIERARCHY,
  getUserRoles,
  computePrimaryRole,
  getEffectiveLevel,
  hasRole,
  hasAnyRole,
  hasAllRoles,

  // Errors
  ApiError,
  isApiError,

  // Access probe (see "API errors and access debugging")
  setAccessProbe,
  setAccessSubject,
  clearAccessSubject,
  evaluateAccess,

  // Types
  type User,
  type UserRolesResponse,
  type RoleName,
  type AuthState,
  type LoginCredentials,
  type LoginResponse,
  type LogoutResponse,
  type RegisterData,
  type AuthConfig,
  type LoginOptions,
  type MFAVerifyOptions,
} from '@classic-homes/auth/core';

Svelte Exports

import {
  // Store
  authStore,
  isAuthenticated,
  currentUser,

  // Actions
  authActions,

  // Guards
  checkAuth,
  createAuthGuard,
  requireAuth,
  requireRole,
  requirePermission,
  protectedLoad,
} from '@classic-homes/auth/svelte';

Configuration Options

interface AuthConfig {
  /** Base URL for the auth API */
  baseUrl: string;

  /** Custom fetch implementation (useful for SSR or testing) */
  fetch?: typeof fetch;

  /** Storage adapter for token persistence */
  storage?: StorageAdapter;

  /** Storage key prefix for auth data */
  storageKey?: string;

  /** SSO configuration */
  sso?: {
    enabled: boolean;
    provider: string;
    authorizeUrl?: string;
    /** Reject SSO callbacks whose client_state is missing or wrong (default false) */
    requireClientState?: boolean;
  };

  /** Callback when auth errors occur */
  onAuthError?: (error: Error) => void;

  /** Callback when tokens are refreshed */
  onTokenRefresh?: (tokens: { accessToken: string; refreshToken: string }) => void;

  /** Callback when user is logged out */
  onLogout?: () => void;
}

Auth Actions

The authActions object provides methods for authentication operations:

// Set auth data after login
authActions.setAuth(accessToken, refreshToken, user, sessionToken);

// Update tokens after refresh
authActions.updateTokens(accessToken, refreshToken);

// Update user profile
authActions.updateUser(user);

// Clear auth state (local logout)
authActions.logout();

// SSO-aware logout (calls API, returns SSO logout URL if applicable)
const result = await authActions.logoutWithSSO();
if (result.ssoLogoutUrl) {
  window.location.href = result.ssoLogoutUrl;
}

// Permission and role checks
authActions.hasPermission('users:read');
authActions.hasRole('admin');
authActions.hasAnyRole(['admin', 'manager']);
authActions.hasAllRoles(['admin', 'manager']);
authActions.hasAnyPermission(['users:read', 'users:write']);
authActions.hasAllPermissions(['users:read', 'users:write']);

// Reload auth from storage
authActions.rehydrate();

Auth Store State

interface AuthState {
  accessToken: string | null;
  refreshToken: string | null;
  user: User | null;
  isAuthenticated: boolean;
}

Using with @classic-homes/theme-svelte

The auth package integrates with the form validation from @classic-homes/theme-svelte:

<script lang="ts">
  import { useForm, loginSchema } from '@classic-homes/theme-svelte';
  import { authService, isMfaChallengeResponse, getMfaToken } from '@classic-homes/auth/core';
  import { goto } from '$app/navigation';

  const form = useForm({
    schema: loginSchema,
    initialValues: {
      email: '',
      password: '',
      rememberMe: false,
    },
    onSubmit: async (data) => {
      const response = await authService.login({
        username: data.email,
        password: data.password,
      });

      if (isMfaChallengeResponse(response)) {
        const mfaToken = getMfaToken(response);
        goto(`/auth/mfa-challenge?token=${mfaToken}`);
        return;
      }

      // Auth state automatically set
      goto('/dashboard');
    },
  });
</script>

<form onsubmit={form.handleSubmit}>
  <input bind:value={form.data.email} />
  {#if form.errors.email}
    <span class="error">{form.errors.email}</span>
  {/if}
  <!-- ... -->
</form>

Automatic Token Refresh

Token refresh happens automatically when:

  • An API request returns 401 Unauthorized
  • The refresh token is valid

The Svelte store is automatically updated when tokens are refreshed, so your UI stays in sync.

Testing Utilities

The auth package includes comprehensive testing utilities for unit and integration tests.

Installation

# The testing utilities are included in the main package
npm install @classic-homes/auth

Quick Start

import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import {
  setupTestAuth,
  mockUser,
  configureMFAFlow,
  assertAuthenticated,
} from '@classic-homes/auth/testing';
import { authService, isMfaChallengeResponse } from '@classic-homes/auth/core';

describe('Login Flow', () => {
  let cleanup: () => void;
  let mockFetch;

  beforeEach(() => {
    const ctx = setupTestAuth();
    cleanup = ctx.cleanup;
    mockFetch = ctx.mockFetch;
  });

  afterEach(() => cleanup());

  it('handles successful login', async () => {
    const response = await authService.login({
      username: '[email protected]',
      password: 'password',
    });

    expect(response.user).toMatchObject(mockUser);
    mockFetch.assertCalled('/auth/login');
  });

  it('handles MFA flow', async () => {
    configureMFAFlow(mockFetch);

    const response = await authService.login({
      username: '[email protected]',
      password: 'password',
    });

    expect(isMfaChallengeResponse(response)).toBe(true);
  });
});

Testing Exports

import {
  // Fixtures - Pre-defined test data
  mockUser,
  mockAdminUser,
  mockSSOUser,
  mockMFAUser,
  mockAccessToken,
  mockRefreshToken,
  mockLoginSuccess,
  mockMFARequired,
  createMockUser,
  createMockTokenPair,
  createMockLoginSuccess,

  // Mocks - Test doubles for dependencies
  MockStorageAdapter,
  MockFetchInstance,
  MockAuthStore,
  createMockStorage,
  createMockFetch,
  createMockAuthStore,

  // Setup Helpers
  setupTestAuth,
  createTestAuthHelpers,
  quickSetupAuth,
  withTestAuth,

  // State Simulation
  authScenarios,
  applyScenario,
  configureMFAFlow,
  configureTokenRefresh,
  configureSSOLogout,
  simulateLogin,
  simulateLogout,

  // Assertions
  assertAuthenticated,
  assertUnauthenticated,
  assertHasPermissions,
  assertHasRoles,
  assertTokenValid,
  assertApiCalled,
  assertStoreMethodCalled,
  assertRequiresMFA,
} from '@classic-homes/auth/testing';

Mock Fetch

The MockFetchInstance provides a configurable mock fetch with pre-defined auth routes:

const ctx = setupTestAuth();
const { mockFetch } = ctx;

// Default routes are pre-configured for all auth endpoints

// Customize responses
mockFetch.requireMFA(); // Login requires MFA
mockFetch.failLogin('Invalid credentials'); // Login fails
mockFetch.enableSSOLogout(); // Logout returns SSO URL

// Add custom routes
mockFetch.addRoute({
  method: 'GET',
  path: '/custom/endpoint',
  response: { data: 'custom response' },
});

// Fail specific endpoints
mockFetch.failEndpoint('GET', '/auth/profile', 403, 'Forbidden');

// Check call history
expect(mockFetch.wasCalled('/auth/login')).toBe(true);
mockFetch.assertCalled('/auth/profile');
mockFetch.assertNotCalled('/auth/logout');

Mock Auth Store

The MockAuthStore mimics the Svelte auth store:

const store = createMockAuthStore();

// Simulate states
store.simulateAuthenticated(mockAdminUser);
store.simulateUnauthenticated();

// Direct state manipulation
store.setState({ isAuthenticated: true, user: mockUser });

// Check method calls
store.assertMethodCalled('setAuth');
store.assertMethodNotCalled('logout');

// Get call history
const calls = store.getCallsFor('setAuth');

Pre-defined Scenarios

Apply common auth scenarios for testing:

import { authScenarios, applyScenario } from '@classic-homes/auth/testing';

// Available scenarios:
// - 'unauthenticated'
// - 'authenticated'
// - 'admin'
// - 'ssoUser'
// - 'mfaEnabled'
// - 'unverifiedEmail'
// - 'inactive'
// - 'expiredToken'

const store = createMockAuthStore();
applyScenario(store, 'admin');
expect(store.user?.roles).toContain('admin');

Custom Assertions

Use built-in assertions for common checks:

import {
  assertAuthenticated,
  assertHasPermissions,
  assertTokenValid,
  assertApiCalled,
  assertRequiresMFA,
} from '@classic-homes/auth/testing';

// Auth state assertions
assertAuthenticated(store.getState());
assertUnauthenticated(store.getState());

// Permission assertions
assertHasPermissions(user, ['read:profile', 'write:profile']);
assertHasRoles(user, ['admin']);

// Token assertions
assertTokenValid(accessToken);
assertTokenExpired(oldToken);

// API call assertions
assertApiCalled(mockFetch, 'POST', '/auth/login', {
  times: 1,
  body: { username: 'test', password: 'pass' },
});

// MFA assertions
assertRequiresMFA(loginResponse);
assertNoMFARequired(loginResponse);

Isolated Test Context

Run tests in isolated auth contexts:

import { withTestAuth } from '@classic-homes/auth/testing';

// Automatic setup and cleanup
await withTestAuth(async ({ mockFetch, mockStore }) => {
  mockFetch.requireMFA();
  const response = await authService.login({ username: 'test', password: 'pass' });
  expect(response.requiresMFA).toBe(true);
});

User Fixtures

Create custom test users:

import {
  mockUser,
  mockAdminUser,
  createMockUser,
  createMockUserWithRoles,
} from '@classic-homes/auth/testing';

// Use pre-defined users
expect(mockUser.roles).toEqual(['user']);
expect(mockAdminUser.permissions).toContain('manage:system');

// Create custom users
const customUser = createMockUser({
  email: '[email protected]',
  firstName: 'Custom',
});

// Create users with specific RBAC
const managerUser = createMockUserWithRoles(['manager', 'user'], ['read:reports', 'write:reports']);

License

MIT

版本与发布记录

@classic-homes/auth 共发布 76 个版本,首次发布于 2026/01/09;过去 12 个月发布了 76 个版本。

版本发布日期距今
1.47.0latest2026/11/10今天
1.46.02026/11/073 天前
1.45.02026/11/073 天前
1.42.12026/11/055 天前
1.42.02026/11/055 天前
1.41.02026/11/055 天前

关于 @classic-homes/auth 的常见问题

@classic-homes/auth 是什么?

Authentication services and Svelte bindings for Classic Theme apps

如何安装 @classic-homes/auth?

在项目目录运行 npm install @classic-homes/auth,或使用 pnpm add / yarn add / bun add。

@classic-homes/auth 支持 TypeScript 吗?

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

@classic-homes/auth 支持 ESM 还是 CommonJS?

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

@classic-homes/auth 可以商用吗?

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

@classic-homes/auth 的下载量有多少?

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

@classic-homes/auth 还在维护吗?

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