A pnpm monorepo containing a React frontend and Cloudflare Worker backends for volunteer registration form submissions.
signup/
├── form/ # React form frontend (deployed as Cloudflare Worker with Worker Assets)
├── submit/ # Form submission worker (Cloudflare Worker with D1 database)
├── export/ # Export worker (Cloudflare Worker with cron trigger for Google Sheets export)
├── pnpm-workspace.yaml
└── package.json
- Form (
signup-form): React/Vite application deployed as Cloudflare Worker with Worker Assets - Submit (
signup-submit): Cloudflare Worker with D1 database for form submissions - Export (
signup-export): Cloudflare Worker with cron trigger (every 6 hours) to export submissions to Google Sheets - Turnstile: Cloudflare Turnstile for bot protection (client and server-side validation)
- Rate Limiting: D1-based rate limiting to prevent spam/abuse
- Iframe Embedding: Configurable CSP headers to allow specific domains to embed the frontend
- Scheduled Exports: Automatic Google Sheets export via cron trigger
- Structured Logging: JSON logging for monitoring and debugging
- Node.js >= 18.0.0
- pnpm >= 9.0.0
- Cloudflare account with Workers and D1 enabled
- Wrangler CLI (
pnpm install -g wrangleror usepnpm exec wrangler)
pnpm installcd submit
pnpm db:createThis will output a database_id. Update both submit/wrangler.jsonc and export/wrangler.jsonc with the same database_id:
cd submit
pnpm db:migrateNote: Both submit and export workers use the same D1 database, so migrations only need to be run once.
{
"vars": {
"VITE_API_URL": "https://submit.signup.example.com",
"VITE_TURNSTILE_SITE_KEY": "your-turnstile-site-key",
"ALLOWED_IFRAME_DOMAINS": "example.com,another-domain.com"
}
}{
"vars": {
"TURNSTILE_SECRET_KEY": "your-turnstile-secret-key",
"ALLOWED_ORIGINS": "https://form.signup.example.com,https://example.com",
"RATE_LIMIT_REQUESTS": "5",
"RATE_LIMIT_WINDOW_SECONDS": "3600"
}
}{
"vars": {
"GOOGLE_SHEETS_API_KEY": "service-account-json-as-string",
"GOOGLE_SHEETS_SPREADSHEET_ID": "your-spreadsheet-id",
"GOOGLE_SHEETS_RANGE": "Sheet1!A:Z"
},
"triggers": {
"crons": ["0 */6 * * *"] // Every 6 hours
}
}- Go to Cloudflare Dashboard
- Navigate to Turnstile
- Create a new site
- Copy the Site Key (for frontend) and Secret Key (for backend)
- Create a Google Cloud Project
- Enable Google Sheets API
- Create a Service Account
- Download the service account JSON key
- Share your Google Spreadsheet with the service account email
- Set
GOOGLE_SHEETS_API_KEYto the path of the JSON file (or upload to Workers Secrets)
cd form
pnpm devForm will be available at http://localhost:3000
cd submit
pnpm devSubmit worker will be available at http://localhost:8787
cd export
pnpm devNote: The export worker runs on a cron schedule. In development, you can trigger it manually via HTTP POST request.
From the root directory:
pnpm devThis runs both frontend and backend in parallel.
cd form
pnpm buildcd submit
pnpm buildcd export
pnpm buildpnpm buildcd form
pnpm deployOr from root:
pnpm deploy:formcd submit
pnpm deployOr from root:
pnpm deploy:submitcd export
pnpm deployOr from root:
pnpm deploy:exportThe export worker will automatically run on the configured cron schedule (default: every 6 hours).
pnpm deployThe frontend can be embedded as an iframe on allowed domains. Configure allowed domains in frontend/wrangler.jsonc:
{
"vars": {
"ALLOWED_IFRAME_DOMAINS": "example.com,another-domain.com"
}
}The frontend worker automatically adds CSP frame-ancestors headers to control embedding.
<iframe src="https://form.signup.example.com/#/widget/signup?privacyUrl=https://example.com/privacy"></iframe>Submit a volunteer registration form.
Request Body:
{
"firstName": "Иван",
"middleName": "Петров",
"lastName": "Иванов",
"email": "ivan@example.com",
"phone": "+359888123456",
"egn": "1234567890",
"country": { "code": "BG", "name": "България" },
"region": { "code": "01", "name": "Благоевград" },
"municipality": { "code": "0101", "name": "Благоевград" },
"settlement": { "id": 1, "name": "Благоевград" },
"cityRegion": { "code": "01", "name": "Център" },
"pollingStation": { "id": "123", "place": "Училище" },
"travelAbility": "settlement",
"gdprConsent": true,
"role": "poll_watcher",
"turnstileToken": "turnstile-token-from-client"
}Response:
{
"success": true,
"message": "Registration successful",
"id": 123
}Manually trigger export to Google Sheets (for testing).
Note: In production, exports run automatically via cron schedule (every 6 hours by default).
Request:
POST https://signup-export.your-subdomain.workers.dev/Response:
{
"success": true,
"message": "Export completed successfully"
}Cron Schedule:
- Default: Every 6 hours (
0 */6 * * *) - Configure in
export/wrangler.jsoncundertriggers.crons
Health check endpoint.
Response:
{
"status": "ok",
"timestamp": "2024-01-01T00:00:00.000Z"
}Rate limiting is configured via environment variables:
RATE_LIMIT_REQUESTS: Maximum requests per window (default: 5)RATE_LIMIT_WINDOW_SECONDS: Time window in seconds (default: 3600)
Rate limits are applied per IP address and per Turnstile token to prevent token reuse.
When rate limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header.
See submit/schema.sql for the complete database schema.
Both submit and export workers share the same D1 database.
- volunteers: Stores form submissions
- rate_limits: Tracks rate limit usage per IP and Turnstile token
Structured JSON logging is used throughout the application. Logs are available in:
- Cloudflare Workers Dashboard → Logs
- Wrangler CLI output during development
Log entries include:
- Timestamp
- Log level (info, warn, error, debug)
- Context (path, method, IP address, etc.)
- Error details (for errors)
cd frontend
pnpm test(Add test setup as needed)
If migrations fail, ensure:
- Database is created (
cd submit && pnpm db:create) database_idis set in bothsubmit/wrangler.jsoncandexport/wrangler.jsonc- You're authenticated with Wrangler (
wrangler login)
Ensure ALLOWED_ORIGINS in worker/wrangler.jsonc includes:
- Frontend domain
- All domains that will embed the frontend as iframe
Check:
- Site key matches in frontend
- Secret key matches in backend
- Domain is registered in Turnstile dashboard
Ensure:
- Service account JSON key is stored as a Workers Secret or in
export/wrangler.jsonc - Spreadsheet is shared with service account email
GOOGLE_SHEETS_RANGEis correct format (e.g.,Sheet1!A:Z)- Cron trigger is configured in
export/wrangler.jsonc - Export worker is deployed (
pnpm deploy:export)
Note: JWT signing for Google Sheets API is not yet implemented. See TODO in export/src/handlers/export.ts.
[Add your license here]
{ "d1_databases": [ { "binding": "DB", "database_name": "signup-db", "database_id": "your-database-id-here" } ] }