Seed Protocol

@seedprotocol/vite

The Vite plugin for browser apps that use @seedprotocol/sdk: cross-origin isolation headers, ES module workers, in-order dev pre-bundles, and opt-in Node polyfills and fs aliases.

@seedprotocol/vite is a Vite plugin that configures a browser build for @seedprotocol/sdk. The SDK keeps its SQLite database and files in the browser's private file system (OPFS), which needs a cross-origin isolated page. The plugin sends the cross-origin isolation headers on the dev and preview servers, builds workers as ES modules, keeps the dev pre-bundle in order, and makes Seed packages linked into your app share one copy of React and of each Seed package. Node polyfills and fs aliases are opt-in, because the SDK doesn't need them. The plugin runs at build time only and adds no runtime API.

import { seedVitePlugin } from '@seedprotocol/vite'

Install

npm install --save-dev @seedprotocol/vite

The plugin needs Vite 8 or later (vite >= 8.0.0 is a peer dependency). @zenfs/core (^2.7.7) and @zenfs/dom (^1.2.14) are optional peers; the SDK depends on both, so an app with the SDK has them. Use @vitejs/plugin-react 6 alongside it for React apps. It depends on vite-plugin-node-polyfills, which it uses only when you turn on includeNodePolyfills, so you don't install a polyfill package yourself.

Add Seed to your app walks through the whole setup for a Vite + React app, including the SDK install.

seedVitePlugin

Returns an array of Vite plugins that configures a project for the SDK. Spread it into plugins after your framework plugin.

function seedVitePlugin(options?: SeedVitePluginOptions): Plugin[]
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { seedVitePlugin } from '@seedprotocol/vite'

export default defineConfig({
  plugins: [react(), ...seedVitePlugin()],
})

Options

All options are optional. An option the plugin doesn't know throws when the config loads, for example seedVitePlugin: unknown option "fsModule". It takes fsModules, debug, includeNodePolyfills, isolationHeaders. The removed autoInit option throws seedVitePlugin: autoInit was removed. Initialize the SDK from your app (client.init(), or <SeedProvider>).

NameTypeDefaultDescription
isolationHeadersboolean | { coop?: string; coep?: string; corp?: string }trueSets cross-origin isolation headers on vite and vite preview. true sends Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: credentialless and Cross-Origin-Resource-Policy: same-origin. Pass an object to change individual values (the rest keep their defaults), or false to send none. One of these headers set in server.headers or preview.headers, in any letter case, wins over the plugin's value for that server; vite preview uses server.headers when you set no preview.headers
includeNodePolyfillsbooleanfalseAdds Node polyfills, the global and process defines, a stream stub and inline shims in index.html (see With includeNodePolyfills). Turn it on when another dependency of your app uses Node built-ins or the Buffer, process or global globals in the browser
fsModulesstring[][]Module names aliased to ZenFS, for app code or a dependency that imports Node's fs in the browser. A name containing promises maps to @zenfs/core/promises, any other name to @zenfs/core. To alias all four forms, pass ['fs', 'fs/promises', 'node:fs', 'node:fs/promises']
debugbooleanfalseLogs what the plugin adds to the console with a [seed-vite-plugin] prefix, and after a production build warns about Node globals left in the output (see Warnings). It isn't related to the debug npm package

The type is exported too:

import type { SeedVitePluginOptions } from '@seedprotocol/vite'

Example

Switch the embedder policy to require-corp and turn on logging:

vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { seedVitePlugin } from '@seedprotocol/vite'

export default defineConfig({
  plugins: [
    react(),
    ...seedVitePlugin({
      isolationHeaders: { coep: 'require-corp' },
      debug: true,
    }),
  ],
})

What the plugin configures

With the default options, the plugin returns one Vite plugin that makes these changes. Only the headers can be turned off.

  • Sends the cross-origin isolation headers (see isolationHeaders) on every response from vite and vite preview, including responses written directly with writeHead, which framework HTML handlers use and Vite's server.headers doesn't reach.
  • Sets worker.format to 'es'. Vite's default 'iife' can't build the database worker, which uses code splitting, so the database never opens in a production build.
  • Sets optimizeDeps.rolldownOptions.output.strictExecutionOrder to true. drizzle-orm's modules import each other in a cycle, and when your app imports both @seedprotocol/sdk and @seedprotocol/sdk/drizzle, the dev pre-bundle can otherwise run them out of order. Production builds aren't affected.
  • Adds react, react-dom, thirdweb, xstate, @xstate/react, @zenfs/core, @zenfs/dom and the Seed packages (sdk, react, publish, feed, eas, arweave, query) to resolve.dedupe, each only when a node_modules folder at or above your app root has it. Under pnpm's default layout that means the packages your app lists itself. Without this, a Seed package linked into your app (file:, link: or a workspace) loads its own copies: a second React makes hooks fail, and a second copy of a Seed package splits its module state, such as the registered EAS and Arweave clients.
  • In vite dev, extends server.fs.allow with the SDK's directory and the sqlocal and @sqlite.org/sqlite-wasm directories when they sit outside the directories Vite already allows, for example when the SDK is linked. Without this, the dev server answers the database worker and WASM requests with 403.

The plugin doesn't set optimizeDeps.include or exclude, so Vite pre-bundles the SDK and its dependencies like any other package. That's what lets vite dev work under pnpm's default isolated layout.

The plugin only adds to your config. A worker.format or strictExecutionOrder you set is kept, resolve.dedupe entries you already listed aren't repeated, and your resolve.alias entries match before its own.

With includeNodePolyfills

includeNodePolyfills: true adds four more Vite plugins, which make these changes:

  • Adds vite-plugin-node-polyfills for path, buffer, events, string_decoder, util and timers (and their node: forms), plus Buffer, global and process globals. stream and crypto aren't polyfilled, because those pull in stream-browserify and break server-side rendering and prerendering. readline is excluded.
  • Resolves the polyfill package's shim imports (buffer, global, process) to absolute file paths inside the plugin's install, so they work when the polyfill package isn't visible from your app.
  • Aliases stream and node:stream to a minimal stub, so packages such as rss-parser load without the full stream polyfill.
  • Defines global as globalThis (in dev pre-bundling too), process.browser as true and process.platform as "browser", except for a key you define yourself. It leaves process.env.NODE_ENV to Vite, so React Fast Refresh keeps working in dev.
  • Injects a small inline <script> at the top of <head> in index.html that creates globalThis.global and a minimal globalThis.process (with env.NODE_ENV set from Vite's mode) before any module runs. It's added through Vite's transformIndexHtml hook, so it only appears in HTML that Vite transforms.

Warnings

With debug: true, the plugin parses each output chunk after a production build and prints free references to global, process, Buffer, __dirname and __filename under [seed-vite-plugin] Bare Node.js globals in browser chunks. It skips typeof checks, property names, names the chunk declares itself, and uses behind a typeof guard or inside a try block. A chunk it can't parse reports nothing.

It also skips chunks made only of Seed packages' modules (the SDK, @seedprotocol/react, query, eas and arweave, or anything under node_modules/@seedprotocol/). A chunk that mixes Seed modules with other code is still scanned, so an app with only the SDK still lists a few hits, such as Buffer in drizzle-orm's blob columns and process.cwd() in Node path handling, and runs without errors. A hit throws a ReferenceError only if that code runs in the browser, so check the named chunk for a dependency that expects Node, and turn on includeNodePolyfills if it does.

@seedprotocol/sdk/vite

The SDK re-exports the plugin and its options type, so older projects that import it from the SDK keep working:

import { seedVitePlugin, type SeedVitePluginOptions } from '@seedprotocol/sdk/vite'

It's the same function. @seedprotocol/vite is an optional peer of the SDK, so this import works only when your app installs @seedprotocol/vite; without it, the import fails with ERR_MODULE_NOT_FOUND: Cannot find package '@seedprotocol/vite'. New projects should import from @seedprotocol/vite.

Headers in production

The plugin sets headers on the Vite dev and preview servers only. Your production host must send the same headers on the HTML page, or the page isn't cross-origin isolated and client.init rejects with DatabaseNotPersistentError (code: 'DB_NOT_PERSISTENT'). SeedClientGate then stays on its loading state; Handling init errors shows how to report it.

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Resource-Policy: same-origin

Use Cross-Origin-Embedder-Policy: require-corp instead if your stack needs it or a browser you target doesn't support credentialless. With require-corp, every cross-origin resource the page loads (images, fonts, scripts from a CDN) must opt in with CORS or a Cross-Origin-Resource-Policy header. Sending the headers on every path, as the examples below do, also covers the worker scripts.

These host snippets follow each host's documented header syntax, but we haven't tested them against a deployed Seed app. Check the response headers in your browser's network panel after deploying, and confirm self.crossOriginIsolated is true in the console.

public/_headers
/*
  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: credentialless
  Cross-Origin-Resource-Policy: same-origin

For Netlify and Cloudflare Pages, a _headers file in Vite's public/ folder is copied to the root of the build output, which is where both hosts look for it. In nginx, add_header directives in a location block replace any inherited from the server block, so keep all three together.

You can check a production build locally with vite build followed by vite preview, which gets the same headers as the dev server.

React Router framework mode and prerendering

React Router framework mode, and other setups that render or prerender pages with Vite's SSR build, take the plugin with no options. By default it adds no polyfills, defines or aliases, and it applies the isolation headers to the HTML that framework mode writes with writeHead. If you turn on includeNodePolyfills, it still leaves out the stream and crypto polyfills, which break prerendering. Don't alias crypto to crypto-browserify yourself for the same reason.

Start the Seed client in client-side code (with SeedClientGate or client.init), not during prerender: the browser client needs OPFS and a cross-origin isolated page.

We haven't run a framework-mode app with the current plugin. The paragraph above describes what the plugin's code does.

Electron

You can use the plugin in a Vite-built Electron renderer config. The renderer needs a cross-origin isolated page like any other browser build, and Node-only work, such as a better-sqlite3 database, stays in the main process. Using Seed in Node and Electron covers the setup.

Troubleshooting

  • Buffer is not defined or process is not defined at runtime, or Vite's "has been externalized for browser compatibility" error in the browser console. A dependency of your app, not the SDK, expects Node. includeNodePolyfills: true covers path, buffer, events, string_decoder, util and timers, the Buffer, global and process globals, and a stream stub. For any other built-in, such as http, https, url or crypto, install a browser version and alias it in resolve.alias. @seedprotocol/mapping in a browser shows the case for rss-parser. To find the dependency, run vite build with debug: true and read the Warnings.
  • The client never gets past loading, and the console shows DatabaseNotPersistentError. The page isn't cross-origin isolated. In dev, check you haven't set isolationHeaders: false and that your own server.headers don't replace the plugin's with other values. In production, check your host's headers (see Headers in production).
  • Failed to resolve import "sqlocal/drizzle", or does not provide an export named 'default', under pnpm. Your config lists the SDK, sqlocal or drizzle-orm in optimizeDeps.exclude, often a workaround copied from an older setup. Remove those entries, delete node_modules/.vite and restart the dev server.
  • Cannot read properties of undefined (reading 'Symbol') at startup in vite dev. drizzle-orm's pre-bundled chunks ran out of order. Check that ...seedVitePlugin() is in plugins and that your config doesn't set optimizeDeps.rolldownOptions.output.strictExecutionOrder to false.
  • Failed query at init in a production build only. The database worker was built as 'iife'. Remove worker.format: 'iife' from your config, or set it to 'es'.
  • File reads fail or come back empty. You probably have two copies of ZenFS. Run npm ls @zenfs/core @zenfs/dom (or pnpm why @zenfs/core): each package should appear at one version, the one the SDK lists. If not, remove your own @zenfs/* entries from package.json, or pin them to the SDK's versions, and install again.
  • 403 for a worker or .wasm file under /@fs/ in dev, or sqlocal worker failed to start with a linked SDK. The file is outside Vite's allowed directories. The plugin adds the SDK, sqlocal and @sqlite.org/sqlite-wasm directories automatically; turn on debug: true to see what it added, and add any other directory to server.fs.allow yourself.
  • Module "node:dns" has been externalized for browser compatibility (and the same for net) during vite build in an app that uses @seedprotocol/publish. They come from publish's domain ownership checks, which a browser app doesn't run, and are expected. An app with only the SDK and @seedprotocol/react builds without them.
  • DuplicateEasModuleError or DuplicateArweaveModuleError in the browser. Two copies of @seedprotocol/eas or @seedprotocol/arweave were loaded. Run npm ls @seedprotocol/eas @seedprotocol/arweave: each should be one deduped copy, at the SDK's version. Keep Seed packages out of optimizeDeps.exclude so Vite pre-bundles them together, then delete node_modules/.vite and restart the dev server. See @seedprotocol/query.

On this page