npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fluid-app/sites

v0.1.152

Published

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

Readme

@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" });
await fluid.auth.signOut();

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.

fluid.storefront: links that keep the sharer's credit

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

| Option | Default | What it does | | ----------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------- | | siteUrl | meta.json url | The site's public address, which sitemap.xml, llms.txt, og:url and og:image start from | | prerender | none | A 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 | | router | src/router.ts(x), then src/routes.ts(x) | The router file read for plain paths, or false for none | | meta | config/meta.json | Where the site's meta is | | media | on | The 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.