@fluid-app/sites

中文文档
0.1.167发布于 2026/11/15UNLICENSEDTypeScriptES Module零依赖近 30 天下载 2.9万

The member-sites browser SDK: fluid.auth, fluid.api, fluid.db, fluid.fn and fluid.cart on the site's own origin

原始使用文档
文档目录15 节
  1. Install
  2. No build step: the script tag
  3. The SDK
  4. fluid.auth : who is here
  5. fluid.api : the Fluid API as the visitor
  6. fluid.db : the site's own database
  7. fluid.fn : the site's server functions
  8. fluid.cart : the storefront's cart
  9. fluid.history : Back and Forward for a single-page app
  10. fluid.storefront : links that keep the sharer's credit
  11. Local development: fluid sites dev
  12. SEO: @fluid-app/sites/vite
  13. Options
  14. Media in the Fluid DAM
  15. Errors

@fluid-app/sites

The browser SDK for sites hosted on Fluid. It gives a page fluid.auth, fluid.api, fluid.db, fluid.fn, fluid.cart, fluid.history and fluid.storefront. Every call goes to the site's own origin, under /_fluid/, so the page needs no API key and no CORS setup: Fluid knows who the visitor is and which site they are on.

It also ships fluidSite() (@fluid-app/sites/vite), a Vite plugin that makes a site findable by search engines, link previews and agents.

Full guides live at docs.fluid.app:

Install

npm install @fluid-app/sites
import { fluid } from "@fluid-app/sites";

Importing the package also sets window.fluid, merged into any window.fluid already on the page.

No build step: the script tag

Every published site serves the same SDK at /_fluid/sdk/v1/sites.js, so a plain HTML page can use it without npm or a bundler:

<script type="module">
  import { fluid } from "/_fluid/sdk/v1/sites.js";

  const me = await fluid.auth.me();
  document.querySelector("#hello").textContent = me.viewer
    ? `Hi, ${me.viewer.name}`
    : "Hi there";
</script>

fluid sites dev serves the same file once the next fluid sites CLI release ships. Until then, the script tag works only on a published site.

The SDK

fluid.auth: who is here

const me = await fluid.auth.me();
// me.viewer: { userId, name, ... } or null for a signed-out visitor
// me.site:   { id, handle, name, visibility, companyName }

if (!me.viewer) fluid.auth.signIn({ returnTo: "/rsvp" });
// Open the Passport (the account menu), such as from your own account button.
// signOut() sends the same request: the visitor signs out from the Passport.
else fluid.auth.openPassport();

fluid.api: the Fluid API as the visitor

fluid.api calls the member API (/api/member/v2026-10/...) as the signed-in visitor, limited to the scopes the site was granted.

const orders = await fluid.api.get("/orders", { "page[limit]": "10" });

const res = await fluid.api.fetch("/orders?page[limit]=10");
if (res.status === 401) fluid.auth.signIn();

get throws FluidRequestError (with status and code, such as 403 scope_not_granted) on a non-2xx answer. fetch returns the response as it is.

fluid.db: the site's own database

Tables and their access rules come from db/schema.json, published with the build. Queries read like Supabase and resolve to { data, error, count }.

await fluid.db.from("rsvps").insert({ event_id: eventId, guests: 2 });

const { data, error } = await fluid.db
  .from("rsvps")
  .select("name,guests")
  .eq("event_id", eventId)
  .order("created_at", { ascending: false })
  .limit(20);

Every row also has id, created_by, created_at and updated_at.

fluid.fn: the site's server functions

const res = await fluid.fn.fetch("rsvp", {
  method: "POST",
  body: JSON.stringify({ eventId }),
});

A function answers at /api/<name> and /api/<name>/<sub-path> on the site's own origin, so fluid.fn.fetch("orders/123?full=1") calls /api/orders/123?full=1 as the viewer. A plain fetch("/api/rsvp") reaches the same function, without the viewer's token. A server or another site calls an access: "key" function with Authorization: Bearer fsk_…, a site API key. A bundle that declares functions may hold no file under /api/.

fluid.cart: the storefront's cart

On a site shown inside the storefront, the cart is the storefront's own cart. Arguments are the FairShare SDK's.

await fluid.cart.add([{ variant_id: 501, quantity: 1 }]);
const stop = fluid.cart.onChange((cart) => render(cart));
fluid.cart.open();
fluid.cart.checkout();

A button with FairShare's attributes, such as data-fluid-add-to-cart, works too, with no script. On a page with no storefront around it, cart calls reject.

The storefront draws FairShare's floating cart button and lead capture bubble over the site's bottom corner. A site with its own cart button can hide them in config/shell.json:

{ "fairshare": "hidden" }

Only the bubbles are hidden. FairShare still loads, so fluid.cart, fluid.cart.open(), checkout and the sharer's credit work as before.

fluid.history: Back and Forward for a single-page app

Inside the storefront, the address bar belongs to the storefront. Let your router follow it:

fluid.history.onGo((path, hash) => router.navigate(path + (hash ?? "")));

A hash router needs nothing more: the route in the fragment (#/deals/42 or #!/deals/42) shows in the storefront's address bar, a link to it opens that view, and Back, Forward and a reload return to it. The SDK moves the frame to another route with replaceState, then fires popstate and hashchange, as a fragment navigation would.

The route shows in the storefront's address bar and history, so don't put a credential in a hash route (#/oauth?access_token=…). A fragment that isn't a hash route, such as #access_token=…, stays in the frame.

const shop = await fluid.storefront.url("/shop");
// "https://yoli.com/janedoe/shop"

Local development: fluid sites dev

fluid sites dev runs the site on your machine with a database of its own, so fluid.db works before you publish and never touches the live site's rows.

npm install -g @fluid-app/fluid-cli @fluid-app/fluid-cli-member-site
fluid sites dev                  # http://127.0.0.1:9500
fluid sites dev --viewer guest   # browse as a signed-out visitor
fluid sites dev --reset-db       # start from an empty database
  • It runs the folder's dev script (or serves dist/ when there is none) and answers /_fluid/db from .fluid/dev.sqlite, with the same checks and table rules the live site applies. It adds .fluid/ to .gitignore. Once the next fluid sites CLI release ships, it also serves /_fluid/sdk/v1/sites.js.
  • Saving db/schema.json updates the local database at once. A change that drops data waits for you to confirm it.
  • By default you are a stand-in owner, local-owner, so rows you write carry that created_by. /_fluid/api answers 503 locally for now.

SEO: @fluid-app/sites/vite

A site is a single-page app, so a crawler that does not run JavaScript sees an empty page. fluidSite() runs after vite build and writes what a crawler reads from config/meta.json:

// vite.config.ts
import react from "@vitejs/plugin-react";
import { fluidSite } from "@fluid-app/sites/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react(), fluidSite()],
});
// config/meta.json
{
  "default": {
    "title": "Diamond Leaders",
    "description": "Events and training for the Diamond Leaders team.",
    "image": "/og.png"
  },
  "url": "https://shop.example.com/home/sites/diamond-leaders",
  "twitter": { "site": "@fluid" },
  "routes": ["/events", "/drafts"],
  "routeMeta": {
    "/events": { "title": "Events", "description": "What's on." },
    "/drafts": { "noindex": true }
  }
}

What it writes into the build output:

  • Head tags in index.html: <title>, description, og:title, og:description, og:image, og:type, og:url and twitter:card (twitter:site and twitter:creator when set). meta.json's title replaces the page's <title>; any other tag the page already has stays. With an absolute site address, each page also gets its own <link rel="canonical">; without one, the gateway's Link header names it.
  • One index.html per route (/events → events/index.html), with that route's head tags. The gateway serves a route's own file before the single-page-app fallback. A route's title, description and image come from routeMeta[path], then pages[path], then default. Routes come from routes, plus any plain path: "/…" literal in src/router.ts(x) or src/routes.ts(x); meta.json wins.
  • Page content: with prerender, each page's HTML goes inside the root element; without it, each page gets a <noscript> with its title and description.
  • sitemap.xml (with a site address, below) and llms.txt listing the home page and every route. Either is left alone when public/ ships its own.

A route that routeMeta marks noindex gets its HTML file, with <meta name="robots" content="noindex">, and nothing else: it is left out of sitemap.xml and llms.txt, and fluid sites publish drops it from the manifest's routes, which the gateway's own sitemap.xml and llms.txt list.

A site address must be an absolute http(s) URL with no user name, password, query or fragment (meta.json url must be https); any other siteUrl is ignored. Without one, llms.txt and og:url hold paths under /, and no sitemap.xml is written: a sitemap needs absolute addresses, and the plugin never writes a relative <loc>.

The sites gateway serves sitemap.xml and llms.txt only for an indexable site: a live, public company site with indexing turned on in its SEO settings, and no company or theme setting that blocks indexing. A new site starts with indexing off. A member's own site, a branch and a private site are never indexable. Any other site serves neither file, not even the bundle's own. An indexable site serves the bundle's own files unchanged (set url for the build's sitemap.xml); a standalone one with none gets them generated from the manifest's meta.routes.

Every member of meta.json is optional, {} included. A title it names replaces a page's own <title>, such as the template's: default.title on the home page, and routeMeta[path].title (then pages[path], then default) on a route's page; each other field falls back the same way. With no title, the page keeps its own <title>, and llms.txt is named after the home page's. On the storefront, the shell's head takes each field from routeMeta/pages for the exact path, then the site's SEO settings (the admin or the API), then default, so leave default out to let the SEO settings decide. Other tags follow the page: the home page and any page the build wrote itself keep their own description, Open Graph and Twitter tags. A route's page built from the home page's index.html takes the route's own tags over the home page's, and never keeps the home page's robots or canonical. Routes are one page with or without a trailing slash, and a route that is a file of the build (such as /feed.xml from public/) stays that file.

Options

OptionDefaultWhat it does
siteUrlmeta.json urlThe site's public address, which sitemap.xml, llms.txt, og:url and og:image start from
prerendernoneA module exporting render(path): string | Promise<string>, built with Vite's SSR build
rootId"root"The id of the empty element prerendered HTML goes in
routersrc/router.ts(x), then src/routes.ts(x)The router file read for plain paths, or false for none
metaconfig/meta.jsonWhere the site's meta is
mediaonThe media step below; { manifest } names another manifest, false turns it off

A prerender entry for a React site:

// src/prerender.tsx
import { renderToString } from "react-dom/server";

import { App } from "./App";

export function render(path: string): string {
  return renderToString(<App path={path} />);
}
fluidSite({ prerender: "./src/prerender.tsx" });

A meta.json the contract refuses fails the build, with the field it names. fluid sites lint warns a company site that has no title or description, or no routes and no prerender entry.

Media in the Fluid DAM

Images, video, audio and large fonts belong in the company's Fluid DAM, not the bundle: the DAM's CDN serves them, resizes images on request, and the bundle stays small. Put them under public/media/ and reference them by their path, as you would any public file:

<video src="/media/hero.mp4" autoplay muted loop playsinline></video>
<img src="/media/team.jpg?tr=w-800" alt="The team" />

fluid sites media sync (an admin login; fluid deploy runs it before the build) uploads each new or changed file to the DAM, and records its CDN address and SHA-256 in config/media.json. Commit that file: a file whose bytes are unchanged is never uploaded again.

After the build, fluidSite() points every reference to a recorded file at its CDN address, keeping any query (?tr=w-800 is a DAM transformation), and leaves the file out of dist. It then warns about each video or audio file, and each image or font over 256 KiB, still in the build. Small icons and SVGs stay in the bundle.

A member login cannot upload to the company's DAM yet, so a member's site keeps its media in the bundle, with that warning.

DAM media is public by URL, whatever the site's visibility. A CDN address works for anyone who has it, without the site's audience check, and keeps working after someone's access to a private site is revoked. Only a file served from the bundle sits behind that check. Keep anything that must stay private in the bundle (outside public/media/), or out of the site.

Errors

  • FluidRequestError: a /_fluid/* route answered with a status that is not 2xx. It has status, code and body.
  • NotSupportedError: a fluid.db method the database does not support.

版本与发布记录

@fluid-app/sites 共发布 169 个版本,首次发布于 2026/11/07;过去 12 个月发布了 169 个版本。

版本发布日期距今
0.1.167latest2026/11/15今天
0.1.1662026/11/15今天
0.1.1652026/11/15今天
0.1.1642026/11/15今天
0.1.1632026/11/15今天
0.1.1622026/11/151 天前

关于 @fluid-app/sites 的常见问题

@fluid-app/sites 是什么?

The member-sites browser SDK: fluid.auth, fluid.api, fluid.db, fluid.fn and fluid.cart on the site's own origin

如何安装 @fluid-app/sites?

在项目目录运行 npm install @fluid-app/sites,或使用 pnpm add / yarn add / bun add。

@fluid-app/sites 支持 TypeScript 吗?

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

@fluid-app/sites 支持 ESM 还是 CommonJS?

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

@fluid-app/sites 可以商用吗?

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

@fluid-app/sites 的下载量有多少?

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

@fluid-app/sites 还在维护吗?

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