From 0f89efd65daa6592728845b64ef7eb6eef570908 Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 8 Mar 2026 22:47:32 +0900 Subject: [PATCH 01/10] feat: add fullstack-vercel-turso example Demonstrates Durably on Vercel serverless with Turso (libSQL): - onRequest lazy init for real-time SSE streaming - Vercel Cron background worker for processing interrupted jobs - Long-running report generation job (~90s, 21 steps) that exceeds serverless timeout to showcase step resumability across invocations - Local development with libsqld via Docker Co-Authored-By: Claude Opus 4.6 --- examples/fullstack-vercel-turso/.dockerignore | 4 + examples/fullstack-vercel-turso/.env.example | 7 + examples/fullstack-vercel-turso/.env.swp | Bin 0 -> 12288 bytes examples/fullstack-vercel-turso/.gitignore | 8 + examples/fullstack-vercel-turso/README.md | 74 ++++ examples/fullstack-vercel-turso/app/app.css | 12 + .../app/jobs/data-sync.ts | 56 +++ .../app/jobs/generate-report.ts | 242 +++++++++++ .../app/jobs/import-csv.ts | 92 +++++ .../fullstack-vercel-turso/app/jobs/index.ts | 26 ++ .../app/jobs/process-image.ts | 48 +++ .../app/lib/database.server.ts | 20 + .../app/lib/durably.server.ts | 41 ++ .../fullstack-vercel-turso/app/lib/durably.ts | 13 + examples/fullstack-vercel-turso/app/root.tsx | 75 ++++ examples/fullstack-vercel-turso/app/routes.ts | 7 + .../app/routes/_index.tsx | 156 +++++++ .../app/routes/_index/dashboard.tsx | 389 ++++++++++++++++++ .../app/routes/_index/data-sync-form.tsx | 47 +++ .../app/routes/_index/data-sync-progress.tsx | 41 ++ .../routes/_index/image-processing-form.tsx | 64 +++ .../_index/image-processing-progress.tsx | 41 ++ .../app/routes/_index/report-form.tsx | 65 +++ .../app/routes/_index/report-progress.tsx | 41 ++ .../app/routes/_index/run-progress.tsx | 125 ++++++ .../app/routes/api.durably.$.ts | 22 + .../app/routes/api.worker.ts | 28 ++ examples/fullstack-vercel-turso/biome.json | 11 + .../fullstack-vercel-turso/docker-compose.yml | 10 + examples/fullstack-vercel-turso/package.json | 43 ++ .../fullstack-vercel-turso/prettier.config.js | 7 + .../fullstack-vercel-turso/public/favicon.ico | Bin 0 -> 15086 bytes .../react-router.config.ts | 7 + examples/fullstack-vercel-turso/tsconfig.json | 27 ++ examples/fullstack-vercel-turso/vercel.json | 8 + .../fullstack-vercel-turso/vite.config.ts | 8 + pnpm-lock.yaml | 340 +++++++++++++++ 37 files changed, 2205 insertions(+) create mode 100644 examples/fullstack-vercel-turso/.dockerignore create mode 100644 examples/fullstack-vercel-turso/.env.example create mode 100644 examples/fullstack-vercel-turso/.env.swp create mode 100644 examples/fullstack-vercel-turso/.gitignore create mode 100644 examples/fullstack-vercel-turso/README.md create mode 100644 examples/fullstack-vercel-turso/app/app.css create mode 100644 examples/fullstack-vercel-turso/app/jobs/data-sync.ts create mode 100644 examples/fullstack-vercel-turso/app/jobs/generate-report.ts create mode 100644 examples/fullstack-vercel-turso/app/jobs/import-csv.ts create mode 100644 examples/fullstack-vercel-turso/app/jobs/index.ts create mode 100644 examples/fullstack-vercel-turso/app/jobs/process-image.ts create mode 100644 examples/fullstack-vercel-turso/app/lib/database.server.ts create mode 100644 examples/fullstack-vercel-turso/app/lib/durably.server.ts create mode 100644 examples/fullstack-vercel-turso/app/lib/durably.ts create mode 100644 examples/fullstack-vercel-turso/app/root.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes.ts create mode 100644 examples/fullstack-vercel-turso/app/routes/_index.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/_index/data-sync-form.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/_index/data-sync-progress.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/_index/image-processing-form.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/_index/image-processing-progress.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/_index/report-form.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/_index/report-progress.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/_index/run-progress.tsx create mode 100644 examples/fullstack-vercel-turso/app/routes/api.durably.$.ts create mode 100644 examples/fullstack-vercel-turso/app/routes/api.worker.ts create mode 100644 examples/fullstack-vercel-turso/biome.json create mode 100644 examples/fullstack-vercel-turso/docker-compose.yml create mode 100644 examples/fullstack-vercel-turso/package.json create mode 100644 examples/fullstack-vercel-turso/prettier.config.js create mode 100644 examples/fullstack-vercel-turso/public/favicon.ico create mode 100644 examples/fullstack-vercel-turso/react-router.config.ts create mode 100644 examples/fullstack-vercel-turso/tsconfig.json create mode 100644 examples/fullstack-vercel-turso/vercel.json create mode 100644 examples/fullstack-vercel-turso/vite.config.ts diff --git a/examples/fullstack-vercel-turso/.dockerignore b/examples/fullstack-vercel-turso/.dockerignore new file mode 100644 index 00000000..9b8d5147 --- /dev/null +++ b/examples/fullstack-vercel-turso/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file 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/.env.swp b/examples/fullstack-vercel-turso/.env.swp new file mode 100644 index 0000000000000000000000000000000000000000..d1d10517854192b3e89cfa06e3fae6bd6f059250 GIT binary patch literal 12288 zcmeI&F>jkd6bJB2mriM`MD1GIEXe@Ic2hTzGB`F(a110EV&lcafXx}q5jgBXrFQH` z=$B~5ewMCPKSbvqi8HiP)U1u(Nlz#2|2yEfS$<>jTvoU}p^DgghS0AMSNO-bmzx`( z5pu{3mmmLYl{tPswuO*5c6fLCV;QW(37Mu9lBN|mOYCu&SG-Gm9*169nPg#@Qrijm zg_k&9$kQxINk#CY#oedEHy{84_Y+u0^;%Udvsw0fJzt9|XSeRT;N2!H?xfB*=900@8p2!H?xfB*=9z<(%Uy9oVU zN9e^9_VDcg|J&dH-=89M#p)}oFRT<+BFmJy=VcZa2!H?xfB*=900@8p2!H?xfWQL~ z*y38crjB}2OP7p;oMZ`~dQRd|WSDwS8#PVicw}fNl6v6f9e;e}U=4TP4<+3=>&dAc z>AOx#ek%v@;Jnq@6Idv=24`5CNp-c5ch$mKYDP1a=V2hv2v*wCu2oY5t1nZv9W0fe zSXbI|z1#2j>g+S14>lHPY>-MELN)b@2 zX5~Xe$<>)zwG1_oBT=|4NRi>=yh9Z;$9yz(d^J36_hNg}C35Scnhl)Ek(*nS%fSh+ zPK>A@MrNZi%?n$pE*oNUL^a%RoS3{`OGqLf_wB>!3a8yPnupDvG&1$>L5RocMYvzN z9d_qkZ%+7f5MHpCDR0MoL=x(Ic1roGa6RuXV1l-|(}cL0LotcCZKE86-(f?$H~N3v xz^623$6t@$`y{3N@5FZ^>+hDc_?+u4ypY6mFQVM`&9k`N0^3}hIDwb!px-=P)BXSe literal 0 HcmV?d00001 diff --git a/examples/fullstack-vercel-turso/.gitignore b/examples/fullstack-vercel-turso/.gitignore new file mode 100644 index 00000000..61669a32 --- /dev/null +++ b/examples/fullstack-vercel-turso/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ + diff --git a/examples/fullstack-vercel-turso/README.md b/examples/fullstack-vercel-turso/README.md new file mode 100644 index 00000000..eed5d3c0 --- /dev/null +++ b/examples/fullstack-vercel-turso/README.md @@ -0,0 +1,74 @@ +# Fullstack Vercel + Turso Example + +Durably fullstack example deployed on Vercel with Turso (libSQL) as the database. + +## 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 every minute + +## How it works + +``` +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 → POST /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 (every minute) | +| `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..b97ab678 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/data-sync.ts @@ -0,0 +1,56 @@ +/** + * Data Sync Job + * + * Simulates syncing data with a remote server. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +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/generate-report.ts b/examples/fullstack-vercel-turso/app/jobs/generate-report.ts new file mode 100644 index 00000000..167d8c68 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/generate-report.ts @@ -0,0 +1,242 @@ +/** + * 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' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +const outputSchema = z.object({ + reportUrl: z.string(), + totalRecords: z.number(), + generatedAt: z.string(), +}) + +export type GenerateReportOutput = z.infer + +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) => { + const totalSteps = 21 + let currentStep = 0 + + step.log.info(`Starting ${input.reportType} report for ${input.department}`) + + // ── Phase 1: Data Collection (5 steps) ── + + const salesData = await step.run('collect-sales', async () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, 'Deduplicating records...') + await delay(5000) + const dupes = Math.floor(totalRecords * 0.03) + step.log.info(`Removed ${dupes} duplicate records`) + }) + + await step.run('normalize', async () => { + currentStep++ + step.progress(currentStep, totalSteps, 'Normalizing data formats...') + await delay(4000) + step.log.info('Data formats normalized') + }) + + await step.run('enrich', async () => { + currentStep++ + step.progress(currentStep, totalSteps, 'Enriching with external data...') + await delay(6000) + step.log.info('Data enriched with geo and demographic info') + }) + + await step.run('validate-data', async () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, 'Analyzing traffic patterns...') + await delay(4000) + step.log.info( + `Peak traffic: ${webAnalytics.uniqueVisitors} unique visitors`, + ) + }) + + await step.run('analyze-inventory', async () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, 'Generating revenue chart...') + await delay(3500) + step.log.info('Revenue trend chart generated') + }) + + await step.run('chart-customers', async () => { + currentStep++ + step.progress(currentStep, totalSteps, 'Generating customer chart...') + await delay(3000) + step.log.info('Customer growth chart generated') + }) + + await step.run('chart-support', async () => { + currentStep++ + step.progress(currentStep, totalSteps, 'Generating support chart...') + await delay(3000) + step.log.info('Support metrics chart generated') + }) + + await step.run('chart-traffic', async () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, 'Assembling PDF report...') + await delay(6000) + step.log.info('PDF report assembled (24 pages)') + }) + + const reportUrl = await step.run('upload-report', async () => { + currentStep++ + step.progress(currentStep, totalSteps, '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 () => { + currentStep++ + step.progress(currentStep, totalSteps, '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..03aed951 --- /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' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +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() }) + +/** Output type for use in components */ +export type ImportCsvOutput = z.infer + +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.name}...`) + await delay(50) + + if (row.amount < 0) { + invalid.push({ row, reason: `Invalid amount: ${row.amount}` }) + step.log.warn(`Validation failed for ${row.name}: 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.name}...`) + await delay(80) + + // Simulate import + imported++ + step.log.info(`Imported: ${row.name} (${row.email}) - $${row.amount}`) + } + + 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..9b4dbd00 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/index.ts @@ -0,0 +1,26 @@ +/** + * 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 } + +/** Type for ImportCsvOutput (for backward compatibility) */ +export type ImportCsvOutput = JobOutput + +/** 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 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..adc29065 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/jobs/process-image.ts @@ -0,0 +1,48 @@ +/** + * Process Image Job + * + * Simulates image processing with multiple steps. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +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..50abd787 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index.tsx @@ -0,0 +1,156 @@ +/** + * 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) { + 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..413584e2 --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx @@ -0,0 +1,389 @@ +/** + * 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, + 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 + +function LabelChips({ labels }: { labels: Record }) { + const entries = Object.entries(labels) + if (entries.length === 0) return - + return ( +
+ {entries.map(([k, v]) => ( + + {k}={v} + + ))} +
+ ) +} + +export function Dashboard() { + const { runs, isLoading, error, page, hasMore, nextPage, prevPage, refresh } = + durably.useRuns({ + pageSize: 6, + }) + + const { + cancel, + retrigger, + deleteRun, + getRun, + 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 = await getRun(runId) + if (run) { + setSelectedRun(run) + const stepsData = await getSteps(runId) + setSteps(stepsData) + } + } + + 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', + } + + 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..00807feb --- /dev/null +++ b/examples/fullstack-vercel-turso/app/routes/api.worker.ts @@ -0,0 +1,28 @@ +/** + * Background Worker Endpoint + * + * Called by Vercel Cron to process pending jobs when no users are connected. + * Authenticated via CRON_SECRET to prevent unauthorized access. + * + * POST /api/worker + */ + +import { durably } from '~/lib/durably.server' +import type { Route } from './+types/api.worker' + +export async function action({ request }: Route.ActionArgs) { + // 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 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 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..25930043 --- /dev/null +++ b/examples/fullstack-vercel-turso/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/worker", + "schedule": "* * * * *" + } + ] +} 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: {} From 8fec29977893e10b694ba006dbbfb8dae012714c Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 8 Mar 2026 22:47:50 +0900 Subject: [PATCH 02/10] chore: remove swp file and add *.swp to gitignore Co-Authored-By: Claude Opus 4.6 --- examples/fullstack-vercel-turso/.env.swp | Bin 12288 -> 0 bytes examples/fullstack-vercel-turso/.gitignore | 1 + 2 files changed, 1 insertion(+) delete mode 100644 examples/fullstack-vercel-turso/.env.swp diff --git a/examples/fullstack-vercel-turso/.env.swp b/examples/fullstack-vercel-turso/.env.swp deleted file mode 100644 index d1d10517854192b3e89cfa06e3fae6bd6f059250..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI&F>jkd6bJB2mriM`MD1GIEXe@Ic2hTzGB`F(a110EV&lcafXx}q5jgBXrFQH` z=$B~5ewMCPKSbvqi8HiP)U1u(Nlz#2|2yEfS$<>jTvoU}p^DgghS0AMSNO-bmzx`( z5pu{3mmmLYl{tPswuO*5c6fLCV;QW(37Mu9lBN|mOYCu&SG-Gm9*169nPg#@Qrijm zg_k&9$kQxINk#CY#oedEHy{84_Y+u0^;%Udvsw0fJzt9|XSeRT;N2!H?xfB*=900@8p2!H?xfB*=9z<(%Uy9oVU zN9e^9_VDcg|J&dH-=89M#p)}oFRT<+BFmJy=VcZa2!H?xfB*=900@8p2!H?xfWQL~ z*y38crjB}2OP7p;oMZ`~dQRd|WSDwS8#PVicw}fNl6v6f9e;e}U=4TP4<+3=>&dAc z>AOx#ek%v@;Jnq@6Idv=24`5CNp-c5ch$mKYDP1a=V2hv2v*wCu2oY5t1nZv9W0fe zSXbI|z1#2j>g+S14>lHPY>-MELN)b@2 zX5~Xe$<>)zwG1_oBT=|4NRi>=yh9Z;$9yz(d^J36_hNg}C35Scnhl)Ek(*nS%fSh+ zPK>A@MrNZi%?n$pE*oNUL^a%RoS3{`OGqLf_wB>!3a8yPnupDvG&1$>L5RocMYvzN z9d_qkZ%+7f5MHpCDR0MoL=x(Ic1roGa6RuXV1l-|(}cL0LotcCZKE86-(f?$H~N3v xz^623$6t@$`y{3N@5FZ^>+hDc_?+u4ypY6mFQVM`&9k`N0^3}hIDwb!px-=P)BXSe diff --git a/examples/fullstack-vercel-turso/.gitignore b/examples/fullstack-vercel-turso/.gitignore index 61669a32..d08c368b 100644 --- a/examples/fullstack-vercel-turso/.gitignore +++ b/examples/fullstack-vercel-turso/.gitignore @@ -6,3 +6,4 @@ /.react-router/ /build/ +*.swp From 1359e9548d5f2aceca027111eedf15d6774dd803 Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 8 Mar 2026 23:46:43 +0900 Subject: [PATCH 03/10] fix: use fixed step numbers and add missing GenerateReport type - Replace mutable currentStep counter with fixed step numbers in generate-report job. Step callbacks are skipped on resume, so a mutable counter would report wrong progress after restart. - Add GenerateReportInput/Output to DashboardRun union type. Co-Authored-By: Claude Opus 4.6 --- .../app/jobs/generate-report.ts | 69 +++++++------------ .../app/routes/_index/dashboard.tsx | 3 + 2 files changed, 28 insertions(+), 44 deletions(-) diff --git a/examples/fullstack-vercel-turso/app/jobs/generate-report.ts b/examples/fullstack-vercel-turso/app/jobs/generate-report.ts index 167d8c68..917a16f3 100644 --- a/examples/fullstack-vercel-turso/app/jobs/generate-report.ts +++ b/examples/fullstack-vercel-turso/app/jobs/generate-report.ts @@ -42,16 +42,17 @@ export const generateReportJob = defineJob({ }), output: outputSchema, run: async (step, input) => { - const totalSteps = 21 - let currentStep = 0 + // 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 () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Collecting sales data...') + step.progress(1, T, 'Collecting sales data...') await delay(4000) const records = Math.floor(Math.random() * 5000) + 3000 step.log.info(`Sales: ${records} records`) @@ -59,8 +60,7 @@ export const generateReportJob = defineJob({ }) const inventoryData = await step.run('collect-inventory', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Collecting inventory data...') + step.progress(2, T, 'Collecting inventory data...') await delay(3500) const items = Math.floor(Math.random() * 2000) + 1000 step.log.info(`Inventory: ${items} items`) @@ -68,8 +68,7 @@ export const generateReportJob = defineJob({ }) const customerData = await step.run('collect-customers', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Collecting customer data...') + step.progress(3, T, 'Collecting customer data...') await delay(4500) const customers = Math.floor(Math.random() * 1500) + 500 step.log.info(`Customers: ${customers} profiles`) @@ -77,8 +76,7 @@ export const generateReportJob = defineJob({ }) const supportData = await step.run('collect-support', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Collecting support tickets...') + step.progress(4, T, 'Collecting support tickets...') await delay(3000) const tickets = Math.floor(Math.random() * 300) + 100 step.log.info(`Support: ${tickets} tickets`) @@ -86,8 +84,7 @@ export const generateReportJob = defineJob({ }) const webAnalytics = await step.run('collect-analytics', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Collecting web analytics...') + 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`) @@ -104,30 +101,26 @@ export const generateReportJob = defineJob({ // ── Phase 2: Data Cleaning & Transformation (4 steps) ── await step.run('deduplicate', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Deduplicating records...') + 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 () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Normalizing data formats...') + step.progress(7, T, 'Normalizing data formats...') await delay(4000) step.log.info('Data formats normalized') }) await step.run('enrich', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Enriching with external data...') + 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 () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Validating data integrity...') + step.progress(9, T, 'Validating data integrity...') await delay(3000) step.log.info('Data validation passed') }) @@ -135,8 +128,7 @@ export const generateReportJob = defineJob({ // ── Phase 3: Analysis (5 steps) ── const revenueMetrics = await step.run('analyze-revenue', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Analyzing revenue trends...') + step.progress(10, T, 'Analyzing revenue trends...') await delay(4000) const growth = (Math.random() * 20 - 5).toFixed(1) step.log.info(`Revenue growth: ${growth}%`) @@ -144,24 +136,21 @@ export const generateReportJob = defineJob({ }) await step.run('analyze-retention', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Analyzing customer retention...') + 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 () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Analyzing support metrics...') + 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 () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Analyzing traffic patterns...') + step.progress(13, T, 'Analyzing traffic patterns...') await delay(4000) step.log.info( `Peak traffic: ${webAnalytics.uniqueVisitors} unique visitors`, @@ -169,8 +158,7 @@ export const generateReportJob = defineJob({ }) await step.run('analyze-inventory', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Analyzing inventory turnover...') + 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`) @@ -179,29 +167,25 @@ export const generateReportJob = defineJob({ // ── Phase 4: Chart Generation (4 steps) ── await step.run('chart-revenue', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Generating revenue chart...') + step.progress(15, T, 'Generating revenue chart...') await delay(3500) step.log.info('Revenue trend chart generated') }) await step.run('chart-customers', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Generating customer chart...') + step.progress(16, T, 'Generating customer chart...') await delay(3000) step.log.info('Customer growth chart generated') }) await step.run('chart-support', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Generating support chart...') + step.progress(17, T, 'Generating support chart...') await delay(3000) step.log.info('Support metrics chart generated') }) await step.run('chart-traffic', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Generating traffic chart...') + step.progress(18, T, 'Generating traffic chart...') await delay(3500) step.log.info('Traffic heatmap generated') }) @@ -209,15 +193,13 @@ export const generateReportJob = defineJob({ // ── Phase 5: Report Assembly & Delivery (3 steps) ── await step.run('assemble-pdf', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Assembling PDF report...') + 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 () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Uploading report...') + 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}`) @@ -225,8 +207,7 @@ export const generateReportJob = defineJob({ }) await step.run('send-notifications', async () => { - currentStep++ - step.progress(currentStep, totalSteps, 'Sending notifications...') + step.progress(21, T, 'Sending notifications...') await delay(2000) step.log.info( `Notification sent to ${input.department} team (revenue: $${revenueMetrics.total.toLocaleString()})`, diff --git a/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx index 413584e2..ef155b4e 100644 --- a/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx @@ -12,6 +12,8 @@ import { useState } from 'react' import type { DataSyncInput, DataSyncOutput, + GenerateReportInput, + GenerateReportOutput, ImportCsvInput, ImportCsvOutput, ProcessImageInput, @@ -22,6 +24,7 @@ import { durably } from '~/lib/durably' /** Union type for all job runs in this dashboard */ type DashboardRun = | TypedClientRun + | TypedClientRun | TypedClientRun | TypedClientRun From 9183553c3ac80f2d583df877484fc9c35fbb0ddf Mon Sep 17 00:00:00 2001 From: coji Date: Mon, 9 Mar 2026 10:56:38 +0900 Subject: [PATCH 04/10] fix: add explicit framework and build config to vercel.json Prevents Vercel from scanning sibling React Router apps in the monorepo by specifying framework, installCommand, and buildCommand explicitly. Co-Authored-By: Claude Opus 4.6 --- examples/fullstack-vercel-turso/vercel.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/fullstack-vercel-turso/vercel.json b/examples/fullstack-vercel-turso/vercel.json index 25930043..05192af3 100644 --- a/examples/fullstack-vercel-turso/vercel.json +++ b/examples/fullstack-vercel-turso/vercel.json @@ -1,4 +1,8 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "react-router", + "installCommand": "cd ../.. && pnpm install --frozen-lockfile", + "buildCommand": "cd ../.. && pnpm --filter example-fullstack-vercel-turso build", "crons": [ { "path": "/api/worker", From 3956cb3430871adbddd448412c690227942b0eb5 Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 12 Mar 2026 20:07:24 +0900 Subject: [PATCH 05/10] fix: fix Vercel deployment and polish example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix buildCommand typo (fullstack-react-router → fullstack-vercel-turso) - Remove unnecessary mkdir from buildCommand - Fix worker endpoint to use loader (GET) for Vercel Cron compatibility - Change cron schedule to daily (per-minute requires Pro plan) - Add .vercelignore to exclude sibling examples from builder scan - Add .vercel and .env*.local to gitignore - Parallelize getRun/getSteps calls in dashboard - Move constants outside component to avoid re-creation on render - Update README to reflect actual behavior Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 +++- CLAUDE.md | 13 +++++++++++ examples/fullstack-vercel-turso/.gitignore | 2 ++ examples/fullstack-vercel-turso/.vercelignore | 13 +++++++++++ examples/fullstack-vercel-turso/README.md | 12 ++++++---- .../app/routes/_index/dashboard.tsx | 23 +++++++++---------- .../app/routes/api.worker.ts | 7 ++++-- examples/fullstack-vercel-turso/vercel.json | 2 +- 8 files changed, 56 insertions(+), 20 deletions(-) create mode 100644 examples/fullstack-vercel-turso/.vercelignore 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/.gitignore b/examples/fullstack-vercel-turso/.gitignore index d08c368b..cce765d8 100644 --- a/examples/fullstack-vercel-turso/.gitignore +++ b/examples/fullstack-vercel-turso/.gitignore @@ -7,3 +7,5 @@ /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 index eed5d3c0..2b1f740a 100644 --- a/examples/fullstack-vercel-turso/README.md +++ b/examples/fullstack-vercel-turso/README.md @@ -1,6 +1,10 @@ # Fullstack Vercel + Turso Example -Durably fullstack example deployed on Vercel with Turso (libSQL) as the database. +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 @@ -8,7 +12,7 @@ Durably fullstack example deployed on Vercel with Turso (libSQL) as the database - **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 every minute + - **Background**: `/api/worker` endpoint called by Vercel Cron (requires Pro plan for per-minute schedule) ## How it works @@ -23,7 +27,7 @@ Worker processes → steps stream via SSE in real-time ↓ SSE disconnects → function terminates, worker stops ↓ -Vercel Cron → POST /api/worker processes any remaining pending jobs +Vercel Cron → GET /api/worker processes any remaining pending jobs ``` ## Setup @@ -70,5 +74,5 @@ The app connects to `http://localhost:8080` — same HTTP protocol as production | `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 (every minute) | +| `vercel.json` | Cron schedule (per-minute requires Pro) | | `react-router.config.ts` | Vercel preset configuration | diff --git a/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx index ef155b4e..370b4712 100644 --- a/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx @@ -45,6 +45,16 @@ function LabelChips({ labels }: { labels: Record }) { ) } +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({ @@ -80,24 +90,13 @@ export function Dashboard() { } const showDetails = async (runId: string) => { - const run = await getRun(runId) + const [run, stepsData] = await Promise.all([getRun(runId), getSteps(runId)]) if (run) { setSelectedRun(run) - const stepsData = await getSteps(runId) setSteps(stepsData) } } - 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', - } - return (

diff --git a/examples/fullstack-vercel-turso/app/routes/api.worker.ts b/examples/fullstack-vercel-turso/app/routes/api.worker.ts index 00807feb..907dddd4 100644 --- a/examples/fullstack-vercel-turso/app/routes/api.worker.ts +++ b/examples/fullstack-vercel-turso/app/routes/api.worker.ts @@ -4,13 +4,16 @@ * Called by Vercel Cron to process pending jobs when no users are connected. * Authenticated via CRON_SECRET to prevent unauthorized access. * - * POST /api/worker + * 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 action({ request }: Route.ActionArgs) { +export async function loader({ request }: Route.LoaderArgs) { // Verify cron secret const authHeader = request.headers.get('authorization') const cronSecret = process.env.CRON_SECRET diff --git a/examples/fullstack-vercel-turso/vercel.json b/examples/fullstack-vercel-turso/vercel.json index 05192af3..aea247b0 100644 --- a/examples/fullstack-vercel-turso/vercel.json +++ b/examples/fullstack-vercel-turso/vercel.json @@ -6,7 +6,7 @@ "crons": [ { "path": "/api/worker", - "schedule": "* * * * *" + "schedule": "0 0 * * *" } ] } From e89a70eb6af71cc413aa5f6d7130f24cd72c9fc8 Mon Sep 17 00:00:00 2001 From: coji Date: Thu, 12 Mar 2026 20:13:33 +0900 Subject: [PATCH 06/10] refactor: extract shared delay helper and remove redundant API call - Extract duplicated delay() into app/jobs/delay.ts - Use runs.find() instead of getRun() in dashboard showDetails - Simplify unnecessary JSX wrapper in _index.tsx Co-Authored-By: Claude Opus 4.6 --- examples/fullstack-vercel-turso/app/jobs/data-sync.ts | 3 +-- examples/fullstack-vercel-turso/app/jobs/delay.ts | 1 + examples/fullstack-vercel-turso/app/jobs/generate-report.ts | 3 +-- examples/fullstack-vercel-turso/app/jobs/import-csv.ts | 3 +-- examples/fullstack-vercel-turso/app/jobs/process-image.ts | 3 +-- examples/fullstack-vercel-turso/app/routes/_index.tsx | 4 +--- .../fullstack-vercel-turso/app/routes/_index/dashboard.tsx | 4 ++-- 7 files changed, 8 insertions(+), 13 deletions(-) create mode 100644 examples/fullstack-vercel-turso/app/jobs/delay.ts diff --git a/examples/fullstack-vercel-turso/app/jobs/data-sync.ts b/examples/fullstack-vercel-turso/app/jobs/data-sync.ts index b97ab678..ea9febe2 100644 --- a/examples/fullstack-vercel-turso/app/jobs/data-sync.ts +++ b/examples/fullstack-vercel-turso/app/jobs/data-sync.ts @@ -6,8 +6,7 @@ import { defineJob } from '@coji/durably' import { z } from 'zod' - -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) +import { delay } from './delay' export const dataSyncJob = defineJob({ name: 'data-sync', 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 index 917a16f3..ec45ed99 100644 --- a/examples/fullstack-vercel-turso/app/jobs/generate-report.ts +++ b/examples/fullstack-vercel-turso/app/jobs/generate-report.ts @@ -23,8 +23,7 @@ import { defineJob } from '@coji/durably' import { z } from 'zod' - -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) +import { delay } from './delay' const outputSchema = z.object({ reportUrl: z.string(), diff --git a/examples/fullstack-vercel-turso/app/jobs/import-csv.ts b/examples/fullstack-vercel-turso/app/jobs/import-csv.ts index 03aed951..c66cab01 100644 --- a/examples/fullstack-vercel-turso/app/jobs/import-csv.ts +++ b/examples/fullstack-vercel-turso/app/jobs/import-csv.ts @@ -8,8 +8,7 @@ import { defineJob } from '@coji/durably' import { z } from 'zod' - -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) +import { delay } from './delay' const csvRowSchema = z.object({ id: z.number(), diff --git a/examples/fullstack-vercel-turso/app/jobs/process-image.ts b/examples/fullstack-vercel-turso/app/jobs/process-image.ts index adc29065..4f185793 100644 --- a/examples/fullstack-vercel-turso/app/jobs/process-image.ts +++ b/examples/fullstack-vercel-turso/app/jobs/process-image.ts @@ -6,8 +6,7 @@ import { defineJob } from '@coji/durably' import { z } from 'zod' - -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) +import { delay } from './delay' export const processImageJob = defineJob({ name: 'process-image', diff --git a/examples/fullstack-vercel-turso/app/routes/_index.tsx b/examples/fullstack-vercel-turso/app/routes/_index.tsx index 50abd787..f47dd1c6 100644 --- a/examples/fullstack-vercel-turso/app/routes/_index.tsx +++ b/examples/fullstack-vercel-turso/app/routes/_index.tsx @@ -85,9 +85,7 @@ export default function Index() {
{/* Job Selection */}
-
-

Run Job

-
+

Run Job