# Ovineko
> Tools born from friction. Solutions that stay.
This file contains all documentation content in a single document following the llmstxt.org standard.
## Development
# Development Setup
## Prerequisites
- **Node.js** >= 24.11.0
- **pnpm** >= 10.25.0
## Getting Started
```bash
git clone https://github.com/ovineko/ovineko.git
cd ovineko
pnpm install
```
## Common Commands
### Building
```bash
# Build all packages (uses Turborepo for orchestration)
turbo build
# Build a specific package
cd packages/react-router
pnpm build
```
### Testing
```bash
# Run tests in a specific package
cd packages/react-router
pnpm test # Run once
pnpm test:watch # Watch mode
pnpm test:coverage # With coverage report
pnpm test:ui # Open Vitest UI
pnpm test:debug # Console output visible (DEBUG=1)
```
Console output (`console.log`, `console.warn`, `console.error`) is suppressed during tests by default. Use `pnpm test:debug` to see it when diagnosing failures.
### Linting
```bash
# Auto-fix issues, then run all checks
pnpm dm check
# Collect all diagnostics without stopping at the first failed tool
pnpm dm check --fail-fast=false --output agent
```
The project uses **datamitsu** to manage linting, formatting, spelling, dependency consistency, and TypeScript checks. Run the tools through `pnpm dm check` rather than invoking individual linters or formatters.
## Monorepo Structure
The repository uses **pnpm workspaces** and **Turborepo** for build orchestration:
- `spa-guard/` - The spa-guard package family (7 packages)
- `packages/` - Utility packages (react-router, clean-pkg-json, datamitsu-config, fastify-base)
- `website/` - This documentation site (Docusaurus)
Each package is self-contained with its own `package.json`, `tsconfig.json`, `vitest.config.ts`, and `tsup.config.ts`.
## Git Hooks
**lefthook** manages git hooks automatically:
- **pre-commit**: Runs `datamitsu fix` and `datamitsu lint` on staged files
- **commit-msg**: Validates commit messages with commitlint
- **post-checkout**: Runs `pnpm install` when switching branches
## Publishing a Package
1. Update version in `package.json` following [semver](https://semver.org/)
2. Run tests and checks: `pnpm test && pnpm dm check`
3. Build: `pnpm build`
4. Publish: `pnpm publish --access public`
The `@ovineko/clean-pkg-json` tool automatically removes `devDependencies` during the prepack hook and restores them after publishing.
## Working with Documentation
The documentation site uses [Docusaurus](https://docusaurus.io/) and lives in the `website/` directory.
### Running Locally
```bash
pnpm --filter website dev
```
This starts a development server at `http://localhost:3000/` with hot reload. Changes to Markdown files are reflected immediately.
### Building
```bash
pnpm --filter website build
```
The build will fail on broken links (`onBrokenLinks: "throw"` is configured), so always verify the build succeeds before committing.
### Adding a New Documentation Page
1. Create a Markdown file in the appropriate directory under `website/docs/`:
- `spa-guard/` for spa-guard family packages
- `packages/` for utility packages
- `getting-started/` for onboarding guides
- `contributing/` for contributor guides
2. Add YAML frontmatter at the top of the file:
```yaml
---
title: Page Title
sidebar_position: 3
---
```
The `sidebar_position` controls the ordering within its category. Lower numbers appear first.
3. The sidebar is auto-generated per directory (configured in `website/sidebars.ts`), so new files are picked up automatically. No manual sidebar edits are needed.
### Linking Between Pages
Use relative paths for internal links:
```markdown
See the [core package](../spa-guard/core.md) for details.
```
Docusaurus resolves `.md` extensions to the correct URL at build time.
### Images and Static Assets
- Place images in `website/static/img/`
- Reference them in Markdown as `/img/filename.png`
- For diagrams, prefer Mermaid code blocks (rendered natively by Docusaurus):
````markdown
```mermaid
graph TD
A[Request] --> B[Handler]
```
````
### Documentation Update Workflow
When modifying a package's public API or behavior:
1. Update the detailed documentation page in `website/docs/`
2. If the README quick-start example changed, update the package README too
3. Run `pnpm --filter website build` to verify no broken links
4. Commit documentation changes together with the code changes
---
## Guidelines
# Contributing Guidelines
## Before You Start
This is a **personal, opinionated monorepo**. The packages reflect specific workflows and preferences. Pull requests may be declined if they don't align with the project's direction. Forks are encouraged for different approaches.
## How to Contribute
1. **Open an issue first** to discuss the change you'd like to make
2. Fork the repository
3. Create a feature branch from `main`
4. Make your changes following the conventions below
5. Submit a pull request
## Language Policy
**All documentation, READMEs, code comments, commit messages, and identifiers (function/variable/type names) MUST be written in English only. No exceptions.**
Non-English text is allowed only in explicit localization assets (for example `i18n/translations.ts`) and tests that validate localized output.
## Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` new feature
- `fix:` bug fix
- `docs:` documentation changes
- `refactor:` code refactoring
- `test:` adding or updating tests
- `chore:` maintenance tasks
Commit messages are validated by commitlint via a git hook.
## Code Style
Code style is enforced automatically by the toolchain:
- **oxlint** and **ESLint** for linting
- **oxfmt** for formatting
- **lefthook** runs `datamitsu fix` and `datamitsu lint` on staged files before each commit
Run these commands to check your code:
```bash
pnpm dm check # Auto-fix issues, then run all checks
```
## Testing
- Write tests for new features and bug fixes
- Tests use **vitest** with **happy-dom** environment
- Place test files alongside source files (`*.test.ts`, `*.test.tsx`)
- Maintain at least 80% coverage (lines/branches/functions/statements)
- Never commit `.only` or `.skip` in test files
```bash
pnpm test # Run tests once
pnpm test:watch # Watch mode
pnpm test:coverage # With coverage report
```
## Documentation
When implementing features or making changes, documentation **must be updated in the same PR**:
- User-facing features: update pages in `website/docs/`
- API changes: update the corresponding package documentation page
- New patterns: add documentation explaining the approach
Package README files should remain minimal (name, install command, basic usage, link to full docs).
## Dependency Management
- Use `workspace:*` for internal package references
- Use caret (`^`) for peer dependencies
- Use exact versions (no range prefix) for regular dependencies
- Run `pnpm datamitsu fix` after adding dependencies to sync versions
## Pull Request Checklist
Before submitting:
- [ ] Tests pass: `pnpm test`
- [ ] Linter passes: `pnpm lint`
- [ ] Build succeeds: `turbo build`
- [ ] Documentation updated (if applicable)
- [ ] Commit messages follow conventional commits
---
## Installation
## Using Individual Packages
Each Ovineko package is published independently to npm. Install only the packages you need.
### SPA Guard Family
```bash
pnpm add @ovineko/spa-guard
pnpm add @ovineko/spa-guard-react
pnpm add @ovineko/spa-guard-react-router
pnpm add -D @ovineko/spa-guard-vite
pnpm add @ovineko/spa-guard-node
pnpm add @ovineko/spa-guard-fastify
pnpm add -D @ovineko/spa-guard-eslint
```
```bash
npm install @ovineko/spa-guard
npm install @ovineko/spa-guard-react
npm install @ovineko/spa-guard-react-router
npm install --save-dev @ovineko/spa-guard-vite
npm install @ovineko/spa-guard-node
npm install @ovineko/spa-guard-fastify
npm install --save-dev @ovineko/spa-guard-eslint
```
```bash
yarn add @ovineko/spa-guard
yarn add @ovineko/spa-guard-react
yarn add @ovineko/spa-guard-react-router
yarn add -D @ovineko/spa-guard-vite
yarn add @ovineko/spa-guard-node
yarn add @ovineko/spa-guard-fastify
yarn add -D @ovineko/spa-guard-eslint
```
```bash
bun add @ovineko/spa-guard
bun add @ovineko/spa-guard-react
bun add @ovineko/spa-guard-react-router
bun add -d @ovineko/spa-guard-vite
bun add @ovineko/spa-guard-node
bun add @ovineko/spa-guard-fastify
bun add -d @ovineko/spa-guard-eslint
```
```bash
deno add npm:@ovineko/spa-guard
deno add npm:@ovineko/spa-guard-react
deno add npm:@ovineko/spa-guard-react-router
deno add npm:@ovineko/spa-guard-vite
deno add npm:@ovineko/spa-guard-node
deno add npm:@ovineko/spa-guard-fastify
deno add npm:@ovineko/spa-guard-eslint
```
### Utility Packages
```bash
pnpm add @ovineko/react-router
pnpm add -D @ovineko/clean-pkg-json
```
```bash
npm install @ovineko/react-router
npm install --save-dev @ovineko/clean-pkg-json
```
```bash
yarn add @ovineko/react-router
yarn add -D @ovineko/clean-pkg-json
```
```bash
bun add @ovineko/react-router
bun add -d @ovineko/clean-pkg-json
```
```bash
deno add npm:@ovineko/react-router
deno add npm:@ovineko/clean-pkg-json
```
## Peer Dependencies
Most packages have peer dependencies that you need to install separately:
| Package | Peer Dependencies |
| ------------------------------- | ---------------------------------------------------------------------------------------------- |
| @ovineko/spa-guard | None |
| @ovineko/spa-guard-react | `@ovineko/spa-guard`, `react@^19` |
| @ovineko/spa-guard-react-router | `@ovineko/spa-guard`, `@ovineko/spa-guard-react`, `react@^19`, `react-router@^7` |
| @ovineko/spa-guard-vite | `@ovineko/spa-guard`, `vite@^8\|\|^7` |
| @ovineko/spa-guard-node | `@ovineko/spa-guard`, `parse5@^8` |
| @ovineko/spa-guard-fastify | `@ovineko/spa-guard`, `@ovineko/spa-guard-node`, `fastify@^5\|\|^4`, `fastify-plugin@^5\|\|^4` |
| @ovineko/spa-guard-eslint | `eslint@^9\|\|^10` |
| @ovineko/react-router | `react@^19`, `react-router@^7`, `valibot@^1` |
## Developing Locally
To contribute or develop locally, clone the monorepo:
```bash
git clone https://github.com/ovineko/ovineko.git
cd ovineko
pnpm install
```
### Requirements
- **Node.js** >= 24.11.0
- **pnpm** >= 10.25.0
Only pnpm is allowed as the package manager (enforced via `only-allow`).
### Building All Packages
```bash
turbo build
```
### Running Tests
```bash
# All packages
pnpm test
# Specific package
cd packages/react-router
pnpm test
```
---
## Quick Start
## SPA Guard
Add resilient chunk loading and error recovery to your Vite + React SPA in three steps.
### 1. Add the Vite Plugin
```ts title="vite.config.ts"
export default defineConfig({
plugins: [
spaGuardVitePlugin({
// Configuration options
}),
],
});
```
### 2. Use `lazyWithRetry` Instead of `React.lazy`
```tsx
const Dashboard = lazyWithRetry(() => import("./pages/Dashboard"));
```
### 3. Call `recommendedSetup` on Boot
```ts
const cleanup = recommendedSetup();
```
That's it. Your app now automatically retries failed chunk loads with cache busting and shows a fallback UI if all retries fail.
See the [SPA Guard Overview](../spa-guard/overview) for the full documentation.
---
## Type-Safe React Router
Add runtime-validated URL parameters to React Router v7.
### 1. Define a Route with Schema
```tsx
const userRoute = createRouteWithParams("/users/:id", {
params: v.object({ id: v.pipe(v.string(), v.uuid()) }),
searchParams: v.object({
tab: v.optional(v.string()),
}),
});
```
### 2. Use Type-Safe Hooks in Components
```tsx
function UserPage() {
const { id } = userRoute.useParams();
const [{ tab }] = userRoute.useSearchParams();
return (
User {id}, tab: {tab}
);
}
```
Invalid params are caught at runtime and redirect to a configurable error page.
See the [@ovineko/react-router docs](../packages/react-router) for more details.
---
## Clean Package JSON
Zero-config tool to strip `devDependencies` from `package.json` before publishing.
```json title="package.json"
{
"scripts": {
"prepack": "clean-pkg-json clean",
"postpack": "clean-pkg-json restore"
}
}
```
See the [@ovineko/clean-pkg-json docs](../packages/clean-pkg-json) for more details.
---
## Introduction
# Ovineko
**A quiet guardian at the threshold.**
ovineko is a collection of tools born from friction. Every package here exists because once upon a time, weeks were spent debugging something you never wanted to rebuild.
When the solution is found — it stays here. Not in notes, not in an old project, not in your head. Here.
> See [Philosophy](./philosophy) for the full story behind ovineko.
## Packages
### SPA Guard
A family of packages for resilient single-page applications:
| Package | Description | Status |
| ------------------------------- | ------------------------------------------------------------------------ | --------- |
| @ovineko/spa-guard | Core runtime, error handling, schema, i18n for SPAs | Published |
| @ovineko/spa-guard-react | React hooks, components, and error boundaries for spa-guard | Published |
| @ovineko/spa-guard-react-router | React Router v7 error boundary integration for spa-guard | Published |
| @ovineko/spa-guard-vite | Vite plugin and inline scripts for spa-guard | Published |
| @ovineko/spa-guard-node | Server-side HTML cache with ETag/304, pre-compression, and i18n (parse5) | Published |
| @ovineko/spa-guard-fastify | Fastify plugin for spa-guard beacon endpoint and HTML cache handler | Published |
| @ovineko/spa-guard-eslint | ESLint rules for spa-guard (no-direct-lazy, no-direct-error-boundary) | Published |
See the [SPA Guard Overview](./spa-guard/overview) for detailed documentation.
### Utility Packages
| Package | Description | Status |
| ------------------------- | -------------------------------------------------------------------- | -------------- |
| @ovineko/react-router | Type-safe wrapper for React Router v7 with valibot schema validation | Published |
| @ovineko/clean-pkg-json | Zero-config tool to clean package.json before publishing | Published |
| @ovineko/fastify-base | Pre-configured Fastify server with Sentry, Prometheus, OpenTelemetry | In Development |
| @ovineko/datamitsu-config | Internal configuration package for datamitsu tooling | Private |
See the [Packages Overview](./packages/overview) for detailed documentation.
## Requirements
- **Node.js** >= 24.11.0
- **pnpm** >= 10.25.0 (enforced by preinstall script)
Only pnpm is allowed as the package manager (enforced via `only-allow`).
## Technology Stack
### Core Technologies
- **TypeScript** - Type-safe development
- **React 19** - Latest React version for UI components
- **React Router v7** - Modern routing solution
- **Valibot** - Runtime validation for type-safe schemas
- **Fastify** - High-performance web framework
### Build and Development
- **tsup** - Fast TypeScript bundler (uses esbuild)
- **vitest** - Fast unit test framework (Vite-powered)
- **turbo** - Monorepo build orchestration
- **pnpm** - Fast, disk space efficient package manager
### Code Quality
- **datamitsu** - Unified linting orchestrator that runs:
- **oxlint** - Fast Rust-based linter (primary)
- **ESLint** - Additional JavaScript/TypeScript rules
- **Prettier** - Code formatter
- **knip** - Unused exports and dependencies detector
- **commitlint** - Conventional commit message validation
- **syncpack** - Dependency version synchronization
- **gitleaks** - Secret scanning
## Project Structure
```plaintext
ovineko/
├── spa-guard/ # spa-guard package family
│ ├── spa-guard/ # Core: runtime, error handling, schema, i18n
│ ├── react/ # React hooks, components, error boundaries
│ ├── react-router/ # React Router v7 error boundary integration
│ ├── vite/ # Vite plugin and inline scripts
│ ├── node/ # Server-side HTML cache, ETag/304, i18n (parse5)
│ ├── fastify/ # Fastify beacon endpoint plugin and HTML cache handler
│ └── eslint/ # ESLint rules
├── packages/ # Other publishable packages
│ ├── react-router/ # Type-safe React Router v7 wrapper
│ ├── clean-pkg-json/ # Package.json cleanup tool
│ ├── datamitsu-config/ # Shared config for datamitsu tooling
│ └── fastify-base/ # Pre-configured Fastify server
├── website/ # Documentation site (Docusaurus)
├── turbo.json # Turborepo config
├── pnpm-workspace.yaml # pnpm workspace config
├── datamitsu.ts # Linting/formatting config
├── .syncpackrc.json # Dependency version sync rules
└── lefthook.yml # Git hooks configuration
```
## Contributing
This is a personal monorepo reflecting specific workflows and preferences. Pull requests may be declined if they don't align with the project's direction. Forks are encouraged for different approaches.
See the [Contributing Guidelines](./contributing/guidelines) for more details.
---
## @ovineko/clean-pkg-json
Zero-config tool to clean `package.json` before publishing and restore it after.
Removes dev-only fields (`devDependencies`, `scripts`, `eslintConfig`, etc.) keeping only what's needed in the published package.
## Install
```bash
pnpm add -D @ovineko/clean-pkg-json
```
```bash
npm install --save-dev @ovineko/clean-pkg-json
```
```bash
yarn add -D @ovineko/clean-pkg-json
```
```bash
bun add -d @ovineko/clean-pkg-json
```
```bash
deno add npm:@ovineko/clean-pkg-json
```
## Usage
```bash
clean-pkg-json clean # backup + clean
clean-pkg-json restore # restore from backup
```
### With npm lifecycle hooks
```json
{
"scripts": {
"prepack": "clean-pkg-json clean",
"postpack": "clean-pkg-json restore"
}
}
```
## How it works
**`clean`** creates `package.json.backup`, then removes all fields not in the whitelist.
**`restore`** replaces `package.json` from backup and deletes the backup file.
## Whitelisted fields
| Category | Fields |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| Identity | `name`, `version`, `description`, `keywords`, `author`, `license` |
| Entry points | `main`, `module`, `types`, `exports`, `bin` |
| Publishing | `files`, `type`, `engines`, `publishConfig`, `repository`, `bugs`, `homepage`, `sideEffects`, `funding` |
| Dependencies | `dependencies`, `peerDependencies`, `peerDependenciesMeta`, `optionalDependencies`, `bundledDependencies`, `bundleDependencies` |
### Whitelisted scripts
Only npm lifecycle hooks survive: `preinstall`, `install`, `postinstall`.
---
## @ovineko/datamitsu-config
Internal configuration package for datamitsu tooling (linting, formatting, etc.)
:::info
This is a private package used internally within the ovineko monorepo and is not published to npm.
:::
## Purpose
Provides shared configuration files for code quality tools across all packages in the ovineko monorepo. This ensures consistent linting, formatting, and code quality standards throughout the project.
## Included Configurations
### ESLint
JavaScript and TypeScript linting rules with oxlint plugin integration:
- `eslint.config.mjs` — Main ESLint configuration
- `eslint-override-config.mjs` — Project-specific overrides
### Prettier
Code formatting rules for consistent style:
- `prettier.config.js` — Prettier configuration
### Oxlint
Fast Rust-based linting configuration:
- `.oxlintrc.json` — Oxlint rules and settings
## Usage
This package is referenced as a workspace dependency in all other packages within the monorepo:
```json
{
"devDependencies": {
"@ovineko/datamitsu-config": "workspace:*"
}
}
```
Configuration files are automatically used by the datamitsu tooling orchestrator. No manual configuration is needed in individual packages.
## Development
To update configurations:
1. Edit the configuration files in this package
2. Test changes by running `pnpm lint` or `pnpm fix` in any package
3. Commit changes to apply across all packages
---
## @ovineko/fastify-base
Pre-configured Fastify server with Sentry, Prometheus, OpenTelemetry, healthcheck, and other common integrations.
:::warning
This package is currently in development (v0.0.0) and not yet published to npm. The API is subject to change.
:::
## Planned Features
- Sentry integration — Automatic error tracking and performance monitoring
- Prometheus metrics — Built-in metrics endpoint for monitoring and alerting
- OpenTelemetry support — Distributed tracing for microservices
- Health checks — Ready-to-use liveness and readiness endpoints
- Sensible defaults — Production-ready configuration out of the box
- Type-safe — Full TypeScript support with proper typing
## Planned Integrations
### Sentry
Automatic error tracking and performance monitoring for production environments:
- Exception capture with stack traces
- Request context and breadcrumbs
- Performance transaction tracking
- User context and tags
### Prometheus
Built-in metrics collection and exposure:
- HTTP request metrics (latency, status codes, throughput)
- Custom application metrics
- Standard runtime metrics (memory, CPU, event loop)
- Configurable metrics endpoint (default: `/metrics`)
### OpenTelemetry
Distributed tracing support for microservices architecture:
- Automatic span creation for HTTP requests
- Custom span instrumentation
- Context propagation across services
- Integration with tracing backends (Jaeger, Zipkin, etc.)
### Health Checks
Production-ready health check endpoints:
- **Liveness probe** (`/health/live`) — Is the service running?
- **Readiness probe** (`/health/ready`) — Is the service ready to handle traffic?
- Custom health checks (database, cache, external services)
- Graceful shutdown handling
## Planned Usage
```typescript
const server = await createFastifyServer({
sentry: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
},
prometheus: {
enabled: true,
endpoint: "/metrics",
},
opentelemetry: {
enabled: true,
serviceName: "my-service",
},
health: {
liveness: "/health/live",
readiness: "/health/ready",
},
});
// Add your routes
server.get("/api/users", async (request, reply) => {
return { users: [] };
});
await server.listen({ port: 3000, host: "0.0.0.0" });
```
## Development Status
This package is in active development. Check back for the first release.
---
## Overview
# Utility Packages
Standalone utility packages for modern web applications. Each package is independently versioned and published.
| Package | Description |
| ----------------------------------------------- | -------------------------------------------------------------------- |
| [@ovineko/react-router](./react-router) | Type-safe wrapper for React Router v7 with valibot schema validation |
| [@ovineko/clean-pkg-json](./clean-pkg-json) | Zero-config tool to clean package.json before publishing |
| [@ovineko/fastify-base](./fastify-base) | Pre-configured Fastify server with observability integrations |
| [@ovineko/datamitsu-config](./datamitsu-config) | Internal shared configuration for datamitsu tooling |
---
## @ovineko/react-router
Type-safe wrapper for React Router v7 with valibot schema validation, automatic error handling, and typed params.
## Install
```bash
pnpm add @ovineko/react-router react react-router valibot
```
```bash
npm install @ovineko/react-router react react-router valibot
```
```bash
yarn add @ovineko/react-router react react-router valibot
```
```bash
bun add @ovineko/react-router react react-router valibot
```
```bash
deno add npm:@ovineko/react-router npm:react npm:react-router npm:valibot
```
Peer dependencies: `react@^19`, `react-router@^7`, `valibot@^1`.
## Features
- Valibot schema validation — Runtime validation for URL params and search params
- Two hook variants — Normal hooks (auto-redirect on error) and Raw hooks (manual error handling)
- Smart validation — Ignores extra query params and invalid optional params
- Global error handling — Configure fallback redirect URLs globally or per-route
- Conditional redirects — `useRedirect` hook for declarative navigation with infinite loop prevention
- Optional search params helper — `optionalSearchParams` utility to avoid repetitive `v.optional()` calls
- Type-safe — Full TypeScript support with inferred types
- Hash support — Generate paths with hash fragments
## Quick Start
```tsx
const userRoute = createRouteWithParams("/users/:id", {
params: v.object({ id: v.pipe(v.string(), v.uuid()) }),
searchParams: optionalSearchParams({
tab: v.string(),
page: v.pipe(v.string(), v.transform(Number), v.number()),
}),
errorRedirect: "/404",
});
// Normal hooks - auto-redirect on validation error
function UserPage() {
const params = userRoute.useParams(); // Readonly<{ id: string }>
const [searchParams, setSearchParams] = userRoute.useSearchParams();
return (
User {params.id}, Page {searchParams.page ?? 1}
);
}
// Raw hooks - manual error handling
function AdvancedUserPage() {
const [params, error] = userRoute.useParamsRaw();
const { data, error: searchError } = userRoute.useSearchParamsRaw();
if (error) return Invalid user ID;
if (searchError) console.warn("Invalid search params", searchError);
return User {params?.id};
}
```
## Global Error Redirect
Configure a global fallback URL for validation errors:
```tsx
// In your app entry point
setGlobalErrorRedirect("/error");
// Priority: route-level > global > default "/"
const route = createRouteWithParams("/users/:id", {
params: v.object({ id: v.string() }),
errorRedirect: "/404", // Overrides global
});
```
## Validation Behavior
### Path Params
- Always strict validation
- Redirect on any validation error
### Search Params
- **Extra params** (not in schema) — ignored
- **Invalid optional params** — ignored (treated as undefined)
- **Invalid required params** — error (triggers redirect)
```tsx
const route = createRouteWithParams("/search", {
params: v.object({}),
searchParams: v.object({
q: v.string(), // required
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
}),
});
// URL: /search?q=react&page=invalid&debug=true
// Result: { q: "react", page: undefined }
// - page=invalid ignored (optional + invalid)
// - debug=true ignored (not in schema)
```
## API Reference
### Route Creation
#### `createRouteWithParams`
```tsx
const route = createRouteWithParams(pattern, config);
```
**Config:**
- `params` — Valibot schema for URL params (required)
- `searchParams` — Valibot schema for search params (optional)
- `errorRedirect` — Redirect URL on validation error (optional)
**Returns:**
- `path(params, searchParams?, hash?)` — Generate URL path
- `parseURLParams(url)` — Parse and validate URL params
- `Link` — Type-safe Link component
- `useParams()` — Get validated params (auto-redirect on error)
- `useParamsRaw()` — Get params with error info `[data, error]`
- `useSearchParams()` — Get validated search params (auto-redirect on error)
- `useSearchParamsRaw()` — Get search params with error info `{data, error, setter}`
- `pattern` — Original route pattern
#### `createRouteWithoutParams`
```tsx
const route = createRouteWithoutParams(pattern, config?);
```
**Config:**
- `searchParams` — Valibot schema for search params
- `errorRedirect` — Redirect URL on validation error
**Returns:**
- `path(searchParams?, hash?)` — Generate URL path
- `Link` — Type-safe Link component
- `useSearchParams()` — Get validated search params
- `useSearchParamsRaw()` — Get search params with error info
- `pattern` — Original route pattern
### Examples
#### Path Generation
```tsx
const userRoute = createRouteWithParams("/users/:id", {
params: v.object({ id: v.string() }),
searchParams: v.object({
tab: v.optional(v.string()),
}),
});
userRoute.path({ id: "42" });
// "/users/42"
userRoute.path({ id: "42" }, { tab: "settings" });
// "/users/42?tab=settings"
userRoute.path({ id: "42" }, { tab: "settings" }, "profile");
// "/users/42?tab=settings#profile"
```
#### Type-safe Links
```tsx
View Profile
// Automatically includes prevPath in navigation state
```
#### Parse URL Params
```tsx
const params = userRoute.parseURLParams("https://example.com/users/42");
// { id: "42" }
// Throws URLParseError on invalid URL or validation error
```
#### Update Search Params
```tsx
const [searchParams, setSearchParams] = route.useSearchParams();
// Set new params
setSearchParams({ q: "react", page: 1 });
// Update based on previous
setSearchParams((prev) => ({ ...prev, page: prev.page + 1 }));
// With navigation options
setSearchParams({ q: "vue" }, { replace: true });
```
### Utilities
#### `useRedirect(path, condition, replace?)`
Declarative hook for conditional redirects with built-in infinite loop prevention.
**Parameters:**
- `path: string` — Target redirect URL
- `condition: boolean` — Whether to trigger the redirect
- `replace?: boolean` — Use replace instead of push (default: `true`)
**Features:**
- Prevents infinite redirect loops using `useRef` tracking
- Only redirects once when condition becomes `true`
- Resets automatically when condition becomes `false`
- Uses `replace: true` by default to prevent back-button issues
```tsx
function ProtectedPage() {
const { isAuthenticated, isLoading } = useAuth();
// Redirect to login if not authenticated
useRedirect("/login", !isAuthenticated && !isLoading);
if (isLoading) return ;
return Protected Content;
}
// Advanced usage with custom options
function UserProfile() {
const { user, error } = useUser();
// Redirect without replacing history
useRedirect("/users", !user && !error, false);
return {user?.name};
}
```
#### `GuardedRoute`
Declarative route guard wrapper for React Router v7 route configs. Makes route protection visible at the routing config level instead of hidden inside page components.
**Props:**
- `useGuard: () => GuardResult` — A React hook that returns guard state
- `loadingFallback?: React.ReactNode` — Optional loading UI (blocks child rendering)
**GuardResult interface:**
```tsx
interface GuardResult {
allowed: boolean; // Whether the user is allowed to access this route
isLoading: boolean; // Whether the guard data is still loading
redirectTo: string; // Where to redirect when !allowed && !isLoading
}
```
**Behavior:**
- **Default (no `loadingFallback`):** Renders `` immediately while `isLoading` is true. This allows the child page's lazy import to start in parallel with the guard's data fetching.
- **With `loadingFallback`:** Renders the fallback instead of `` while `isLoading` is true. Use this when you need to block child rendering until the guard resolves.
- **Redirect:** When `isLoading` is false and `allowed` is false, redirects to `redirectTo` using `replace` (guards are access checks, not navigation).
**Example — Extracting Guard from Data Hook:**
```tsx
// Guard hook — extracted, visible in route config
function useOrderGuard(): GuardResult {
const { orderId } = useParams<{ orderId: string }>();
const { data, isLoading } = useSWR(`/api/orders/${orderId}`);
return {
allowed: Boolean(data?.order),
isLoading,
redirectTo: "/orders",
};
}
// Data hook — pure, no redirects
function useOrderData() {
const { orderId } = useParams<{ orderId: string }>();
const { data, isLoading } = useSWR(`/api/orders/${orderId}`);
return { order: data?.order, isLoading };
}
function Checkout() {
const { order, isLoading } = useOrderData();
if (isLoading) return ;
return Checkout for order {order.id};
}
// Route config — protection is visible and declarative
const routes: RouteObject[] = [
{
element: ,
children: [{ path: "orders/:orderId/checkout", element: }],
},
];
```
**Example — With `loadingFallback`:**
```tsx
const protectedRoutes: RouteObject[] = [
{
element: } />,
children: [{ path: "dashboard", element: }],
},
];
```
**Comparison with `useRedirect`:**
- `useRedirect` — Hook-level conditional redirect (used inside components)
- `GuardedRoute` — Config-level declarative guard (used in route definitions)
Both complement each other: `GuardedRoute` makes route protection explicit in the config, while `useRedirect` handles component-level conditional navigation.
#### `optionalSearchParams(entries)`
Utility to make all search param fields optional automatically, avoiding repetitive `v.optional()` calls.
```tsx
// Before: Manual v.optional() for each field
const route = createRouteWithParams("/search", {
params: v.object({ id: v.string() }),
searchParams: v.object({
q: v.optional(v.string()),
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
sort: v.optional(v.string()),
filter: v.optional(v.string()),
}),
});
// After: Clean and concise
const route = createRouteWithParams("/search", {
params: v.object({ id: v.string() }),
searchParams: optionalSearchParams({
q: v.string(),
page: v.pipe(v.string(), v.transform(Number), v.number()),
sort: v.string(),
filter: v.string(),
}),
});
// All fields are automatically optional!
const [searchParams] = route.useSearchParams();
// Type: Readonly<{ q?: string; page?: number; sort?: string; filter?: string }>
```
#### `setGlobalErrorRedirect(url)`
Set global fallback redirect URL for validation errors.
```tsx
setGlobalErrorRedirect("/error");
```
#### `replaceState(state)`
Update browser history state without navigation.
```tsx
replaceState({ scrollTop: window.scrollY });
```
#### `URLParseError`
Error class thrown when URL parsing fails.
```tsx
try {
route.parseURLParams("invalid-url");
} catch (error) {
if (error instanceof URLParseError) {
console.log(error.context); // { pattern, url, ... }
}
}
```
## TypeScript
All types are automatically inferred from valibot schemas:
```tsx
const route = createRouteWithParams("/users/:id", {
params: v.object({ id: v.string() }),
searchParams: v.object({
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
}),
});
// Inferred types:
const params = route.useParams(); // Readonly<{ id: string }>
const [searchParams] = route.useSearchParams(); // Readonly<{ page?: number }>
```
---
## Philosophy
## A quiet guardian at the threshold
There's a particular kind of pain — when you realize you're solving a problem you've already solved. Not just similar. The exact same one. And once again you spend days trying to remember the nuances that once came with difficulty.
How to properly set up Fastify with Prometheus on a separate port, with a healthcheck that doesn't turn green until you allow it, with tracing that doesn't break types, with Swagger generated from the same types that validate requests. It's not complicated. But there are dozens of small decisions, each of which needs to be made anew — if you didn't write it down.
Or chunk load errors in SPAs. Seems simple — just reload the page. But behind this stands: when to reload, how to bypass cache, how many times to retry, what to show the user during attempts, where to send error reports, how not to spam reports during normal retry. Months of production observations before all this crystallizes into a working solution.
**ovineko exists so these solutions don't get lost.**
Not in notes you won't find. Not in an old project that no longer runs. Not in your head that forgets. Here — as code that works, is tested, and connects with one line.
## Why a monorepo?
The monorepo isn't an architectural decision. It's a psychological trick.
When all libraries live together in one repository, they're constantly visible. Updating dependencies — you see them all. Starting a new project — they're already nearby. Whether you want to or not — you maintain them.
The tools here are opinionated intentionally. Not because other opinions don't matter — but because the goal isn't a universal solution. The goal is to remove the distance between "I want to try an idea" and "it already works as needed." When everything is configured the way you need it, with one function — you can think about the idea, not the configuration.
## The name
The name reflects this in three words from different languages.
**Ovi** is both _ovis_ (sheep/ram in Latin — hence the horns in the logo) and _ovi_ (door in Finnish). A ram is stubborn, steadfast, immovable. A door is the threshold between intention and implementation, between "I want to try" and "it already works."
**Neko** is cat in Japanese. A creature that chooses a place and stays. Doesn't leave when it gets difficult, doesn't disappear when it stops being interesting.
Together — a stubborn ram-cat in a doorway. Not going anywhere.
_A quiet guardian at the threshold. It doesn't move, it doesn't disappear. It simply stays._
## Not for everyone
This is a personal monorepo reflecting specific workflows and preferences. The packages here aren't designed to please everyone.
If these opinions align with your needs — great! If not, these packages might not be the right fit, and that's perfectly fine. Forks are encouraged for different approaches.
The goal is to consolidate proven solutions, maintain consistency, and iterate faster. Update once, benefit everywhere.
---
## Core (@ovineko/spa-guard)
# @ovineko/spa-guard
Core runtime for spa-guard — chunk load error handling, version checking, spinner, i18n, and event schema for SPAs.
## Install
```bash
pnpm add @ovineko/spa-guard
```
```bash
npm install @ovineko/spa-guard
```
```bash
yarn add @ovineko/spa-guard
```
```bash
bun add @ovineko/spa-guard
```
```bash
deno add npm:@ovineko/spa-guard
```
## Usage
Call `recommendedSetup` once when your app boots. It dismisses the loading spinner, starts version checking, and returns a cleanup function.
```ts
const cleanup = recommendedSetup();
// optionally call cleanup() to stop background checks
```
`recommendedSetup` is idempotent. Repeated calls return the same cleanup function and do not re-run setup side effects.
By default, `recommendedSetup` uses `healthyBoot: "auto"`:
- if retry params exist in the URL, it waits a dynamic grace period and calls `markRetryHealthyBoot()` only when the orchestrator phase is still `idle`.
- if no retry params exist, it does nothing.
Auto grace period formula:
- `max(5000, max(reloadDelays)+1000, sum(lazyRetry.retryDelays)+1000)`
If you want strict control, switch to manual healthy-boot mode and call `markRetryHealthyBoot()` yourself.
```ts
recommendedSetup();
await Promise.all([import("./routes/Home"), import("./routes/Checkout")]);
markRetryHealthyBoot();
```
Disable auto healthy boot:
```ts
recommendedSetup({ healthyBoot: "manual" });
```
Disable version checking:
```ts
const cleanup = recommendedSetup({ versionCheck: false });
```
## Retry behavior and event flow
spa-guard uses a single retry orchestrator (`retryOrchestrator.ts`) as the sole owner of retry lifecycle. All reload scheduling, deduplication, and fallback transitions run through `triggerRetry()`.
### Retry state machine
The orchestrator maintains an explicit phase:
- `idle` — no retry in progress
- `scheduled` — a reload has been scheduled (timer running); concurrent triggers are deduplicated
- `fallback` — retries exhausted, fallback UI is shown; further triggers are ignored
### Retry progression
When a recoverable error occurs (chunk load error, unhandled rejection, `vite:preloadError`, or static asset 404):
1. Event listener classifies the event and calls `triggerRetry({ source, error })`.
2. Orchestrator checks phase — if already `scheduled` or `fallback`, returns immediately without scheduling another reload.
3. On first trigger: reads `RETRY_ATTEMPT_PARAM` from URL to restore attempt count after a reload.
4. If attempts remain: increments attempt, calls `showLoadingUI(nextAttempt)` to render the loading UI immediately (before the timer fires), sets a timer for `reloadDelays[currentAttempt]`, encodes `retryId` and attempt count into the reload URL, then navigates. Requires `options.html.loading.content` to be configured; if absent, `showLoadingUI` returns silently and the retry still proceeds.
5. If attempts are exhausted: transitions to `fallback`, calls `setFallbackMode()`, sends a beacon, and renders fallback UI.
### URL params
The orchestrator serializes state into URL params for cross-reload continuity:
- `RETRY_ATTEMPT_PARAM` — current attempt count (strict non-negative integer; malformed values are ignored)
- `RETRY_ID_PARAM` — unique ID for this retry session
- `CACHE_BUST_PARAM` — timestamp for cache busting (set when `cacheBust: true` is passed, e.g. for static asset errors)
### Loading UI during retry delay
When `options.html.loading.content` is configured, `showLoadingUI(attempt)` is called before the reload timer fires. It injects the loading HTML into the target element (selector from `options.html.fallback.selector`, defaulting to `body`), reveals the `data-spa-guard-section="retrying"` element, fills attempt numbers into `[data-spa-guard-content="attempt"]` elements, applies i18n via `applyI18n`/`getI18n`, and optionally hides or replaces the spinner based on `options.html.spinner`. If loading content is not configured or the target element is not found, `showLoadingUI` returns silently — the retry still proceeds normally.
Default fallback/loading templates are theme-aware: they set `color-scheme: light dark` and apply neutral dark colors automatically via `@media (prefers-color-scheme: dark)`.
### Unhandledrejection serialization
`serializeError` handles `PromiseRejectionEvent` with strict redaction guardrails. For HTTP-like errors it extracts only safe metadata: `status`, `statusText`, `url`, `method`, `response.type`, and `X-Request-ID` from response headers when present. Response body, request/response payload, and the full headers object are **never** included in serialized output. For request wrappers (`reason.request` / `reason.config`) only `method`, `url`, and `baseURL` are extracted. Deep object traversal is bounded by `MAX_DEPTH=4`, `MAX_KEYS=20`, and `MAX_STRING_LEN=500` to prevent oversized beacons. Circular references are handled via a `WeakSet` visited tracker. The output also includes `isTrusted`, `timeStamp` from the event, and runtime context (`pageUrl`, `constructorName`).
This HTTP-like extraction also applies to `Error` subclasses that carry a `response` field (for example `ResponseError`), not only to plain objects.
### Retry reset
If enough time has passed since the last reload (configurable via `minTimeBetweenResets`, default 5000 ms), the orchestrator resets the attempt counter and starts a fresh retry cycle instead of continuing to fallback. This prevents stale URL params from triggering fallback on a clean page load.
### Healthy boot
After a successful app boot following a retry reload, `markRetryHealthyBoot()` clears retry URL params, cancels any pending timer, and resets orchestrator state.
Default behavior in `recommendedSetup`: auto healthy-boot after a grace period (`healthyBoot: "auto"`).
Manual override: set `healthyBoot: "manual"` and call `markRetryHealthyBoot()` yourself once critical boot is confirmed.
### Retry on unhandled rejections
By default, regular `unhandledrejection` events only send a beacon (`handleUnhandledRejections.retry: false`). To also trigger `triggerRetry()` on non-chunk rejections, enable it explicitly — but only if you're confident these rejections indicate stale chunks:
```ts
window.__SPA_GUARD_OPTIONS__ = {
handleUnhandledRejections: {
retry: true,
},
};
```
Note: enabling `retry` for generic rejections can cause reload loops if your app has non-chunk unhandled rejections unrelated to stale assets.
Resource failures produce `resource-error` beacons for code resources by default: scripts,
stylesheets, module preloads, and code-bearing preloads or prefetches. Font, image, icon, and other
non-code failures are excluded. Worker and document preload destinations count as code. When a
link's kind is not recognized, its URL extension is used (`.js`, `.mjs`, `.cjs`, and `.css` are
treated as code). Set `resources.report` to `"all"` to report every resource element or `"none"` to
disable resource beacons.
Beacon deduplication and a 20-report per-page cap are enabled by default. `dedupeWindowMs` controls
how long a report with the same stable identifying fields is suppressed; the default is five
minutes. Serialized diagnostics are not part of the dedupe key because they can contain volatile
event data such as timestamps. The retained dedupe keys are bounded. Set `dedupe` to `false` to
disable deduplication. `maxPerPageLoad` accepts non-negative values; `0` or `Infinity` disables the
cap, while a negative value uses the default safety cap of 20.
When the cap is reached, one `beacon-throttled` notice is sent without a stale interim total.
A final report with the total suppressed during the page load is attempted on `pagehide`, with
`visibilitychange` as a fallback. Exhausted-retry reports bypass this cap.
### Known browser noise
Known browser-noise filtering is enabled by default. Set `errors.ignoreKnownNoise` to `false` to
disable it. User-provided `errors.ignore` patterns are added to the built-in list rather than
replacing it. The list is exported as `KNOWN_BROWSER_NOISE`.
It contains only the ResizeObserver loop messages and non-error rejection noise. Cross-origin
`"Script error."` messages are intentionally not in that default list; add the exported
`CROSS_ORIGIN_ERROR` pattern to `errors.ignore` only when that signal is unwanted.
### Retry source and resource status
The exhausted-retry beacon retains the historical `eventName: "chunk_error_max_reloads"`.
Its `errorType` identifies the retry source, and the same value is included in `serialized`.
Resource failure beacons populate `httpStatus` from Resource Timing when the browser provides it.
The browser-reported value can be `0` when an HTTP status is unavailable or opaque, so it should
not be interpreted as a specific HTTP response. When the API or status field is unavailable,
`httpStatus` is omitted; the context records a no-byte signal when that signal is available.
Before transport, all beacon strings are capped to the limits enforced by `parseBeacon`, and
non-finite numeric fields are omitted. This prevents an otherwise valid report from being rejected
as a whole because one client-derived field is too long or invalid.
### Static asset burst coalescing
Multiple 404'd asset errors within a short window (default 500 ms) are coalesced into a single `triggerRetry({ cacheBust: true, source: "static-asset-error" })`. The orchestrator's deduplication ensures only one reload is scheduled.
### Retry ownership rule
Only `retryOrchestrator.ts` may schedule reloads, advance retry state, or transition to fallback. Listeners, renderers, and other modules must not schedule their own retry timers or set fallback state directly.
## API
### `@ovineko/spa-guard` (common)
- `listen` — subscribe to spa-guard events
- `events` — event type constants
- `options` — runtime options helpers
- `disableDefaultRetry` / `enableDefaultRetry` / `isDefaultRetryEnabled` — control default retry behaviour
- `isInFallbackMode` — returns `true` when fallback UI is active (retries exhausted)
- `resetFallbackMode` — clears the fallback flag; use in tests or programmatic recovery flows
- `BeaconError` — error class for beacon failures
- `ForceRetryError` — error class to force a retry
- `stripForceRetryMagic(message)` — remove the internal force-retry marker before rendering or
persisting an error message
- `isCodeResource(target)` — classify a script or link element for `resources.report: "code"`
- `resetBeaconThrottle()` — clear dedupe/cap state and lifecycle listeners; intended for tests or
an explicit host-managed page lifecycle
- `KNOWN_BROWSER_NOISE` / `CROSS_ORIGIN_ERROR` — reusable error-message filter patterns
**Retry orchestrator (single owner of retry lifecycle):**
- `triggerRetry(input?)` — trigger a retry from any source; returns `TriggerResult`:
- `{ status: "accepted" }` — reload scheduled
- `{ status: "deduped", reason: string }` — ignored because another retry is already scheduled
- `{ status: "fallback" }` — already in fallback mode; no retry scheduled
- `{ status: "internal-error" }` — retry setup failed and the orchestrator returned to `idle`
- `{ status: "retry-disabled" }` — retry is disabled via `disableDefaultRetry()`
- `markRetryHealthyBoot()` — call after a successful boot following a retry reload; clears URL params, cancels timers, resets orchestrator state and fallback flag
- `getRetrySnapshot()` — returns current orchestrator state: `{ phase, attempt, retryId, lastSource, lastTriggerTime }`
### `@ovineko/spa-guard/runtime`
- `recommendedSetup(options?)` — enable recommended runtime features; idempotent and returns cleanup function
- `RecommendedSetupOptions` — `{ versionCheck?: boolean; healthyBoot?: "auto" | "manual" | "off" | false | { mode?: "auto"; graceMs?: number } }`
- `healthyBoot.graceMs` acts as a lower bound override for auto mode (`max(computedGraceMs, graceMs)`)
- `startVersionCheck` / `stopVersionCheck` — manual version check control
- `getState` / `subscribeToState` — runtime state access
- `SpaGuardState` — state type
- `showSpinner` / `dismissSpinner` / `getSpinnerHtml` — spinner helpers
- `setTranslations` — override i18n strings
- `ForceRetryError`
### `@ovineko/spa-guard/schema`
Schemas for spa-guard configuration.
### `@ovineko/spa-guard/i18n`
Built-in translation strings.
### `@ovineko/spa-guard/runtime/debug`
Debug helpers for testing error scenarios. Use `createDebugger()` to mount an in-page panel with buttons for each scenario.
Available error scenarios:
- `dispatchChunkLoadError` — unhandled ChunkLoadError rejection
- `dispatchNetworkTimeout` — unhandled network timeout after a delay
- `dispatchSyncRuntimeError` — sync error thrown during React render
- `dispatchAsyncRuntimeError` — uncaught error via setTimeout
- `dispatchFinallyError` — unhandled rejection from Promise.finally
- `dispatchForceRetryError` — ForceRetryError to exercise the force-retry path
- `dispatchUnhandledRejection` — generic unhandled promise rejection
- `dispatchRetryExhausted` — emits exhaustion and uses the debug transition to render fallback UI
with orchestrator phase `fallback`
- `dispatchStaticAsset404` — appends a hashed `', ...]
```
## API
- `buildSpaGuardScript(options?)` — builds the inline runtime script; returns `BuildScriptResult`
- `buildExternalScript(options)` — writes a content-hashed script file to `outDir`; returns `BuildExternalScriptResult`
- `BuildScriptOptions` — options for `buildSpaGuardScript` (extends core `Options`, adds `trace?: boolean`)
- `BuildExternalScriptOptions` — options for `buildExternalScript` (extends `BuildScriptOptions`, adds `outDir: string` and `publicPath?: string`)
- `BuildScriptResult` — `{ scriptContent, hash, html: string[], tags: HtmlTagDescriptor[] }`
- `BuildExternalScriptResult` — `{ fileName, publicUrl, html: string[], tags: HtmlTagDescriptor[] }`
- `HtmlTagDescriptor` — structured tag object `{ tag, attrs?, children?, injectTo }` for framework injection
- `createHtmlCache(options)` — builds a cache with gzip/brotli/zstd variants for all languages; returns an `HtmlCache` with a `get()` method
- `createHTMLCacheStore(input, languages?)` — manages multiple named caches; call `load()` once then `getCache(key)`
- `patchHtmlI18n(options)` — injects `` and updates `` for server-side rendering
- `matchLang(input, available?)` — resolves an Accept-Language value to a supported language code
- `translations` — built-in translation map (38 languages including en, ko, ja, zh, fr, de, es, pt, ru, ar, and more)
## Related packages
- [@ovineko/spa-guard](./core) — core runtime (install in your app entry)
- [@ovineko/spa-guard-vite](./vite) — Vite plugin that injects the runtime at build time
- [@ovineko/spa-guard-fastify](./fastify) — Fastify plugin built on this package
- [@ovineko/spa-guard-react](./react) — `lazyWithRetry` and React error boundary
- [@ovineko/spa-guard-react-router](./react-router) — React Router error boundary
- [@ovineko/spa-guard-eslint](./eslint) — ESLint rules
---
## Overview(Spa-guard)
# SPA Guard
A family of packages for resilient single-page applications with automatic retry, cache busting, and error reporting.
## What is SPA Guard?
SPA Guard handles the "white screen of death" that occurs when your SPA's JavaScript chunks fail to load — whether from network issues, deployment cache invalidation, CDN problems, or static asset 404s. It automatically retries loading, shows user-friendly fallback UI, and reports errors via beacons.
## Package Family
The spa-guard functionality is split into 7 separate packages, each with a focused responsibility:
| Package | Description | Peer Dependencies |
| ------------------------------------------------- | ----------------------------------------------------------- | -------------------------- |
| [@ovineko/spa-guard](./core) | Core runtime, error handling, schema, i18n | None |
| [@ovineko/spa-guard-react](./react) | React hooks, components, error boundaries | react@^19 |
| [@ovineko/spa-guard-react-router](./react-router) | React Router v7 error boundary integration | react@^19, react-router@^7 |
| [@ovineko/spa-guard-vite](./vite) | Vite plugin (injects runtime script at build time) | vite@^8\|\|^7 |
| [@ovineko/spa-guard-node](./node) | Server-side HTML cache with ETag/304, pre-compression, i18n | parse5@^8 |
| [@ovineko/spa-guard-fastify](./fastify) | Fastify beacon endpoint plugin and HTML cache handler | fastify@^5\|\|^4 |
| [@ovineko/spa-guard-eslint](./eslint) | ESLint rules for enforcing spa-guard patterns | eslint@^9\|\|^10 |
## Architecture
Configuration flows from the Vite plugin at build time into the runtime:
```mermaid
graph TD
subgraph Build[Build Time]
Plugin[spaGuardVitePlugin]
Plugin -->|Injects config| Options[window.__SPA_GUARD_OPTIONS__]
end
subgraph Runtime[Runtime]
Options --> Core[Core Runtime]
Core --> Listeners[Event Listeners]
Core --> Orchestrator[Retry Orchestrator]
Core --> VersionChecker[Version Checker]
Listeners -->|Classifies errors| Orchestrator
Orchestrator -->|Schedules reloads| LoadingUI[Loading UI]
Orchestrator -->|Exhausted retries| FallbackUI[Fallback UI]
Orchestrator -->|Reports errors| Beacon[Beacon]
end
Beacon -->|HTTP POST| FastifyPlugin[Fastify Plugin]
```
### Two-Level Retry Strategy
1. **Module-level retry** (`lazyWithRetry` in spa-guard-react): Retries the individual `import()` call with configurable delays before the component fails.
2. **Page-level retry** (`retryOrchestrator` in core): If module retry fails, schedules a full page reload with cache busting.
### Retry State Machine
The orchestrator maintains an explicit phase:
- **idle** — no retry in progress
- **scheduled** — a reload has been scheduled; concurrent triggers are deduplicated
- **fallback** — retries exhausted, fallback UI is shown; further triggers are ignored
## Quick Start
### 1. Install packages
```bash
pnpm add @ovineko/spa-guard @ovineko/spa-guard-react
pnpm add -D @ovineko/spa-guard-vite
```
```bash
npm install @ovineko/spa-guard @ovineko/spa-guard-react
npm install --save-dev @ovineko/spa-guard-vite
```
```bash
yarn add @ovineko/spa-guard @ovineko/spa-guard-react
yarn add -D @ovineko/spa-guard-vite
```
```bash
bun add @ovineko/spa-guard @ovineko/spa-guard-react
bun add -d @ovineko/spa-guard-vite
```
```bash
deno add npm:@ovineko/spa-guard npm:@ovineko/spa-guard-react npm:@ovineko/spa-guard-vite
```
### 2. Add the Vite plugin
```ts
export default defineConfig({
plugins: [spaGuardVitePlugin()],
});
```
### 3. Use lazyWithRetry in your React app
```tsx
const LazyHome = lazyWithRetry(() => import("./pages/Home"));
export function App() {
return (
);
}
```
### 4. Call recommendedSetup
```ts
const cleanup = recommendedSetup();
```
See individual package pages for detailed API documentation and configuration options.
---
## React Router (@ovineko/spa-guard-react-router)
# @ovineko/spa-guard-react-router
React Router v7 error boundary integration for [spa-guard](./core).
Catches route errors via `useRouteError()` and automatically retries chunk loading failures with spa-guard integration.
React Router can surface a stale lazy-route manifest as an engine-specific access to an undefined
module's `default` export instead of a standard dynamic-import failure. The boundary treats the
Chromium, WebKit, and Firefox forms of that error as retryable chunk failures. Set
`autoRetryChunkErrors={false}` to disable both standard chunk retries and this lazy-route recovery.
## Install
```bash
pnpm add @ovineko/spa-guard-react-router @ovineko/spa-guard @ovineko/spa-guard-react react react-router
```
```bash
npm install @ovineko/spa-guard-react-router @ovineko/spa-guard @ovineko/spa-guard-react react react-router
```
```bash
yarn add @ovineko/spa-guard-react-router @ovineko/spa-guard @ovineko/spa-guard-react react react-router
```
```bash
bun add @ovineko/spa-guard-react-router @ovineko/spa-guard @ovineko/spa-guard-react react react-router
```
```bash
deno add npm:@ovineko/spa-guard-react-router npm:@ovineko/spa-guard npm:@ovineko/spa-guard-react npm:react npm:react-router
```
## Usage
Register `ErrorBoundaryReactRouter` as the `errorElement` (or `ErrorBoundary`) for your routes:
```tsx
const router = createBrowserRouter([
{
path: "/",
element: ,
ErrorBoundary: ErrorBoundaryReactRouter,
},
]);
export function Root() {
return ;
}
```
### Custom fallback
```tsx
ErrorBoundaryReactRouter,
type RouterFallbackProps,
} from "@ovineko/spa-guard-react-router";
function MyFallback({ error, isChunkError, isRetrying }: RouterFallbackProps) {
if (isRetrying) return Reloading...;
if (isChunkError) return Failed to load module — retrying…;
return Something went wrong: {String(error)};
}
// In your route definition:
;
```
## API
From `@ovineko/spa-guard-react-router`:
- `ErrorBoundaryReactRouter` — error boundary component for React Router routes
- `ErrorBoundaryReactRouterProps` — props for `ErrorBoundaryReactRouter`
- `autoRetryChunkErrors?` — retry chunk errors automatically (default: `true`)
- `fallback?` — custom fallback component or render function
- `onError?` — callback invoked on any route error
- `sendBeacon?` — send beacon on error (default: `true`)
- `RouterFallbackProps` — props passed to the fallback component
- `error` — the route error caught by `useRouteError()`
- `isChunkError` — whether the error is a chunk load failure
- `isRetrying` — whether spa-guard is currently retrying
- `spaGuardState` — current spa-guard state
## Related Packages
- [@ovineko/spa-guard](./core) — core package
- [@ovineko/spa-guard-react](./react) — React hooks and error boundary
- [@ovineko/spa-guard-vite](./vite) — Vite plugin
---
## React (@ovineko/spa-guard-react)
# @ovineko/spa-guard-react
React hooks, components, and error boundaries for [spa-guard](./core).
## Install
```bash
pnpm add @ovineko/spa-guard-react @ovineko/spa-guard react
```
```bash
npm install @ovineko/spa-guard-react @ovineko/spa-guard react
```
```bash
yarn add @ovineko/spa-guard-react @ovineko/spa-guard react
```
```bash
bun add @ovineko/spa-guard-react @ovineko/spa-guard react
```
```bash
deno add npm:@ovineko/spa-guard-react npm:@ovineko/spa-guard npm:react
```
## Usage
### lazyWithRetry
Wrap `React.lazy` to automatically retry chunk load failures with configurable delays.
```tsx
const LazyHome = lazyWithRetry(() => import("./pages/Home"));
// Override retry delays for a specific route
const LazyCheckout = lazyWithRetry(() => import("./pages/Checkout"), {
retryDelays: [500, 1000, 2000, 4000],
});
export function App() {
return (
);
}
```
### ErrorBoundary
Catches errors in child components and integrates with spa-guard retry state.
```tsx
export function App() {
return (
);
}
```
## API
From `@ovineko/spa-guard-react`:
- `lazyWithRetry(importFn, options?)` — lazy component with automatic retry
- `useSpaGuardState()` — reactive hook for current spa-guard state
- `useSPAGuardChunkError()` — hook to detect chunk load errors
- `useSPAGuardEvents()` — hook to subscribe to spa-guard events
- `DefaultErrorFallback` — default fallback UI component
- `Spinner` — loading spinner component
- `DebugSyncErrorTrigger` — trigger sync errors for testing
- `ForceRetryError` — error class to force a retry
- `LazyRetryOptions` — options type for `lazyWithRetry`
- `SpaGuardState` — spa-guard state type
From `@ovineko/spa-guard-react/error-boundary`:
- `ErrorBoundary` — error boundary with spa-guard integration
- `ErrorBoundaryProps` — props for `ErrorBoundary`
- `FallbackProps` — props passed to fallback component
## Related Packages
- [@ovineko/spa-guard](./core) — core package
- [@ovineko/spa-guard-react-router](./react-router) — React Router integration
- [@ovineko/spa-guard-vite](./vite) — Vite plugin
---
## Why Server-Side HTML Cache?
When JavaScript fails to load, who renders the fallback UI? This is the bootstrapping problem — and it's why `@ovineko/spa-guard-node` and `@ovineko/spa-guard-fastify` exist.
## The bootstrapping problem
A single-page application is a JavaScript application. Everything visible on screen — including error messages and fallback UI — is rendered by JavaScript. When a chunk load error occurs and the retry orchestrator exhausts its attempts, SPA Guard needs to show the user something useful.
But here's the catch: the JavaScript that would render that fallback UI might be the very thing that failed to load.
SPA Guard solves this by generating the fallback HTML at the orchestrator level, before React is involved. The fallback UI is injected directly into the DOM via `innerHTML` — no React, no virtual DOM, no bundled components. The templates are embedded in the core bundle at build time and rendered client-side.
This works well for the initial fallback render. But it has a constraint: the fallback HTML is static. Once the user's browser has it, there's no way to update it without a new deployment. This matters when:
- You have users in multiple languages
- Your fallback UI text changes across releases
- You want the fallback to reflect the current deployment's version
## Build-time embedding vs server-side cache
**Build-time embedding** means the fallback HTML is hardcoded into `index.html` at build time via the Vite plugin. It's simple — no server required. The limitations:
- Language is fixed at build time (one language per build, or one default)
- Updating fallback text requires a new deployment
- No content negotiation based on the user's `Accept-Language` header
**Server-side HTML cache** means the server generates the HTML response from the current `dist/index.html` at startup. The server holds the live version of the HTML and serves it with full HTTP semantics. The advantages:
- Language negotiation on every request (`Accept-Language` → patched i18n meta tag)
- ETag-based caching: browsers cache the response, but revalidate after each deployment
- Pre-compression: the server pre-generates gzip, brotli, and zstd variants at startup
- No stale fallback text: the HTML reflects the current deployment automatically
## How `createHtmlCache` works
`createHtmlCache` (from `@ovineko/spa-guard-node`) is the core of the server-side approach. At startup:
1. **Reads `dist/index.html`** — the built SPA entry point, which already contains the fallback HTML template injected by the Vite plugin
2. **Extracts the version** — reads `__SPA_GUARD_VERSION__` from the HTML to use as the ETag value
3. **Generates language variants** — for each configured locale, calls `patchHtmlI18n` to inject translated strings into a `` tag in ``
4. **Pre-compresses each variant** — produces gzip, brotli, and zstd buffers for each language variant at startup, so no compression work happens at request time
5. **Serves with ETag/304** — on each request, checks `If-None-Match`; if the ETag matches (same deployment), returns 304 with no body
```mermaid
sequenceDiagram
participant Browser
participant Fastify
participant HtmlCache
Note over HtmlCache: Startup: read dist/index.html,generate i18n variants,pre-compress all
Browser->>Fastify: GET / (Accept-Language: fr, Accept-Encoding: br)
Fastify->>HtmlCache: get({ language: "fr", encoding: "br" })
HtmlCache-->>Fastify: { statusCode: 200, body: , etag: "v1.2.3" }
Fastify-->>Browser: 200 Content-Encoding: br, ETag: "v1.2.3"
Note over Browser: User stays on page, new deployment happens
Browser->>Fastify: GET / If-None-Match: "v1.2.3"
Fastify->>HtmlCache: get({ ifNoneMatch: "v1.2.3", ... })
HtmlCache-->>Fastify: { statusCode: 200, body: }
Fastify-->>Browser: 200 (new deployment, ETag changed)
```
When SPA Guard performs a cache-busting reload (appending `?spaGuardRetryAttempt=1&spaGuardCacheBust=`), the browser skips its local cache and hits the server. If the ETag has changed since the last visit (new deployment), the server returns the new HTML. The new HTML references new chunk URLs — and the app recovers.
## When you need it
The server-side HTML cache is **optional**. You don't need it to get the core retry behavior. You need it when:
- Your app targets multiple languages and the fallback UI should be localized
- You want precise cache invalidation tied to your deployment version (not time-based)
- You want to avoid serving stale fallback HTML to users after a deployment
- You're already running a Fastify server and want to consolidate HTML serving through it
If you're serving a purely static SPA from a CDN with no server component, the build-time embedding approach (just the Vite plugin) is sufficient.
## Related packages
- [`@ovineko/spa-guard-node`](./node) — `createHtmlCache`, `createHTMLCacheStore`, `patchHtmlI18n`, builder API
- [`@ovineko/spa-guard-fastify`](./fastify) — `spaGuardFastifyHandler`, `spaGuardBeaconPlugin`
- [`@ovineko/spa-guard-vite`](./vite) — Vite plugin that injects the fallback HTML template and runtime config at build time
---
## Vite Plugin (@ovineko/spa-guard-vite)
# @ovineko/spa-guard-vite
Vite plugin for spa-guard — injects the runtime inline script and loading spinner into your SPA's HTML at build time.
## Install
```bash
pnpm add -D @ovineko/spa-guard-vite
pnpm add @ovineko/spa-guard
```
```bash
npm install --save-dev @ovineko/spa-guard-vite
npm install @ovineko/spa-guard
```
```bash
yarn add -D @ovineko/spa-guard-vite
yarn add @ovineko/spa-guard
```
```bash
bun add -d @ovineko/spa-guard-vite
bun add @ovineko/spa-guard
```
```bash
deno add npm:@ovineko/spa-guard-vite npm:@ovineko/spa-guard
```
## Usage
Add the plugin to your `vite.config.ts`:
```ts
export default defineConfig({
plugins: [spaGuardVitePlugin()],
});
```
With options:
```ts
export default defineConfig({
plugins: [
spaGuardVitePlugin({
version: "1.0.0",
reloadDelays: [1000, 3000, 10000],
html: { spinner: { background: "#f5f5f5" } },
}),
],
});
```
Disable the spinner:
```ts
spaGuardVitePlugin({ html: { spinner: { disabled: true } } });
```
Enable trace mode (logs extra debug info at runtime):
```ts
spaGuardVitePlugin({ trace: true });
```
Use external script mode to write a content-hashed `.js` file instead of inlining the script:
```ts
spaGuardVitePlugin({
mode: "external",
externalScriptDir: "dist/assets",
publicPath: "/assets",
});
// Injects