# 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