Architecture
Technical architecture and design decisions for Scry.
System Overview
Scry is a distributed system for deploying and serving Storybook builds:
┌─────────────────────────────────────────────────────────────────────────────┐
│ User's CI/CD │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ Build │───▶│ Scry CLI │───▶│ ZIP Archive │ │
│ │ Storybook │ │ │ │ │ │
│ └────────────────┘ └────────────────┘ └───────┬────────┘ │
└──────────────────────────────────────────────────────┼──────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ Upload Service │
│ (Cloudflare Worker + Hono) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Auth │ │ Presigned│ │ Build │ │ Direct │ │
│ │ Check │──▶│ URL │──▶│ Track │──▶│ Upload │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Firestore│ │ R2 │ │
│ │ (builds) │ │ (files) │ │
│ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ CDN Service │
│ (Cloudflare Worker + Hono) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Subdomain│ │ Central │ │ Range │ │ File │ │
│ │ Parse │──▶│ Dir │──▶│ Request │──▶│ Serve │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────┐ │
│ │ │ KV │ │
│ │ │ (cache) │ │
│ │ └──────────┘ │
│ ▼ │
│ ┌──────────┐ │
│ │ R2 │ │
│ │ (ZIPs) │ │
│ └──────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘Design Principles
1. Edge-First
All services run at the edge:
- Cloudflare Workers for compute
- R2 for storage
- KV for caching
Benefits:
- Low latency globally
- No cold starts
- Automatic scaling
2. Storage as ZIP
Storybook builds are stored as ZIP files:
- Single object to manage
- Efficient partial extraction
- Atomic uploads
3. Presigned URLs
Direct uploads to R2:
- Reduces service bandwidth
- Faster uploads
- Lower costs
4. Service Abstraction
All services use interface abstractions:
typescript
// Storage abstraction
interface StorageService {
put(key: string, data: ArrayBuffer): Promise<void>;
get(key: string): Promise<ArrayBuffer | null>;
getPresignedUrl(key: string): Promise<string>;
}
// Implementations
class R2StorageService implements StorageService { }
class S3StorageService implements StorageService { }
class FilesystemStorageService implements StorageService { }Benefits:
- Portable across environments
- Easy testing
- Flexible deployment
Component Details
CLI (@scry/storybook-deployer)
Purpose: Build, package, and upload Storybook
Key modules:
bin/
├── cli.js # Entry point, argument parsing
lib/
├── archive.js # ZIP creation
├── apiClient.js # HTTP client
├── logger.js # Colored output
└── errors.js # Custom errorsFlow:
1. Parse arguments
2. Load config file
3. Create ZIP archive
4. Request presigned URL
5. Upload directly to R2Upload Service
Purpose: Authenticate uploads, track builds
Key modules:
src/
├── app.ts # Hono routes
├── entry.worker.ts # Worker entry
├── middleware/
│ └── auth.ts # API key validation
└── services/
├── storage/ # R2 operations
├── firestore/ # Build tracking
└── apikey/ # Key managementAuthentication flow:
1. Extract X-API-Key header
2. Validate format (scry_proj_{project}_{random})
3. Extract project ID from key
4. Hash key with SHA-256
5. Query Firestore for hash match
6. Verify status and expiration
7. Check project matches request
8. Update lastUsedAtCDN Service
Purpose: Serve files from ZIP archives
Key modules:
src/
├── routes/
│ └── zip-static.ts # File serving
├── services/
│ └── zip/
│ ├── central-directory.ts # ZIP metadata
│ └── extractor.ts # File extraction
└── adapters/
├── storage/ # R2/filesystem
└── zip/ # Range readerPartial extraction:
1. Parse subdomain for project ID
2. Load central directory from KV (or R2)
3. Find file entry in directory
4. Calculate byte offset and size
5. Range request to R2 for file bytes
6. Decompress if needed
7. Return with MIME typeDashboard
Purpose: Project and API key management
Key modules:
app/
├── (dashboard)/ # Protected routes
│ ├── projects/ # Project management
│ └── settings/ # User settings
├── api/ # API routes
└── login/ # Authentication
lib/
├── firebase.ts # Firebase init
├── firebase-provider.tsx # Auth context
└── services/ # Data accessData Model
Firestore Structure
projects/{projectId}/
├── name: string
├── description: string
├── ownerId: string
├── createdAt: Timestamp
│
├── builds/{buildId}/
│ ├── projectId: string
│ ├── versionId: string
│ ├── buildNumber: number
│ ├── zipUrl: string
│ ├── status: 'active' | 'archived'
│ └── createdAt: Timestamp
│
├── apiKeys/{keyId}/
│ ├── name: string
│ ├── prefix: string
│ ├── hash: string
│ ├── status: 'active' | 'revoked'
│ ├── createdAt: Timestamp
│ └── lastUsedAt: Timestamp
│
└── counters/builds/
└── currentBuildNumber: numberR2 Structure
static-sites/
└── {projectId}/
└── {versionId}.zipKV Structure
cd:{projectId}.zip → { central directory metadata }Security
API Key Security
- Never stored raw - Only SHA-256 hash
- Project-scoped - Can only access assigned project
- Revocable - Immediate effect on revocation
- Audited -
lastUsedAttracking
Request Validation
- Check API key format
- Validate project ownership
- Verify file type (ZIP only)
- Enforce size limits
Data Isolation
- Subdomain routing prevents cross-project access
- Firestore rules enforce owner-only access
- API keys bound to single project
Performance
Caching Strategy
| Data | Cache | TTL |
|---|---|---|
| Static files | Edge (Cloudflare) | 1 year |
| Central directory | KV | 24 hours |
| API key validation | None | Real-time |
Optimization Techniques
- Partial ZIP extraction - ~50x less data transfer
- KV caching - ~10ms vs ~50ms from R2
- Presigned URLs - Direct upload to storage
- Edge computing - No origin roundtrip
Extensibility
Adding New Storage Backend
typescript
// 1. Implement interface
class NewStorageService implements StorageService {
async put(key: string, data: ArrayBuffer): Promise<void> { }
async get(key: string): Promise<ArrayBuffer | null> { }
async getPresignedUrl(key: string): Promise<string> { }
}
// 2. Register in factory
function createStorage(type: string): StorageService {
switch (type) {
case 'r2': return new R2StorageService();
case 'new': return new NewStorageService();
}
}Adding New Auth Method
typescript
// 1. Create middleware
async function newAuth(c: Context, next: Next) {
const token = c.req.header('Authorization');
// Validate token
await next();
}
// 2. Add route
app.post('/upload/*', newAuth, uploadHandler);Future Considerations
Planned Features
- Webhooks for build events
- Build comparison/diff
- Team/organization support
- Custom domains per project
Scalability
Current architecture supports:
- Millions of requests/day
- Thousands of concurrent uploads
- Unlimited storage (R2)
Potential bottlenecks:
- Firestore writes (can shard)
- KV writes (can batch)
- Worker CPU (can optimize)
Next Steps
- Development Setup - Get started
- Code Style - Guidelines
- Services Overview - Detailed service docs