diff --git a/.gitignore b/.gitignore index 69f5fbd5..a7fd75b6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ website/.vitepress/cache/ website/.vitepress/dist/ # test coverage -coverage \ No newline at end of file +coverage +.vercel +.env*.local diff --git a/CLAUDE.md b/CLAUDE.md index 0703e2c3..f8cadd92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,19 @@ pnpm --filter @coji/durably test:node:all # Run all Node tests (SQLite + pnpm db:down # Stop Postgres ``` +## Codex CLI for Research & Consultation + +When stuck or needing up-to-date information about external services, use Codex CLI (`codex exec`) to consult GPT. Codex can perform web searches, making it especially useful for real-time information like Vercel configuration, latest library APIs, or deployment troubleshooting. + +```bash +codex exec "your question or prompt here" +``` + +- Run in the background and check results later for non-blocking workflow +- Include relevant codebase context in the prompt for better answers +- Useful for research, design consultation, and debugging — not just search +- **During `/review` and `/simplify`**: Run `codex exec` in parallel with the review agents to get a second opinion on the diff. Pass the diff content and ask for code quality feedback, potential issues, or improvement suggestions + ## Skills - **release-check** - Pre-release integrity check for API changes and spec updates (`.claude/skills/release-check/`) diff --git a/examples/fullstack-vercel-turso/.env.example b/examples/fullstack-vercel-turso/.env.example new file mode 100644 index 00000000..8b0cc422 --- /dev/null +++ b/examples/fullstack-vercel-turso/.env.example @@ -0,0 +1,7 @@ +# Local development (libsqld via Docker) +TURSO_DATABASE_URL=http://localhost:8080 + +# Production (Turso) +# TURSO_DATABASE_URL=libsql://your-db-name.turso.io +# TURSO_AUTH_TOKEN=your-token +# CRON_SECRET=your-secret diff --git a/examples/fullstack-vercel-turso/.gitignore b/examples/fullstack-vercel-turso/.gitignore new file mode 100644 index 00000000..cce765d8 --- /dev/null +++ b/examples/fullstack-vercel-turso/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ + +*.swp +.vercel +.env*.local diff --git a/examples/fullstack-vercel-turso/.vercelignore b/examples/fullstack-vercel-turso/.vercelignore new file mode 100644 index 00000000..8aed15d0 --- /dev/null +++ b/examples/fullstack-vercel-turso/.vercelignore @@ -0,0 +1,13 @@ +# Exclude sibling examples to prevent @vercel/react-router builder +# from scanning other React Router apps in the monorepo +../../examples/fullstack-react-router +../../examples/spa-vite-react +../../examples/spa-react-router +../../examples/server-node + +# Exclude non-essential workspace packages +../../website + +# Exclude tests and docs +../../packages/durably/tests +../../packages/durably-react/tests diff --git a/examples/fullstack-vercel-turso/README.md b/examples/fullstack-vercel-turso/README.md new file mode 100644 index 00000000..3d2712e7 --- /dev/null +++ b/examples/fullstack-vercel-turso/README.md @@ -0,0 +1,78 @@ +# Fullstack Vercel + Turso Example + +Durably fullstack demo deployed on Vercel with Turso (libSQL) as the database. + +> **Note**: This is a demo app with no authentication on the Durably control plane. +> For production use, add authentication via `createDurablyHandler({ authenticate })`. +> See the [auth guide](../../website/guide/auth.md) for details. + +## Architecture + +- **Framework**: React Router v7 with `@vercel/react-router` preset +- **Database**: Turso (remote libSQL) in production, local libsqld via Docker in development +- **Worker**: Dual-mode + - **Real-time**: `onRequest` lazily starts the worker — runs during SSE streaming + - **Background**: `/api/worker` endpoint called by Vercel Cron (requires Pro plan for per-minute schedule) + +## How it works + +```text +User triggers job → POST /api/durably/trigger + ↓ +User subscribes → GET /api/durably/subscribe?runId=xxx (SSE) + ↓ +onRequest → durably.init() starts worker during SSE connection + ↓ +Worker processes → steps stream via SSE in real-time + ↓ +SSE disconnects → function terminates, worker stops + ↓ +Vercel Cron → GET /api/worker processes any remaining pending jobs +``` + +## Setup + +### Local development + +Requires Docker for the local libsqld instance. + +```bash +cp .env.example .env +pnpm install +pnpm dev +``` + +This starts a local libsqld container (Docker) and the dev server. +The app connects to `http://localhost:8080` — same HTTP protocol as production Turso. + +### Production (Vercel + Turso) + +1. Create a Turso database: + + ```bash + turso db create my-durably-app + turso db tokens create my-durably-app + ``` + +2. Set environment variables in Vercel: + - `TURSO_DATABASE_URL` — `libsql://my-durably-app-.turso.io` + - `TURSO_AUTH_TOKEN` — token from step 1 + - `CRON_SECRET` — any random string to authenticate cron requests + +3. Deploy: + ```bash + vercel + ``` + +## Key files + +| File | Description | +| ----------------------------- | ------------------------------------------- | +| `docker-compose.yml` | Local libsqld for development | +| `app/lib/database.server.ts` | Turso/libSQL connection config | +| `app/lib/durably.server.ts` | Durably instance with `onRequest` lazy init | +| `app/lib/durably.ts` | Type-safe client for React components | +| `app/routes/api.durably.$.ts` | Durably HTTP/SSE handler (splat route) | +| `app/routes/api.worker.ts` | Background worker endpoint for Vercel Cron | +| `vercel.json` | Cron schedule (per-minute requires Pro) | +| `react-router.config.ts` | Vercel preset configuration | diff --git a/examples/fullstack-vercel-turso/app/app.css b/examples/fullstack-vercel-turso/app/app.css new file mode 100644 index 00000000..f3902dce --- /dev/null +++ b/examples/fullstack-vercel-turso/app/app.css @@ -0,0 +1,12 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: + 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +html, +body { + @apply bg-gray-50; +} diff --git a/examples/fullstack-vercel-turso/app/jobs/data-sync.ts b/examples/fullstack-vercel-turso/app/jobs/data-sync.ts new file mode 100644 index 00000000..ea9febe2 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/data-sync.ts @@ -0,0 +1,55 @@ +/** + * Data Sync Job + * + * Simulates syncing data with a remote server. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' +import { delay } from './delay' + +export const dataSyncJob = defineJob({ + name: 'data-sync', + input: z.object({ userId: z.string() }), + output: z.object({ synced: z.number(), failed: z.number() }), + run: async (step, input) => { + step.log.info(`Starting sync for user: ${input.userId}`) + + const items = await step.run('fetch-local', async () => { + step.progress(1, 4, 'Fetching local data...') + await delay(300) + return Array.from({ length: 10 }, (_, i) => ({ + id: `item-${i}`, + data: `Data for ${input.userId}`, + })) + }) + + let synced = 0 + let failed = 0 + + for (let i = 0; i < items.length; i++) { + const item = items[i] + const success = await step.run(`sync-item-${item.id}`, async () => { + step.progress(2 + Math.floor(i / 5), 4, `Syncing item ${i + 1}...`) + await delay(100) + return Math.random() > 0.1 // 90% success rate + }) + + if (success) { + synced++ + } else { + failed++ + step.log.warn(`Failed to sync item: ${item.id}`) + } + } + + await step.run('finalize', async () => { + step.progress(4, 4, 'Finalizing...') + await delay(200) + }) + + step.log.info(`Sync complete: ${synced} synced, ${failed} failed`) + + return { synced, failed } + }, +}) diff --git a/examples/fullstack-vercel-turso/app/jobs/delay.ts b/examples/fullstack-vercel-turso/app/jobs/delay.ts new file mode 100644 index 00000000..3697ce6a --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/delay.ts @@ -0,0 +1 @@ +export const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) diff --git a/examples/fullstack-vercel-turso/app/jobs/generate-report.ts b/examples/fullstack-vercel-turso/app/jobs/generate-report.ts new file mode 100644 index 00000000..f1798eda --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/generate-report.ts @@ -0,0 +1,220 @@ +/** + * Report Generation Job (Long-running demo) + * + * Simulates a multi-phase analytics report pipeline with many steps. + * Designed to demonstrate: + * - SSE streaming with real-time step progress + * - Cron background processing for interrupted jobs + * - Step resumability across serverless invocations + * + * Phases: + * 1. Data collection (5 sources) + * 2. Data cleaning & transformation (4 steps) + * 3. Analysis (5 metrics) + * 4. Chart generation (4 charts) + * 5. Report assembly & delivery (3 steps) + * + * Total: ~21 steps, ~90 seconds + * + * This intentionally exceeds Vercel's serverless timeout (10s hobby / 60s pro) + * to demonstrate step resumability: SSE streams progress until timeout, + * then Vercel Cron picks up remaining steps on the next invocation. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' +import { delay } from './delay' + +const outputSchema = z.object({ + reportUrl: z.string(), + totalRecords: z.number(), + generatedAt: z.string(), +}) + +export const generateReportJob = defineJob({ + name: 'generate-report', + input: z.object({ + reportType: z.enum(['daily', 'weekly', 'monthly']), + department: z.string(), + }), + output: outputSchema, + run: async (step, input) => { + // Use fixed step numbers instead of a mutable counter. + // Step callbacks are skipped on resume (cached output returned), + // so a mutable counter would report wrong progress after restart. + const T = 21 + + step.log.info(`Starting ${input.reportType} report for ${input.department}`) + + // ── Phase 1: Data Collection (5 steps) ── + + const salesData = await step.run('collect-sales', async () => { + step.progress(1, T, 'Collecting sales data...') + await delay(4000) + const records = Math.floor(Math.random() * 5000) + 3000 + step.log.info(`Sales: ${records} records`) + return { records, revenue: records * 42.5 } + }) + + const inventoryData = await step.run('collect-inventory', async () => { + step.progress(2, T, 'Collecting inventory data...') + await delay(3500) + const items = Math.floor(Math.random() * 2000) + 1000 + step.log.info(`Inventory: ${items} items`) + return { items, lowStock: Math.floor(items * 0.12) } + }) + + const customerData = await step.run('collect-customers', async () => { + step.progress(3, T, 'Collecting customer data...') + await delay(4500) + const customers = Math.floor(Math.random() * 1500) + 500 + step.log.info(`Customers: ${customers} profiles`) + return { customers, newCustomers: Math.floor(customers * 0.08) } + }) + + const supportData = await step.run('collect-support', async () => { + step.progress(4, T, 'Collecting support tickets...') + await delay(3000) + const tickets = Math.floor(Math.random() * 300) + 100 + step.log.info(`Support: ${tickets} tickets`) + return { tickets, resolved: Math.floor(tickets * 0.85) } + }) + + const webAnalytics = await step.run('collect-analytics', async () => { + step.progress(5, T, 'Collecting web analytics...') + await delay(5000) + const pageViews = Math.floor(Math.random() * 50000) + 20000 + step.log.info(`Analytics: ${pageViews} page views`) + return { pageViews, uniqueVisitors: Math.floor(pageViews * 0.35) } + }) + + const totalRecords = + salesData.records + + inventoryData.items + + customerData.customers + + supportData.tickets + + webAnalytics.pageViews + + // ── Phase 2: Data Cleaning & Transformation (4 steps) ── + + await step.run('deduplicate', async () => { + step.progress(6, T, 'Deduplicating records...') + await delay(5000) + const dupes = Math.floor(totalRecords * 0.03) + step.log.info(`Removed ${dupes} duplicate records`) + }) + + await step.run('normalize', async () => { + step.progress(7, T, 'Normalizing data formats...') + await delay(4000) + step.log.info('Data formats normalized') + }) + + await step.run('enrich', async () => { + step.progress(8, T, 'Enriching with external data...') + await delay(6000) + step.log.info('Data enriched with geo and demographic info') + }) + + await step.run('validate-data', async () => { + step.progress(9, T, 'Validating data integrity...') + await delay(3000) + step.log.info('Data validation passed') + }) + + // ── Phase 3: Analysis (5 steps) ── + + const revenueMetrics = await step.run('analyze-revenue', async () => { + step.progress(10, T, 'Analyzing revenue trends...') + await delay(4000) + const growth = (Math.random() * 20 - 5).toFixed(1) + step.log.info(`Revenue growth: ${growth}%`) + return { growth: Number(growth), total: salesData.revenue } + }) + + await step.run('analyze-retention', async () => { + step.progress(11, T, 'Analyzing customer retention...') + await delay(3500) + const rate = (85 + Math.random() * 10).toFixed(1) + step.log.info(`Retention rate: ${rate}%`) + }) + + await step.run('analyze-support', async () => { + step.progress(12, T, 'Analyzing support metrics...') + await delay(3000) + const avgTime = (2 + Math.random() * 4).toFixed(1) + step.log.info(`Avg resolution time: ${avgTime}h`) + }) + + await step.run('analyze-traffic', async () => { + step.progress(13, T, 'Analyzing traffic patterns...') + await delay(4000) + step.log.info( + `Peak traffic: ${webAnalytics.uniqueVisitors} unique visitors`, + ) + }) + + await step.run('analyze-inventory', async () => { + step.progress(14, T, 'Analyzing inventory turnover...') + await delay(3000) + const turnover = (3 + Math.random() * 5).toFixed(1) + step.log.info(`Inventory turnover: ${turnover}x`) + }) + + // ── Phase 4: Chart Generation (4 steps) ── + + await step.run('chart-revenue', async () => { + step.progress(15, T, 'Generating revenue chart...') + await delay(3500) + step.log.info('Revenue trend chart generated') + }) + + await step.run('chart-customers', async () => { + step.progress(16, T, 'Generating customer chart...') + await delay(3000) + step.log.info('Customer growth chart generated') + }) + + await step.run('chart-support', async () => { + step.progress(17, T, 'Generating support chart...') + await delay(3000) + step.log.info('Support metrics chart generated') + }) + + await step.run('chart-traffic', async () => { + step.progress(18, T, 'Generating traffic chart...') + await delay(3500) + step.log.info('Traffic heatmap generated') + }) + + // ── Phase 5: Report Assembly & Delivery (3 steps) ── + + await step.run('assemble-pdf', async () => { + step.progress(19, T, 'Assembling PDF report...') + await delay(6000) + step.log.info('PDF report assembled (24 pages)') + }) + + const reportUrl = await step.run('upload-report', async () => { + step.progress(20, T, 'Uploading report...') + await delay(4000) + const url = `https://reports.example.com/${input.department}/${input.reportType}-${Date.now()}.pdf` + step.log.info(`Report uploaded: ${url}`) + return url + }) + + await step.run('send-notifications', async () => { + step.progress(21, T, 'Sending notifications...') + await delay(2000) + step.log.info( + `Notification sent to ${input.department} team (revenue: $${revenueMetrics.total.toLocaleString()})`, + ) + }) + + return { + reportUrl, + totalRecords, + generatedAt: new Date().toISOString(), + } + }, +}) diff --git a/examples/fullstack-vercel-turso/app/jobs/import-csv.ts b/examples/fullstack-vercel-turso/app/jobs/import-csv.ts new file mode 100644 index 00000000..4f9b6f3d --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/import-csv.ts @@ -0,0 +1,92 @@ +/** + * CSV Import Job + * + * Demonstrates separation of steps (resumable units) and progress (UI feedback). + * - Steps: validate, import, finalize (3 resumable checkpoints) + * - Progress: fine-grained row-level feedback within each step + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' +import { delay } from './delay' + +const csvRowSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + amount: z.number(), +}) + +/** Output schema for type inference */ +const outputSchema = z.object({ imported: z.number(), failed: z.number() }) + +export const importCsvJob = defineJob({ + name: 'import-csv', + input: z.object({ + filename: z.string(), + rows: z.array(csvRowSchema), + }), + output: outputSchema, + run: async (step, input) => { + step.log.info( + `Starting import of ${input.filename} (${input.rows.length} rows)`, + ) + + // Step 1: Validate all rows + const validRows = await step.run('validate', async () => { + const valid: typeof input.rows = [] + const invalid: { row: (typeof input.rows)[0]; reason: string }[] = [] + + for (let i = 0; i < input.rows.length; i++) { + const row = input.rows[i] + step.progress(i + 1, input.rows.length, `Validating row ${row.id}...`) + await delay(50) + + if (row.amount < 0) { + invalid.push({ row, reason: `Invalid amount: ${row.amount}` }) + step.log.warn(`Validation failed for row ${row.id}: negative amount`) + } else { + valid.push(row) + } + } + + step.log.info( + `Validation complete: ${valid.length} valid, ${invalid.length} invalid`, + ) + return { valid, invalidCount: invalid.length } + }) + + // Step 2: Import valid rows + const importResult = await step.run('import', async () => { + let imported = 0 + + for (let i = 0; i < validRows.valid.length; i++) { + const row = validRows.valid[i] + step.progress( + i + 1, + validRows.valid.length, + `Importing row ${row.id}...`, + ) + await delay(80) + + // Simulate import + imported++ + step.log.info(`Imported row ${row.id}`) + } + + return { imported } + }) + + // Step 3: Finalize + await step.run('finalize', async () => { + step.progress(1, 1, 'Finalizing...') + await delay(200) + step.log.info('Import finalized') + }) + + return { + imported: importResult.imported, + failed: validRows.invalidCount, + } + }, +}) diff --git a/examples/fullstack-vercel-turso/app/jobs/index.ts b/examples/fullstack-vercel-turso/app/jobs/index.ts new file mode 100644 index 00000000..ff53538a --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/index.ts @@ -0,0 +1,24 @@ +/** + * Job Definitions + * + * Barrel export for all job definitions. + * When adding a new job, import and add it here. + */ + +import type { JobInput, JobOutput } from '@coji/durably' +import { dataSyncJob } from './data-sync' +import { generateReportJob } from './generate-report' +import { importCsvJob } from './import-csv' +import { processImageJob } from './process-image' + +export { dataSyncJob, generateReportJob, importCsvJob, processImageJob } + +/** Input/Output types for all jobs - used for typed useRuns dashboard */ +export type DataSyncInput = JobInput +export type DataSyncOutput = JobOutput +export type GenerateReportInput = JobInput +export type GenerateReportOutput = JobOutput +export type ImportCsvInput = JobInput +export type ImportCsvOutput = JobOutput +export type ProcessImageInput = JobInput +export type ProcessImageOutput = JobOutput diff --git a/examples/fullstack-vercel-turso/app/jobs/process-image.ts b/examples/fullstack-vercel-turso/app/jobs/process-image.ts new file mode 100644 index 00000000..4f185793 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/process-image.ts @@ -0,0 +1,47 @@ +/** + * Process Image Job + * + * Simulates image processing with multiple steps. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' +import { delay } from './delay' + +export const processImageJob = defineJob({ + name: 'process-image', + input: z.object({ filename: z.string(), width: z.number() }), + output: z.object({ url: z.string(), size: z.number() }), + run: async (step, input) => { + step.log.info(`Starting image processing: ${input.filename}`) + + // Download original image + const fileSize = await step.run('download', async () => { + step.progress(1, 3, 'Downloading...') + await delay(500) + return Math.floor(Math.random() * 1000000) + 500000 // 500KB-1.5MB + }) + + step.log.info(`Downloaded: ${fileSize} bytes`) + + // Resize to target width + const resizedSize = await step.run('resize', async () => { + step.progress(2, 3, 'Resizing...') + await delay(600) + return Math.floor(fileSize * (input.width / 1920)) + }) + + step.log.info(`Resized to: ${resizedSize} bytes`) + + // Upload to CDN + const url = await step.run('upload', async () => { + step.progress(3, 3, 'Uploading...') + await delay(400) + return `https://cdn.example.com/${input.width}/${input.filename}` + }) + + step.log.info(`Uploaded to: ${url}`) + + return { url, size: resizedSize } + }, +}) diff --git a/examples/fullstack-vercel-turso/app/lib/database.server.ts b/examples/fullstack-vercel-turso/app/lib/database.server.ts new file mode 100644 index 00000000..b14f85db --- /dev/null +++ b/examples/fullstack-vercel-turso/app/lib/database.server.ts @@ -0,0 +1,20 @@ +/** + * Database Configuration + * + * Turso/libSQL dialect for Vercel serverless. + * - Production: Turso via TURSO_DATABASE_URL + TURSO_AUTH_TOKEN + * - Development: local libsqld via Docker (http://localhost:8080) + * + * Server-only - do not import in client code. + */ + +import { LibsqlDialect } from '@libsql/kysely-libsql' + +if (!process.env.TURSO_DATABASE_URL) { + throw new Error('TURSO_DATABASE_URL is required. See .env.example') +} + +export const dialect = new LibsqlDialect({ + url: process.env.TURSO_DATABASE_URL, + authToken: process.env.TURSO_AUTH_TOKEN, +}) diff --git a/examples/fullstack-vercel-turso/app/lib/durably.server.ts b/examples/fullstack-vercel-turso/app/lib/durably.server.ts new file mode 100644 index 00000000..fbe27a68 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/lib/durably.server.ts @@ -0,0 +1,41 @@ +/** + * Durably Server Configuration (Vercel Serverless) + * + * Sets up Durably instance, registers jobs, and provides HTTP handler. + * Server-only - do not import in client code. + * + * Key difference from Fly.io: no top-level `await durably.init()`. + * Instead, `onRequest` lazily initializes on each request. + * This works because Vercel functions are short-lived — the worker + * runs during the lifetime of the request (including SSE streaming). + */ + +import { createDurably, createDurablyHandler } from '@coji/durably' +import { + dataSyncJob, + generateReportJob, + importCsvJob, + processImageJob, +} from '~/jobs' +import { dialect } from './database.server' + +// Create Durably instance with jobs +export const durably = createDurably({ + dialect, + jobs: { + processImage: processImageJob, + dataSync: dataSyncJob, + importCsv: importCsvJob, + generateReport: generateReportJob, + }, +}) + +// HTTP handler with lazy initialization. +// init() is safe to call multiple times — it migrates the DB and starts +// the worker on first call, subsequent calls are no-ops. +// The worker stays alive as long as the request is active (e.g., SSE streaming). +export const durablyHandler = createDurablyHandler(durably, { + onRequest: async () => { + await durably.init() + }, +}) diff --git a/examples/fullstack-vercel-turso/app/lib/durably.ts b/examples/fullstack-vercel-turso/app/lib/durably.ts new file mode 100644 index 00000000..2856297e --- /dev/null +++ b/examples/fullstack-vercel-turso/app/lib/durably.ts @@ -0,0 +1,13 @@ +/** + * Durably Client Configuration + * + * Creates a type-safe Durably client for React components. + * Uses type-only import from the server — no server code is bundled. + */ + +import { createDurably } from '@coji/durably-react' +import type { durably as serverDurably } from './durably.server' + +export const durably = createDurably({ + api: '/api/durably', +}) diff --git a/examples/fullstack-vercel-turso/app/root.tsx b/examples/fullstack-vercel-turso/app/root.tsx new file mode 100644 index 00000000..08861915 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/root.tsx @@ -0,0 +1,75 @@ +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from 'react-router' + +import type { Route } from './+types/root' +import './app.css' + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, +] + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) +} + +export default function App() { + return +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!' + let details = 'An unexpected error occurred.' + let stack: string | undefined + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error' + details = + error.status === 404 + ? 'The requested page could not be found.' + : error.statusText || details + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message + stack = error.stack + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ) +} diff --git a/examples/fullstack-vercel-turso/app/routes.ts b/examples/fullstack-vercel-turso/app/routes.ts new file mode 100644 index 00000000..9dbef205 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes.ts @@ -0,0 +1,7 @@ +import { type RouteConfig, index, route } from '@react-router/dev/routes' + +export default [ + index('routes/_index.tsx'), + route('api/durably/*', 'routes/api.durably.$.ts'), + route('api/worker', 'routes/api.worker.ts'), +] satisfies RouteConfig diff --git a/examples/fullstack-vercel-turso/app/routes/_index.tsx b/examples/fullstack-vercel-turso/app/routes/_index.tsx new file mode 100644 index 00000000..510b8cd5 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index.tsx @@ -0,0 +1,155 @@ +/** + * Full-Stack React Router Example + * + * This example demonstrates: + * - React Router v7 with server-side action + * - SSE streaming for real-time progress updates + * - action for Form-based job triggering + * - useJobRun hook for monitoring jobs via SSE + */ + +import { useState } from 'react' +import { durably } from '~/lib/durably.server' +import type { Route } from './+types/_index' +import { Dashboard } from './_index/dashboard' +import { DataSyncForm } from './_index/data-sync-form' +import { DataSyncProgress } from './_index/data-sync-progress' +import { ImageProcessingForm } from './_index/image-processing-form' +import { ImageProcessingProgress } from './_index/image-processing-progress' +import { ReportForm } from './_index/report-form' +import { ReportProgress } from './_index/report-progress' + +export function meta() { + return [ + { title: 'Durably - Full-Stack React Router' }, + { name: 'description', content: 'Full-stack job processing with SSE' }, + ] +} + +// Action: Trigger jobs +export async function action({ request }: Route.ActionArgs) { + await durably.init() + const formData = await request.formData() + const intent = formData.get('intent') as string + + if (intent === 'image') { + const filename = formData.get('filename') as string + const width = Number(formData.get('width')) + const run = await durably.jobs.processImage.trigger( + { filename, width }, + { labels: { source: 'server' } }, + ) + return { intent: 'image', runId: run.id } + } + + if (intent === 'sync') { + const userId = formData.get('userId') as string + const run = await durably.jobs.dataSync.trigger({ userId }) + return { intent: 'sync', runId: run.id } + } + + if (intent === 'report') { + const reportType = formData.get('reportType') as + | 'daily' + | 'weekly' + | 'monthly' + const department = formData.get('department') as string + const run = await durably.jobs.generateReport.trigger({ + reportType, + department, + }) + return { intent: 'report', runId: run.id } + } + + return null +} + +export default function Index() { + const [activeJob, setActiveJob] = useState<'image' | 'sync' | 'report'>( + 'report', + ) + + return ( +
+
+
+

+ Durably - Vercel + Turso +

+

+ React Router v7 on Vercel with Turso + SSE streaming +

+
+ +
+ {/* Left: Job Trigger + Progress */} +
+ {/* Job Selection */} +
+

Run Job

+ +
+ + + +
+ + {activeJob === 'image' && } + {activeJob === 'sync' && } + {activeJob === 'report' && } +
+ + {/* Progress Display */} + {activeJob === 'image' && } + {activeJob === 'sync' && } + {activeJob === 'report' && } +
+ + {/* Right: Dashboard */} + +
+ +
+

+ Data is stored in Turso (libSQL). Worker runs via onRequest + Vercel + Cron. +

+

+ Try reloading the page during job execution - progress updates via + SSE! +

+
+
+
+ ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx new file mode 100644 index 00000000..4534e267 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx @@ -0,0 +1,391 @@ +/** + * Dashboard Component + * + * Displays run history with real-time updates via SSE and pagination. + * First page auto-subscribes to SSE for instant updates. + * + * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. + */ + +import type { ClientRun, StepRecord, TypedClientRun } from '@coji/durably-react' +import { useState } from 'react' +import type { + DataSyncInput, + DataSyncOutput, + GenerateReportInput, + GenerateReportOutput, + ImportCsvInput, + ImportCsvOutput, + ProcessImageInput, + ProcessImageOutput, +} from '~/jobs' +import { durably } from '~/lib/durably' + +/** Union type for all job runs in this dashboard */ +type DashboardRun = + | TypedClientRun + | TypedClientRun + | TypedClientRun + | TypedClientRun + +function LabelChips({ labels }: { labels: Record }) { + const entries = Object.entries(labels) + if (entries.length === 0) return - + return ( +
+ {entries.map(([k, v]) => ( + + {k}={v} + + ))} +
+ ) +} + +const formatDate = (iso: string) => new Date(iso).toLocaleString() + +const statusClasses: Record = { + pending: 'bg-yellow-100 text-yellow-800', + leased: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + cancelled: 'bg-gray-100 text-gray-800', +} + +export function Dashboard() { + const { runs, isLoading, error, page, hasMore, nextPage, prevPage, refresh } = + durably.useRuns({ + pageSize: 6, + }) + + const { + cancel, + retrigger, + deleteRun, + getSteps, + isLoading: isActioning, + } = durably.useRunActions() + + const [selectedRun, setSelectedRun] = useState(null) + const [steps, setSteps] = useState([]) + + const handleCancel = async (runId: string) => { + await cancel(runId) + refresh() + } + + const handleRetrigger = async (runId: string) => { + await retrigger(runId) + refresh() + } + + const handleDelete = async (runId: string) => { + await deleteRun(runId) + setSelectedRun(null) + refresh() + } + + const showDetails = async (runId: string) => { + const run = runs.find((r) => r.id === runId) + if (run) { + setSelectedRun(run) + const stepsData = await getSteps(runId) + setSteps(stepsData) + } + } + + return ( +
+
+

Run History

+
+ {isLoading && ( + Refreshing... + )} + +
+
+ + {error &&
Error: {error}
} + + {runs.length === 0 ? ( +

No runs yet

+ ) : ( + <> +
+ + + + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + + + + ))} + +
+ ID + + Job + + Status + + Labels + + Steps + + Progress + + Created + + Actions +
+ {run.id.slice(0, 8)}... + {run.jobName} + + {run.status} + + + + + {run.stepCount > 0 ? ( + + {run.stepCount} + + ) : ( + - + )} + + {run.progress ? ( +
+
+
0 ? 100 : 0}%`, + }} + /> +
+ + {run.progress.current} + {run.progress.total && `/${run.progress.total}`} + +
+ ) : ( + - + )} +
+ {formatDate(run.createdAt)} + +
+ + {(run.status === 'failed' || + run.status === 'cancelled') && ( + + )} + {(run.status === 'leased' || + run.status === 'pending') && ( + + )} + {run.status !== 'leased' && + run.status !== 'pending' && ( + + )} +
+
+
+ + {/* Pagination */} +
+ + Page {page + 1} + +
+ + )} + + {/* Run Details Modal */} + {selectedRun && ( +
+
+
+
+

Run Details

+ +
+ +
+
+ ID:{' '} + + {selectedRun.id} + +
+
+ Job:{' '} + {selectedRun.jobName} +
+
+ Status:{' '} + + {selectedRun.status} + +
+ {Object.keys(selectedRun.labels).length > 0 && ( +
+ Labels: +
+ +
+
+ )} +
+ Created:{' '} + {formatDate(selectedRun.createdAt)} +
+ + {selectedRun.progress && ( +
+ Progress:{' '} + {selectedRun.progress.current} + {selectedRun.progress.total + ? `/${selectedRun.progress.total}` + : ''}{' '} + {selectedRun.progress.message || ''} +
+ )} + + {selectedRun.error && ( +
+ Error:{' '} + {selectedRun.error} +
+ )} + + {selectedRun.output !== null && ( +
+ Output: +
+                      {JSON.stringify(selectedRun.output, null, 2)}
+                    
+
+ )} + +
+ Input: +
+                    {JSON.stringify(selectedRun.input, null, 2)}
+                  
+
+ + {steps.length > 0 && ( +
+ Steps: +
    + {steps.map((s) => ( +
  • + {s.name} + + {s.status} + +
  • + ))} +
+
+ )} +
+
+
+
+ )} +
+ ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/_index/data-sync-form.tsx b/examples/fullstack-vercel-turso/app/routes/_index/data-sync-form.tsx new file mode 100644 index 00000000..717728ac --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/data-sync-form.tsx @@ -0,0 +1,47 @@ +/** + * Data Sync Form Component + * + * Form for triggering data sync jobs via server action. + */ + +import { Form, useActionData, useNavigation } from 'react-router' +import type { action } from '../_index' + +export function DataSyncForm() { + const actionData = useActionData() + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + const runId = actionData?.intent === 'sync' ? actionData.runId : null + + return ( +
+ +
+ + +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/_index/data-sync-progress.tsx b/examples/fullstack-vercel-turso/app/routes/_index/data-sync-progress.tsx new file mode 100644 index 00000000..1bc1fa1f --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/data-sync-progress.tsx @@ -0,0 +1,41 @@ +/** + * Data Sync Progress Component + * + * Displays progress for the data sync job using the typed Durably client. + */ + +import { useActionData } from 'react-router' +import { durably } from '~/lib/durably' +import type { action } from '../_index' +import { RunProgress } from './run-progress' + +export function DataSyncProgress() { + const actionData = useActionData() + const runId = actionData?.intent === 'sync' ? actionData.runId : null + + const { + progress, + output, + error, + logs, + isPending, + isLeased, + isCompleted, + isFailed, + isCancelled, + } = durably.dataSync.useRun(runId) + + return ( + + ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/_index/image-processing-form.tsx b/examples/fullstack-vercel-turso/app/routes/_index/image-processing-form.tsx new file mode 100644 index 00000000..eaf03150 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/image-processing-form.tsx @@ -0,0 +1,64 @@ +/** + * Image Processing Form Component + * + * Form for triggering image processing jobs via server action. + */ + +import { Form, useActionData, useNavigation } from 'react-router' +import type { action } from '../_index' + +export function ImageProcessingForm() { + const actionData = useActionData() + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + const runId = actionData?.intent === 'image' ? actionData.runId : null + + return ( +
+ +
+ + +
+
+ + +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/_index/image-processing-progress.tsx b/examples/fullstack-vercel-turso/app/routes/_index/image-processing-progress.tsx new file mode 100644 index 00000000..f2f88381 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/image-processing-progress.tsx @@ -0,0 +1,41 @@ +/** + * Image Processing Progress Component + * + * Displays progress for the image processing job using the typed Durably client. + */ + +import { useActionData } from 'react-router' +import { durably } from '~/lib/durably' +import type { action } from '../_index' +import { RunProgress } from './run-progress' + +export function ImageProcessingProgress() { + const actionData = useActionData() + const runId = actionData?.intent === 'image' ? actionData.runId : null + + const { + progress, + output, + error, + logs, + isPending, + isLeased, + isCompleted, + isFailed, + isCancelled, + } = durably.processImage.useRun(runId) + + return ( + + ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/_index/report-form.tsx b/examples/fullstack-vercel-turso/app/routes/_index/report-form.tsx new file mode 100644 index 00000000..31122d60 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/report-form.tsx @@ -0,0 +1,65 @@ +/** + * Report Generation Form Component + * + * Form for triggering the long-running report generation job. + */ + +import { Form, useActionData, useNavigation } from 'react-router' +import type { action } from '../_index' + +export function ReportForm() { + const actionData = useActionData() + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + const runId = actionData?.intent === 'report' ? actionData.runId : null + + return ( +
+ +
+ + +
+
+ + +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/_index/report-progress.tsx b/examples/fullstack-vercel-turso/app/routes/_index/report-progress.tsx new file mode 100644 index 00000000..f96a665b --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/report-progress.tsx @@ -0,0 +1,41 @@ +/** + * Report Generation Progress Component + * + * Displays progress for the long-running report generation job. + */ + +import { useActionData } from 'react-router' +import { durably } from '~/lib/durably' +import type { action } from '../_index' +import { RunProgress } from './run-progress' + +export function ReportProgress() { + const actionData = useActionData() + const runId = actionData?.intent === 'report' ? actionData.runId : null + + const { + progress, + output, + error, + logs, + isPending, + isLeased, + isCompleted, + isFailed, + isCancelled, + } = durably.generateReport.useRun(runId) + + return ( + + ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/_index/run-progress.tsx b/examples/fullstack-vercel-turso/app/routes/_index/run-progress.tsx new file mode 100644 index 00000000..737f6b34 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/run-progress.tsx @@ -0,0 +1,125 @@ +/** + * RunProgress Component + * + * Displays real-time progress and result for jobs. + */ + +import type { LogEntry } from '@coji/durably-react' + +interface RunProgressProps { + progress: { current: number; total?: number; message?: string } | null + output: unknown + error: string | null + logs: LogEntry[] + isPending: boolean + isLeased: boolean + isCompleted: boolean + isFailed: boolean + isCancelled: boolean +} + +export function RunProgress({ + progress, + output, + error, + logs, + isPending, + isLeased, + isCompleted, + isFailed, + isCancelled, +}: RunProgressProps) { + // Don't render anything if no activity + if (!isPending && !isLeased && !isCompleted && !isFailed && !isCancelled) { + return null + } + + return ( + <> + {/* Pending State */} + {isPending && ( +
+
Waiting to start...
+
+ )} + + {/* Progress Display */} + {isLeased && progress && ( +
+
+ Progress + + {progress.current}/{progress.total || '?'} + +
+
+
+
+ {progress.message && ( +
{progress.message}
+ )} +
+ )} + + {/* Success Result */} + {isCompleted && output !== null && output !== undefined && ( +
+
Completed!
+
+            {JSON.stringify(output, null, 2)}
+          
+
+ )} + + {/* Error Result */} + {isFailed && ( +
+
Failed
+
{error}
+
+ )} + + {/* Cancelled Result */} + {isCancelled && ( +
+
Cancelled
+
+ The job was cancelled before completion. +
+
+ )} + + {/* Logs */} + {logs.length > 0 && ( +
+

Logs

+
+
    + {logs.map((log) => ( +
  • + + [{log.level}] + {' '} + {log.message} +
  • + ))} +
+
+
+ )} + + ) +} diff --git a/examples/fullstack-vercel-turso/app/routes/api.durably.$.ts b/examples/fullstack-vercel-turso/app/routes/api.durably.$.ts new file mode 100644 index 00000000..a060d54a --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/api.durably.$.ts @@ -0,0 +1,22 @@ +/** + * Durably API Route (Splat) + * + * GET /api/durably/subscribe?runId=xxx - SSE stream for single run + * GET /api/durably/runs/subscribe?jobName=xxx - SSE stream for run updates + * GET /api/durably/runs - List runs + * GET /api/durably/run?runId=xxx - Get single run + * POST /api/durably/trigger - Trigger a job + * POST /api/durably/retrigger?runId=xxx - Create a fresh run from a terminal run + * POST /api/durably/cancel?runId=xxx - Cancel a run + */ + +import { durablyHandler } from '~/lib/durably.server' +import type { Route } from './+types/api.durably.$' + +export async function loader({ request }: Route.LoaderArgs) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function action({ request }: Route.ActionArgs) { + return durablyHandler.handle(request, '/api/durably') +} diff --git a/examples/fullstack-vercel-turso/app/routes/api.worker.ts b/examples/fullstack-vercel-turso/app/routes/api.worker.ts new file mode 100644 index 00000000..907dddd4 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/api.worker.ts @@ -0,0 +1,31 @@ +/** + * Background Worker Endpoint + * + * Called by Vercel Cron to process pending jobs when no users are connected. + * Authenticated via CRON_SECRET to prevent unauthorized access. + * + * Vercel Cron sends GET requests with CRON_SECRET as a Bearer token. + * See: https://vercel.com/docs/cron-jobs + * + * GET /api/worker + */ + +import { durably } from '~/lib/durably.server' +import type { Route } from './+types/api.worker' + +export async function loader({ request }: Route.LoaderArgs) { + // Verify cron secret + const authHeader = request.headers.get('authorization') + const cronSecret = process.env.CRON_SECRET + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + return new Response('Unauthorized', { status: 401 }) + } + + // Initialize (migrate + start worker) if needed + await durably.init() + + // Process all pending jobs in this invocation + const processed = await durably.processUntilIdle() + + return Response.json({ processed }) +} diff --git a/examples/fullstack-vercel-turso/biome.json b/examples/fullstack-vercel-turso/biome.json new file mode 100644 index 00000000..1996ac5a --- /dev/null +++ b/examples/fullstack-vercel-turso/biome.json @@ -0,0 +1,11 @@ +{ + "root": false, + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "extends": ["../../biome.json"], + "css": { + "parser": { + "cssModules": true, + "tailwindDirectives": true + } + } +} diff --git a/examples/fullstack-vercel-turso/docker-compose.yml b/examples/fullstack-vercel-turso/docker-compose.yml new file mode 100644 index 00000000..d2ffcc1d --- /dev/null +++ b/examples/fullstack-vercel-turso/docker-compose.yml @@ -0,0 +1,10 @@ +services: + libsqld: + image: ghcr.io/tursodatabase/libsql-server:latest + ports: + - '8080:8080' # HTTP API + volumes: + - libsqld-data:/var/lib/sqld + +volumes: + libsqld-data: diff --git a/examples/fullstack-vercel-turso/package.json b/examples/fullstack-vercel-turso/package.json new file mode 100644 index 00000000..6808a757 --- /dev/null +++ b/examples/fullstack-vercel-turso/package.json @@ -0,0 +1,43 @@ +{ + "name": "example-fullstack-vercel-turso", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "docker compose up -d && react-router dev", + "dev:app": "react-router dev", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "prettier --experimental-cli --check .", + "format:fix": "prettier --experimental-cli --write ." + }, + "dependencies": { + "@coji/durably": "workspace:*", + "@coji/durably-react": "workspace:*", + "@libsql/kysely-libsql": "^0.4.1", + "@react-router/node": "7.13.1", + "@react-router/serve": "7.13.1", + "@vercel/react-router": "^1.2.5", + "isbot": "^5.1.35", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "7.13.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.6", + "@react-router/dev": "7.13.1", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^25.3.5", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "prettier": "^3.8.1", + "prettier-plugin-organize-imports": "^4.3.0", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.1.1" + } +} diff --git a/examples/fullstack-vercel-turso/prettier.config.js b/examples/fullstack-vercel-turso/prettier.config.js new file mode 100644 index 00000000..48dce797 --- /dev/null +++ b/examples/fullstack-vercel-turso/prettier.config.js @@ -0,0 +1,7 @@ +export default { + semi: false, + singleQuote: true, + trailingComma: 'all', + printWidth: 80, + plugins: ['prettier-plugin-organize-imports'], +} diff --git a/examples/fullstack-vercel-turso/public/favicon.ico b/examples/fullstack-vercel-turso/public/favicon.ico new file mode 100644 index 00000000..5dbdfcdd Binary files /dev/null and b/examples/fullstack-vercel-turso/public/favicon.ico differ diff --git a/examples/fullstack-vercel-turso/react-router.config.ts b/examples/fullstack-vercel-turso/react-router.config.ts new file mode 100644 index 00000000..2baedd4d --- /dev/null +++ b/examples/fullstack-vercel-turso/react-router.config.ts @@ -0,0 +1,7 @@ +import type { Config } from '@react-router/dev/config' +import { vercelPreset } from '@vercel/react-router/vite' + +export default { + ssr: true, + presets: [vercelPreset()], +} satisfies Config diff --git a/examples/fullstack-vercel-turso/tsconfig.json b/examples/fullstack-vercel-turso/tsconfig.json new file mode 100644 index 00000000..dc391a45 --- /dev/null +++ b/examples/fullstack-vercel-turso/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/examples/fullstack-vercel-turso/vercel.json b/examples/fullstack-vercel-turso/vercel.json new file mode 100644 index 00000000..8823ab3d --- /dev/null +++ b/examples/fullstack-vercel-turso/vercel.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "react-router", + "installCommand": "cd ../.. && pnpm install --frozen-lockfile", + "buildCommand": "cd ../.. && pnpm turbo run build --filter=example-fullstack-vercel-turso", + "crons": [ + { + "path": "/api/worker", + "schedule": "0 0 * * *" + } + ] +} diff --git a/examples/fullstack-vercel-turso/vite.config.ts b/examples/fullstack-vercel-turso/vite.config.ts new file mode 100644 index 00000000..de677b2b --- /dev/null +++ b/examples/fullstack-vercel-turso/vite.config.ts @@ -0,0 +1,8 @@ +import { reactRouter } from '@react-router/dev/vite' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 758443a5..c3f86acc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,79 @@ importers: specifier: ^6.1.1 version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + examples/fullstack-vercel-turso: + dependencies: + '@coji/durably': + specifier: workspace:* + version: link:../../packages/durably + '@coji/durably-react': + specifier: workspace:* + version: link:../../packages/durably-react + '@libsql/kysely-libsql': + specifier: ^0.4.1 + version: 0.4.1(kysely@0.28.11) + '@react-router/node': + specifier: 7.13.1 + version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@react-router/serve': + specifier: 7.13.1 + version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@vercel/react-router': + specifier: ^1.2.5 + version: 1.2.5(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(@react-router/node@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(isbot@5.1.35)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + isbot: + specifier: ^5.1.35 + version: 5.1.35 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + react-router: + specifier: 7.13.1 + version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@biomejs/biome': + specifier: ^2.4.6 + version: 2.4.6 + '@react-router/dev': + specifier: 7.13.1 + version: 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@types/node': + specifier: ^25.3.5 + version: 25.3.5 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + prettier: + specifier: ^3.8.1 + version: 3.8.1 + prettier-plugin-organize-imports: + specifier: ^4.3.0 + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vite-tsconfig-paths: + specifier: ^6.1.1 + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + examples/server-node: dependencies: '@coji/durably': @@ -1217,6 +1290,18 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1624,6 +1709,9 @@ packages: '@types/react-dom': optional: true + '@ts-morph/common@0.11.1': + resolution: {integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1654,6 +1742,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -1695,6 +1786,18 @@ packages: '@ungap/with-resolvers@0.1.0': resolution: {integrity: sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==} + '@vercel/react-router@1.2.5': + resolution: {integrity: sha512-y1GSzt+pZZkO53oUzpJzmeYkdQwgWF4nI9i5OtuM1h6ItgOppkYgtGze9SmRwon3B6m0gOQXiiRhcTB1kM7Hxg==} + peerDependencies: + '@react-router/dev': '7' + '@react-router/node': '7' + isbot: '5' + react: '>=18' + react-dom: '>=18' + + '@vercel/static-config@3.1.2': + resolution: {integrity: sha512-2d+TXr6K30w86a+WbMbGm2W91O0UzO5VeemZYBBUJbCjk/5FLLGIi8aV6RS2+WmaRvtcqNTn2pUA7nCOK3bGcQ==} + '@vitejs/plugin-react@5.1.4': resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1858,6 +1961,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv@8.6.3: + resolution: {integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==} + algoliasearch@5.49.1: resolution: {integrity: sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==} engines: {node: '>= 14.0.0'} @@ -1892,6 +1998,9 @@ packages: babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@0.0.8: resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} engines: {node: '>= 0.4'} @@ -1928,6 +2037,13 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1987,6 +2103,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + code-block-writer@10.1.1: + resolution: {integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==} + coincident@1.2.3: resolution: {integrity: sha512-Uxz3BMTWIslzeWjuQnizGWVg0j6khbvHUQ8+5BdM7WuJEm4ALXwq3wluYoB+uF68uPBz/oUOeJnYURKyfjexlA==} @@ -2008,6 +2127,9 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -2242,6 +2364,16 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2261,6 +2393,10 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -2324,6 +2460,10 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} @@ -2398,6 +2538,18 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2452,6 +2604,12 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-to-ts@1.6.4: + resolution: {integrity: sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2656,6 +2814,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -2675,6 +2837,10 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2696,6 +2862,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2708,6 +2877,11 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mlly@1.8.1: resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} @@ -2815,6 +2989,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} @@ -2864,6 +3041,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -3044,6 +3225,9 @@ packages: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -3114,6 +3298,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -3122,6 +3310,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -3340,6 +3531,10 @@ packages: resolution: {integrity: sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==} hasBin: true + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -3369,6 +3564,12 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-morph@12.0.0: + resolution: {integrity: sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==} + + ts-toolbelt@6.15.5: + resolution: {integrity: sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==} + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -3491,6 +3692,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4435,6 +4639,18 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + '@polka/url@1.0.0-next.29': {} '@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': @@ -4785,6 +5001,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@ts-morph/common@0.11.1': + dependencies: + fast-glob: 3.3.3 + minimatch: 3.1.5 + mkdirp: 1.0.4 + path-browserify: 1.0.1 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -4825,6 +5048,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -4868,6 +5093,22 @@ snapshots: '@ungap/with-resolvers@0.1.0': {} + '@vercel/react-router@1.2.5(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(@react-router/node@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(isbot@5.1.35)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-router/dev': 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@react-router/node': 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@vercel/static-config': 3.1.2 + isbot: 5.1.35 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + ts-morph: 12.0.0 + + '@vercel/static-config@3.1.2': + dependencies: + ajv: 8.6.3 + json-schema-to-ts: 1.6.4 + ts-morph: 12.0.0 + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -5078,6 +5319,13 @@ snapshots: agent-base@7.1.4: {} + ajv@8.6.3: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + algoliasearch@5.49.1: dependencies: '@algolia/abtesting': 1.15.1 @@ -5126,6 +5374,8 @@ snapshots: transitivePeerDependencies: - supports-color + balanced-match@1.0.2: {} + base64-js@0.0.8: {} base64-js@1.5.1: {} @@ -5174,6 +5424,15 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 @@ -5226,6 +5485,8 @@ snapshots: chownr@1.1.4: {} + code-block-writer@10.1.1: {} + coincident@1.2.3: dependencies: '@ungap/structured-clone': 1.3.0 @@ -5260,6 +5521,8 @@ snapshots: transitivePeerDependencies: - supports-color + concat-map@0.0.1: {} + confbox@0.1.8: {} confbox@0.2.4: {} @@ -5511,6 +5774,20 @@ snapshots: exsolve@1.0.8: {} + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -5524,6 +5801,10 @@ snapshots: file-uri-to-path@1.0.0: {} + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -5594,6 +5875,10 @@ snapshots: github-from-package@0.0.0: {} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + globrex@0.1.2: {} gopd@1.2.0: {} @@ -5674,6 +5959,14 @@ snapshots: ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-what@5.5.0: {} @@ -5732,6 +6025,13 @@ snapshots: jsesc@3.0.2: {} + json-schema-to-ts@1.6.4: + dependencies: + '@types/json-schema': 7.0.15 + ts-toolbelt: 6.15.5 + + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} kysely@0.28.11: {} @@ -5915,6 +6215,8 @@ snapshots: merge-descriptors@1.0.3: {} + merge2@1.4.1: {} + methods@1.1.2: {} micromark-util-character@2.1.1: @@ -5934,6 +6236,11 @@ snapshots: micromark-util-types@2.0.2: {} + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -5946,6 +6253,10 @@ snapshots: mimic-response@3.1.0: {} + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + minimist@1.2.8: {} minisearch@7.2.0: {} @@ -5954,6 +6265,8 @@ snapshots: mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} + mlly@1.8.1: dependencies: acorn: 8.16.0 @@ -6050,6 +6363,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-to-regexp@0.1.12: {} pathe@1.1.2: {} @@ -6095,6 +6410,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.3: {} pirates@4.0.7: {} @@ -6209,6 +6526,8 @@ snapshots: dependencies: side-channel: 1.1.0 + queue-microtask@1.2.3: {} + range-parser@1.2.1: {} raw-body@2.5.3: @@ -6270,6 +6589,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} rollup@4.59.0: @@ -6303,6 +6624,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -6540,6 +6865,10 @@ snapshots: dependencies: tldts-core: 7.0.24 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toidentifier@1.0.1: {} totalist@3.0.1: {} @@ -6560,6 +6889,13 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-morph@12.0.0: + dependencies: + '@ts-morph/common': 0.11.1 + code-block-writer: 10.1.1 + + ts-toolbelt@6.15.5: {} + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -6683,6 +7019,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {}