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
- `website/app/readme.mdx` — generated by `website/package.json` readme script
- `website/public/readme.md` — generated by `website/package.json` readme script

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.

```bash
# 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:

```bash
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.

```bash
# 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.

## 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:

```bash
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:

```bash
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`:

```bash
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`:
  ```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:
  ```ts
  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:

```ts
.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:

```ts
{
  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 (`runOptimizer` → `fullReload`) 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 react-router v7.

React-router framework is the successor of Remix, it is basically the same framework and it uses loaders and actions as core features.

react-router follows all the conventions of remix but all imports must be updated to point to `react-router` instead of `@remix-run/react` or `@remix-run/node`.

## route file exports

You can export the functions `loader` and `action` to handle loading data and submitting user data.

The default export (not always required for API routes) is the jsx component that renders the page visually.

Notice that the `json` utils was removed from `react-router`, instead there is a function `data` which is very similar and accepts a second argument to add headers and status like `json` does, but it supports more data types than json, like generators, async generators, dates, map, sets, etc.

## type safety

react-router exports a `Route` namespace with types like `Route.LoaderArgs`, `Route.ActionArgs` and `Route.ComponentProps`

these types can be used for the main route exports, they must be imported from `./+types/{route-basename}`

For example if the current file is `src/routes/home.tsx` you can import `import { Route } from './+types/home'`.

## styling

always use tailwind for styling, prefer using simple styles using flex and gap. Try to use the built in tailwind colors like gray, red, green, etc. Margins should be avoided, instead use flexbox gaps, grid gaps, or separate spacing divs.

## 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:

```md
---
'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:

```ts
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.
