Skip to content

Latest commit

 

History

History
428 lines (275 loc) · 27.8 KB

File metadata and controls

428 lines (275 loc) · 27.8 KB

spiceflow is still in pre release. ignore backwards compatibility, instead focus on making code as simple as possible

generated READMEs

The root README.md is the single source of truth. These files are generated from it and must NOT be edited directly:

  • spiceflow/README.md — generated by spiceflow/package.json build script

The website imports the root README.md directly via holocron's local import feature (website/src/index.mdx), so no copy step is needed.

Always edit the root README.md and then rebuild to propagate changes.

README content guidelines

When adding or editing content in the root README.md, follow these rules:

  • Progressive disclosure. Early sections introduce the framework to new users with simple examples. Advanced topics, edge cases, and platform-specific patterns go later in the document.
  • Prescriptive rules in toggles. Paragraphs that say ALWAYS, MUST, Never, or prescribe strict rules for agents/developers should be wrapped in <details><summary>descriptive title</summary>...</details> toggles so they don't dominate the reading flow.
  • Short headings. Keep section headings concise (3-5 words). Move explanatory content into the opening paragraph below the heading.
  • Extend existing examples. For new APIs, prefer adding them to an existing section's code examples rather than creating a new section. Only add a new ## section for a genuinely new core API or concept.
  • Think about placement. Before adding content, review the overall section hierarchy and choose the best location. Don't just append at the end — find the section where the topic fits logically.
  • Split long sections. If a ## section grows beyond ~5 paragraphs or ~3 code blocks, split it into ### subsections with short headings. Readers should never have to scroll through a wall of content under a single heading.

package manager: pnpm with workspace

This project uses pnpm workspaces to manage dependencies. Important scripts are in the root package.json or various packages package.json

try to run commands inside the package folder that you are working on. for example you should never run pnpm test from the root

typescript

Try to use object arguments for new typescript functions if the function would accept more than one argument, this way you can use the object as a sort of named argument feature, where order of arguments does not matter and it's easier to discover parameters.

Always run pnpm tsc (or the package build script) from the package you changed after code edits, and fix any reported issues before finishing. This both type-checks and emits dist files. Never use --noEmit — we always want dist to stay in sync with source. Stale dist files are the most common cause of test failures.

This project follows the errors-as-values philosophy (return errors instead of throwing, check with instanceof, use early returns). We do NOT use the errore npm package as a dependency; the pattern is inlined where needed. Do not install errore.

do not add useless comments if the code is self descriptive. only add comments if requested or if this was a change that i asked for, meaning it is not obvious code and needs some inline documentation.

try to use early returns and breaks, try nesting code as little as possible, follow the go best practice of if statements: avoid else, nest as little as possible, use top level ifs. minimize nesting.

example-* folders must typecheck

After editing any example-* folder, run npx tsc --noEmit from that folder and fix any errors before finishing.

testing

Use vitest to run tests. Tests should be run from the current package directory and not root, try using the test script instead of vitest directly. Additional vitest flags can be added at the end, like --run to disable watch mode or -u to update snapshots.

Most tests should be simple calls to functions with some expect calls, no mocks. Test files should be called same as the file where the tested function is being exported from.

Tests should strive to be as simple as possible, the best test is a simple .toMatchInlineSnapshot() call. These can be easily evaluated reading the test file after the run passing the -u option. You can clearly see from the inline snapshot if the function behaves as expected or not.

Try to use only describe and test in your tests. Do not use beforeAll, before, etc if not strictly required.

Sometimes tests work directly on database data, using prisma. To run these tests you have to use the package.json script, which will call doppler run -- vitest or similar. Never run doppler cli yourself as you could delete or update production data. Tests generally use a staging database instead.

Never write tests yourself that call prisma or interact with database or emails. For these asks the user to write them for you.

e2e testing (integration-tests)

E2e tests live in integration-tests/e2e/ and use Playwright (chromium only). The dev server starts automatically via the webServer config in playwright.config.ts.

running e2e tests

These are the integration test commands for the RSC app in integration-tests/:

  • pnpm test-e2e runs the Playwright suite against the dev server, so it covers dev-only behavior like HMR and middleware behavior during development.
  • pnpm test-e2e-start runs the same Playwright suite against the production start server, so it catches build-only regressions that do not show up in dev.
  • Run both commands when validating an integration change, because they exercise different environments and one passing does not imply the other passes.
# run from integration-tests directory, never from root
cd integration-tests

# run all e2e tests
pnpm test-e2e

# filter by test name
pnpm test-e2e --grep "SSR error"

# run against production build
pnpm test-e2e-start

Tests tagged @dev are skipped during start runs; tests tagged @build are skipped during dev runs (controlled by grepInvert in integration-tests/playwright.config.ts).

base path testing

Run the same e2e suite with a non-root base path to validate base path support:

cd integration-tests
pnpm test-e2e-basepath

This sets BASEPATH=/test-base which vite.config.ts reads as base: '/test-base'. All e2e test helpers use a url() function and basePath variable to prepend this prefix to page.goto, fetch, and assertion calls.

When writing new tests, always use url("/path") for page.goto and toHaveURL, and ${basePath}/path inside template literals for fetch URLs and Location header assertions. This ensures tests work identically with and without the base path.

federation testing

Federation e2e tests live in example-federation/host/e2e/ and use Playwright. They require both the remote and host apps to be built first, since tests run against production builds.

# 1. rebuild spiceflow dist (if you changed spiceflow/src/)
cd spiceflow
pnpm tsc

# 2. build remote first (host fetches from it at runtime)
cd example-federation/remote
pnpm build

# 3. build host
cd example-federation/host
pnpm build

# 4. run federation e2e tests
cd example-federation/host
pnpm test-e2e

Both remote and host servers start automatically via webServer in the host's playwright.config.ts. Always rebuild both after changing spiceflow/src/ — stale dist files are the most common cause of federation test failures.

standalone federation consumer (manual testing)

The example-federation/standalone/ folder tests that a Vite library mode build of a federation consumer works from a plain index.html. It simulates shipping an npm package that bundles spiceflow/federation-client and externalizes React.

To run it manually for interactive testing or debugging the federation client:

# 1. rebuild spiceflow dist (if you changed spiceflow/src/)
cd spiceflow && pnpm tsc

# 2. start the remote in dev mode
cd example-federation/remote && pnpm exec vite dev --port 3051

# 3. in another terminal, start the standalone library in watch mode
cd example-federation/standalone && pnpm dev

# 4. in another terminal, serve the static files
cd example-federation/standalone && npx serve . -l 3053

Open http://localhost:3053. The pnpm dev command runs vite build --watch, so editing src/chat-widget.tsx or src/main.tsx rebuilds the library automatically. Refresh the browser to pick up changes.

The remote runs in vite dev mode so client chunks are served from source with React externalized for cross-origin federation consumers. The federation-dev-externalize plugin strips Fast Refresh and HMR artifacts from "use client" modules so they work cross-origin. Refresh the consumer page after editing remote files.

E2e tests: cd example-federation/standalone && pnpm test-e2e. To cover the remote vite dev path and the federation-dev-externalize plugin, run pnpm test-e2e-dev-remote.

nextjs federation consumer (manual testing)

The example-federation/nextjs-consumer/ tests that spiceflow/federation-client works inside a Next.js app bundled by webpack/turbopack.

To run it manually:

# 1. rebuild spiceflow dist (if you changed spiceflow/src/)
cd spiceflow && pnpm tsc

# 2. start the remote in dev mode (or use pnpm build + node dist/rsc/index.js)
cd example-federation/remote && pnpm exec vite dev --port 3051

# 3. in another terminal, start the Next.js dev server
cd example-federation/nextjs-consumer && pnpm dev

Open http://localhost:3060. Click "Load Remote Chart" to fetch and render a federated component from the remote.

E2e tests run in two modes:

  • pnpm test-e2e — dev mode (next dev)
  • pnpm test-e2e-start — production mode (next build + next start)
  • pnpm test-e2e-dev-remote — Next.js production mode against the remote vite dev server

rebuild dist before testing

The Vite SSR middleware imports from spiceflow/dist/ (the compiled package), NOT from source. If you modify files in spiceflow/src/, you must rebuild before e2e tests will pick up the changes:

cd spiceflow
pnpm tsc

NEVER use --noCheck or --noEmit

This is the most common reason e2e tests fail after code changes — stale dist files.

vercel deployment checks

The integration-tests Vercel project exists to validate that Spiceflow still deploys and runs correctly on Vercel. Keep this project green. If a change breaks this deployment, treat it as a real regression in platform support, not just a flaky preview failure.

Use the Vercel CLI to check the latest production deployment status:

vercel list integration-tests --scope tommaso-de-rossis-projects

If the latest deployment failed, inspect its build logs using the deployment URL from vercel list:

vercel inspect "https://integration-tests-<id>-tommaso-de-rossis-projects.vercel.app" --logs --wait --scope tommaso-de-rossis-projects

Useful workflow when debugging:

  • Look at the latest failed deployment first, not an older dashboard link.
  • Read the exact failing command from the build logs, usually pnpm install, pnpm run vercel-build, or the generated output step.
  • If the failure is before the build starts, check workspace metadata problems first: missing lockfile updates, bad workspace ranges, missing files, or package.json changes not reflected in pnpm-lock.yaml.
  • If the failure happens during vercel-build, reproduce it from integration-tests/ locally and rebuild spiceflow/ first if you changed spiceflow/src/.
  • After fixing the issue, push to main, then watch the new deployment logs again with vercel inspect ... --logs --wait until it reaches status ● Ready.

Common gotcha: Vercel runs pnpm install with a frozen lockfile. If integration-tests/package.json changes but pnpm-lock.yaml was not committed, the deployment fails with ERR_PNPM_OUTDATED_LOCKFILE. In that case the fix is usually to update and commit the lockfile, not to change Vercel settings.

writing e2e tests

  • The base URL and port are defined at the top of basic.test.ts:
    const port = Number(process.env.E2E_PORT || 6174)
    const baseURL = `http://localhost:${port}`
  • Use page.goto("/path") for browser-based tests that need rendering, JS execution, or DOM interaction.
  • Use Node.js fetch(baseURL + "/path") directly (not page.evaluate) when you need to control HTTP headers like Origin — browsers restrict forbidden headers.
  • Use page.getByTestId(), page.getByText(), page.getByRole() for locators. Prefer test-ids for stability.
  • When a data-testid matches multiple elements (e.g. multiple counter components on a page), use .filter({ hasText: "..." }) to disambiguate:
    const clientCounter = page
      .getByTestId('client-counter')
      .filter({ hasText: 'Client counter' })
    await clientCounter.getByRole('button', { name: '+' }).click()
  • If a locator's text changes during the test (e.g. HMR edits), do NOT use it through a pre-filtered variable — query the page directly for the new text.

adding test routes

To add a route for e2e testing, add it in integration-tests/src/main.tsx using the spiceflow API:

.page("/my-test-route", async () => {
    return <MyComponent />;
})

Client components used in tests should be created in integration-tests/src/app/ with a "use client" directive.

HMR tests

  • createEditor("src/app/file.tsx") from e2e/helper.ts edits a file and auto-reverts on dispose.
  • Always call file[Symbol.dispose]() or use try/finally to restore files after edits.
  • When editing files, make sure the replace() string actually exists in the source. For example, client.tsx has name = "Client" as a default prop — the literal string "Client counter" does NOT exist in the file, so replace("Client counter", ...) would be a no-op and the HMR test would silently fail.
  • Client HMR preserves state: editing a client component triggers React Fast Refresh without a server re-render. Client state is preserved. Vite's SSR environment logs page reload internally but the browser does not actually reload — Fast Refresh handles it.
  • Server HMR preserves server state: editing a server component triggers RSC HMR. Server-side state (e.g. counters stored in module scope) is preserved. router.refresh() re-fetches the RSC payload, but React reconciles matching client components in place, so their local state is preserved unless their identity/key actually changes.
  • The home page has a serverRenderCount counter (data-testid="server-render-count") that increments on each RSC render. Use it in tests to verify whether a server re-render happened.
  • To detect full page reloads in tests, set a window sentinel before the edit and check it after: await page.evaluate((s) => { window.__hmrSentinel = s }, sentinel) — if the sentinel is gone after the edit, a full reload happened.

debugging unwanted full page reloads in vite

When HMR triggers a full page reload instead of a hot update, the cause is a {type:"full-reload"} WebSocket message sent to the browser. To find who sends it, patch hot.send on every Vite environment with console.trace in a temporary plugin:

{
  name: 'debug-full-reload',
  configureServer(server) {
    for (const envName of Object.keys(server.environments)) {
      const env = server.environments[envName]
      const origSend = env.hot.send.bind(env.hot)
      env.hot.send = function (...args) {
        if (args[0]?.type === 'full-reload') {
          console.trace(`[full-reload] env=${envName} payload=${JSON.stringify(args[0])}`)
        }
        return origSend(...args)
      }
    }
  },
},

The stack trace reveals exactly which plugin and hook is responsible. Common culprits:

  • @tailwindcss/vite hotUpdate sending bare {type:"full-reload"} for server-only files it scans for class names
  • Vite's dep optimizer (runOptimizerfullReload) when deps change — usually harmless, triggers once
  • updateModules in Vite core when propagateUpdate hits a dead end (no HMR boundary found)

website

The website uses holocron (@holocron.so/vite) as a Vite plugin for docs rendering. It is configured in website/vite.config.ts with a custom entry at website/src/server.tsx that mounts the holocron app alongside API routes.

Pages live in website/src/ as .md or .mdx files. The index page (website/src/index.mdx) imports the root README.md via holocron's local import feature. Navigation is configured in website/docs.json.

The custom entry (website/src/server.tsx) preserves:

  • /llms.txt serving raw README text
  • /gh redirect to GitHub

The Cloudflare Vite plugin is only enabled during builds (not dev) because miniflare's dev runtime doesn't forward CSS side-effects from the RSC environment to the client, causing holocron's styles to not load in dev mode.

spiceflow/react exports

All React-facing APIs (components, router, utilities) must be exported from spiceflow/react (i.e. spiceflow/src/react/index.ts). Never import from spiceflow/dist/react/... directly — that's an internal path that breaks when the build output changes. If something is meant for users (like Head, Link, ProgressBar, router), it must be in the public export.

type-safe routing with SpiceflowRegister

Spiceflow uses a type registry pattern (like TanStack Router) for type-safe routing. The preferred approach:

  1. Import router directly from spiceflow/react — no function call, no generics
  2. Add declare module 'spiceflow/react' { interface SpiceflowRegister { app: typeof app } } at the bottom of the app entry file

This registers the app type globally so all typed APIs work without generics:

  • router and getRouter() from spiceflow/react
  • useLoaderData() and useRouterState() from spiceflow/react
  • createSpiceflowFetch() from spiceflow/client

Never use explicit generics like getRouter<typeof app>(), useLoaderData<typeof app>(), or createSpiceflowFetch<typeof app>() in new code or documentation — the register pattern replaces them.

The register interface lives in spiceflow/src/react/router.tsx as SpiceflowRegister, exported from spiceflow/react and spiceflow. RegisteredApp is the conditional type that reads from the register and falls back to AnySpiceflow (which gives any fallback behavior). All typed APIs default their generic to RegisteredApp.

In tests, the register pattern assumes a single app per TypeScript project — which doesn't hold in test files that create multiple apps per test. Tests that need to verify type safety for a specific app must still use explicit generics like useLoaderData<typeof app>('/path') or getRouter<typeof app>(). Tests that only verify runtime behavior can use the no-generic versions.

server actions and router.refresh()

Spiceflow automatically re-renders the page after every server action call (callServer always applies the new RSC payload). This means router.refresh() is NOT needed after server actions — the page updates automatically for direct <form action={serverAction}>, client wrapper functions, and direct imported action calls.

router.refresh() is still useful for standalone refreshes (e.g. after a WebSocket event, or to poll for updates). It triggers a re-fetch of all server components and loaders.

router.refresh() is fire-and-forget. router.push(), router.replace(), router.back(), router.forward(), and router.go() are fire-and-forget too. Never await refresh completion or build awaitable navigation/refresh helpers and call them inside a React client form action (<form action={async () => { ... }}>). React keeps the form action transition pending until the action returns, so awaiting the commit from inside that same action can deadlock the page.

The auto-re-render uses setPayloadDirect (raw setState, no startTransition) so it joins whatever transition is already active. This is critical: React 19 wraps form actions in startTransition, and a nested startTransition would create a separate transition that commits independently — causing a flash of stale content between the caller's state updates and the new server data.

Use ErrorBoundary from spiceflow/react to catch thrown errors from form actions. It provides ErrorBoundary.ErrorMessage and ErrorBoundary.ResetButton sub-components. See the README "Error Handling" section for examples.

files

always use kebab case for new filenames. never use uppercase letters in filenames

changesets

after you make a change that is noteworthy, add a changeset manually as a markdown file inside the .changeset folder. these files are used later on to create the changelog for the package. if the current cwd does not have a .changeset folder, check parent directories.

NEVER make breaking changes changesets. our releases are close enough in time that you should never do breaking changes, even if we do one you have nothing to worry about because it's probably a publish so close in time that on one will ever use the current version.

Only add changesets for packages that are not marked as private in their package.json and have a version in the package.json.

Changeset files should be plain .md files with this structure:

---
'package-name': patch # can also be `minor`, never use `major`
---

markdown describing the changes you made, in present tense, like "add support for X" or "fix bug with Y". write a single concise paragraph, not bullet points or lists. include example code snippets if useful, and use proper markdown formatting.

check-entry (bundleability guard)

pnpm check-entry in the spiceflow package bundles src/index.ts with esbuild using zero externals. It runs automatically during pnpm build. This validates that import { Spiceflow } from 'spiceflow' works for users who are NOT using the React/RSC features and bundle with esbuild, webpack, or similar — without needing to externalize anything.

If this check fails, it means a Vite-only dependency (like @vitejs/plugin-rsc) leaked into the main import path. RSC-only imports must go through the #rsc-runtime subpath import (defined in package.json imports field) which uses the react-server condition to resolve to the real implementation in Vite RSC environments and to an empty fallback everywhere else.

Never import virtual: modules directly in spiceflow source files outside of .rsc.ts files. Vite virtual modules (like virtual:spiceflow-deployment-id) only exist inside Vite builds — importing them directly makes spiceflow impossible to build or bundle without Vite. Instead, use the package.json imports field with a react-server condition:

  1. Create a .rsc.ts file with the real implementation that imports the virtual module
  2. Create a default .ts fallback that returns a safe default (e.g. '')
  3. Add an entry to package.json imports mapping react-server → the .rsc.js file and default → the fallback

Existing examples: #rsc-runtime, #deployment-id.

conditions and environments

Spiceflow runs code in three Vite environments with different resolution conditions:

  • client uses the normal browser/default path
  • ssr is the HTML render environment and must resolve server-only helpers through an explicit ssr condition
  • rsc is the React Server Components environment and resolves through react-server

When code needs different implementations per environment, prefer package.json imports entries over ad-hoc runtime branching when the dependency itself is environment-specific. Use default for the browser-safe fallback, ssr for plain server rendering helpers, and react-server for RSC-only implementations.

ALS means AsyncLocalStorage from node:async_hooks. We use it to store request-scoped router state (location, loaderData) during SSR and RSC renders so getRouter(), useRouterState(), and useLoaderData() can read the current request without threading props through every layer. The browser never has ALS, so browser-safe fallbacks must resolve to modules that do not import node:async_hooks at all.

Keep the conditional imports split by concern. #router-context exists to choose between the real ALS-backed request store on the server and a noop browser fallback. #flight-data-context exists separately to choose between a real React context in normal environments and a null export in react-server, where React.createContext is not available. Do not merge these two imports: they vary on different axes and collapsing them makes the env matrix harder to reason about.

Current pattern:

  1. Create a default .ts file that is safe in the browser bundle
  2. Create an .rsc.ts file when logic is only valid in the RSC environment
  3. Create a server file for SSR-only logic when plain SSR needs the real implementation but the browser must not import it
  4. Add a package.json imports entry that maps browser/default, ssr, and react-server to the correct build outputs
  5. Make sure the spiceflow Vite plugin adds matching resolve.conditions for each environment so client, ssr, and rsc do not accidentally load the same file

Current examples:

  • #rsc-runtime: real RSC runtime in react-server, safe fallback elsewhere
  • #deployment-id: Vite virtual module behind a react-server split
  • #flight-data-context: real React.createContext by default, null in react-server
  • #router-context: real ALS store for ssr/react-server/direct Node-Bun tests, noop fallback in browser/default

Use this for internal helpers like request-scoped stores, runtime bridges, and environment-specific filesystem or Node APIs. If the browser bundle ever tries to import a Node-only module, the fix is usually to move that dependency behind an env-specific #import and wire the condition in Vite, not to add more runtime guards after the import.

vite-rsc

the spiceflow vite plugin depends on vite-rsc plugin. you can read its source code with opensrc vitejs/vite-plugin-react. inside folder packages/plugin-rsc`. there are also examples there. inside examples folder

we also try to work well with the cloudflare vite plugin. the source code of that is in https://github.com/cloudflare/workers-sdk/blob/main/packages/vite-plugin-cloudflare

we have an example example-cloudflare that we can use to make sure pnpm dev, build, preview and deployment work well.

in wrangler.jsonc, "main" must always point to the actual app entry file (e.g. "./src/main.tsx"). Never use "spiceflow/cloudflare-entrypoint" or any virtual/package entrypoint. The spiceflow vite plugin handles RSC wiring regardless of what main points to, and bare package specifiers break @cloudflare/vitest-pool-workers static analysis.

Waku (opensrc dai-shi/waku, packages/waku/) is another Vite RSC framework we use as reference for Vite integration patterns. It uses the same @vitejs/plugin-rsc plugin and has a similar multi-environment setup (client, ssr, rsc). Useful to check how they handle optimizeDeps, resolve.noExternal, SSR middleware, and RSC environment config. Their Vite plugins live in packages/waku/src/lib/vite-plugins/.

import.meta.env

do not use import.meta.env to get env variables. this is not always available. instead to know if we are in development you can use import.meta.hot instead

Code-split entry and import.meta.filename resolution

Vite/Rolldown code-splits virtual:app-entry into rsc/assets/*.js chunks. The actual rsc/index.js becomes a tiny re-export stub. Any code inside the entry that uses import.meta.filename to compute relative paths will resolve from rsc/assets/, not rsc/.

When computing paths relative to the rsc output dir, always detect the assets/ nesting and walk up:

const thisDir = dirname(import.meta.filename)
const rscDir = basename(thisDir) === 'assets' ? dirname(thisDir) : thisDir
const target = resolve(rscDir, relativePathFromRsc)

This pattern is used in two places in vite.tsx:

  • virtual:spiceflow-dirs for publicDir/distDir
  • virtual:app-entry for the auto-injected serveStatic client dir

If you add any new code to these virtual modules that resolves paths relative to the rsc output, apply the same basename === 'assets' guard. Without it, the path resolves one directory too deep and points to a nonexistent location.