FRE-4534: Remove standalone scripter files moved to ~/code/scripter
- brand/ (identity.md) - scripts/ (deploy, export, load-test, outreach) - server/types/ (project.ts — re-exported Drizzle types) - server/websocket/ (Yjs CRDT sync server) - .eslintrc.json (TypeScript ESLint config) - FRE-4510-IMPLEMENTATION.md - Merged FrenoCorp .gitignore entries into scripter's .gitignore Cross-dependent items (src/, src-tauri/, server/trpc/, marketing/, public/, dist/, docs/, .gitignore) delegated to FRE-4535 for favor-newer comparison. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"browser": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/strict-boolean-expressions": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist"]
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
# FRE-4510: Feature Flag Implementation for Spam Classification
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented a centralized feature flag management system for the SpamShield service, enabling runtime control over spam detection features without code deployment.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Feature Flag System (`apps/api/src/services/spamshield/feature-flags.ts`)
|
||||
|
||||
**New File** - Created a complete feature flag management system with:
|
||||
|
||||
- **FeatureFlagResolver Class**: Centralized flag resolution with priority order:
|
||||
1. Environment variables (allows runtime updates via `FLAG_<KEY>` variables)
|
||||
2. Resolution cache
|
||||
3. Default values
|
||||
|
||||
- **Pre-defined Flags**: 15 flags across three categories:
|
||||
|
||||
**SpamShield Flags** (8):
|
||||
- `spamshield.enable.number.reputation` - Number reputation checking
|
||||
- `spamshield.enable.content.classification` - SMS content classification
|
||||
- `spamshield.enable.behavioral.analysis` - Call behavioral analysis
|
||||
- `spamshield.enable.community.intelligence` - Community intelligence sharing
|
||||
- `spamshield.enable.real.time.blocking` - Real-time spam blocking
|
||||
- `spamshield.enable.multiple.sources` - Multiple reputation source aggregation
|
||||
- `spamshield.enable.ml.classifier` - ML-based spam classification
|
||||
|
||||
**VoicePrint Flags** (5):
|
||||
- `voiceprint.enable.ml.service` - ML service integration
|
||||
- `voiceprint.enable.faiss.index` - FAISS index for voice matching
|
||||
- `voiceprint.enable.batch.analysis` - Batch voice analysis
|
||||
- `voiceprint.enable.realtime.analysis` - Real-time voice analysis
|
||||
- `voiceprint.enable.mock.model` - Mock model for development
|
||||
|
||||
**Platform Flags** (2):
|
||||
- `platform.enable.audit.logs` - Comprehensive audit logging
|
||||
- `platform.enable.kpi.tracking` - KPI snapshot tracking
|
||||
|
||||
### 2. Updated SpamShield Config (`apps/api/src/services/spamshield/spamshield.config.ts`)
|
||||
|
||||
**Modified** - Integrated feature flag system:
|
||||
|
||||
```typescript
|
||||
import { checkFlag } from './feature-flags';
|
||||
|
||||
export const spamFeatureFlags = {
|
||||
enableNumberReputation: checkFlag('spamshield.enable.number.reputation', true),
|
||||
enableContentClassification: checkFlag('spamshield.enable.content.classification', true),
|
||||
enableBehavioralAnalysis: checkFlag('spamshield.enable.behavioral.analysis', true),
|
||||
enableCommunityIntelligence: checkFlag('spamshield.enable.community.intelligence', true),
|
||||
enableRealTimeBlocking: checkFlag('spamshield.enable.real.time.blocking', true),
|
||||
enableMultipleSources: checkFlag('spamshield.enable.multiple.sources', false),
|
||||
enableMLClassifier: checkFlag('spamshield.enable.ml.classifier', false),
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Updated SpamShield Service (`apps/api/src/services/spamshield/spamshield.service.ts`)
|
||||
|
||||
**Modified** - Added feature flag checks to all major services:
|
||||
|
||||
- **NumberReputationService.checkReputation()**: Returns early with zero values when flag is disabled
|
||||
- **NumberReputationService.checkMultiSource()**: Disables multi-source aggregation when flag is off
|
||||
- **SMSClassifierService.classify()**: Falls back to feature-based classification when ML flag disabled
|
||||
- **CallAnalysisService.analyzeCall()**: Skips behavioral analysis when flag disabled
|
||||
- **SpamFeedbackService.recordFeedback()**: Returns mock data when community intelligence disabled
|
||||
|
||||
### 4. Updated Index Exports
|
||||
|
||||
**Modified both index files** to export feature flag utilities:
|
||||
|
||||
```typescript
|
||||
// apps/api/src/services/spamshield/index.ts
|
||||
export { checkFlag, isFeatureEnabled } from './spamshield.config';
|
||||
export * from './feature-flags';
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Environment Variable Configuration
|
||||
|
||||
Set feature flags via environment variables:
|
||||
|
||||
```bash
|
||||
# Enable number reputation checking
|
||||
export FLAG_SPAMSHIELD_ENABLE_NUMBER_REPUTATION=true
|
||||
|
||||
# Disable ML classifier
|
||||
export FLAG_SPAMSHIELD_ENABLE_ML_CLASSIFIER=false
|
||||
|
||||
# Enable multiple sources
|
||||
export FLAG_SPAMSHIELD_ENABLE_MULTIPLE_SOURCES=true
|
||||
```
|
||||
|
||||
### Programmatic Access
|
||||
|
||||
```typescript
|
||||
import { checkFlag, isFeatureEnabled, featureFlags } from './spamshield';
|
||||
|
||||
// Check if a flag is enabled
|
||||
if (isFeatureEnabled('spamshield.enable.number.reputation', true)) {
|
||||
// Perform number reputation check
|
||||
}
|
||||
|
||||
// Get flag value with fallback
|
||||
const isEnabled = checkFlag('spamshield.enable.ml.classifier', false);
|
||||
|
||||
// List all flags
|
||||
console.log(featureFlags);
|
||||
```
|
||||
|
||||
### Runtime Flag Updates
|
||||
|
||||
Flags can be updated at runtime by setting environment variables:
|
||||
|
||||
```bash
|
||||
# In production, restart the service with new env vars
|
||||
FLAG_SPAMSHIELD_ENABLE_ML_CLASSIFIER=false npm run start
|
||||
```
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Unit Tests**: Test each service method with flags disabled
|
||||
2. **Integration Tests**: Verify feature gating works end-to-end
|
||||
3. **E2E Tests**: Test fallback behavior when ML/classification features disabled
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. [ ] Add unit tests for feature flag system
|
||||
2. [ ] Document flag definitions in README
|
||||
3. [ ] Set up CI/CD pipeline to validate flag configurations
|
||||
4. [ ] Create admin UI for flag management (future enhancement)
|
||||
|
||||
## Verification
|
||||
|
||||
✓ TypeScript compilation successful
|
||||
✓ All imports resolved correctly
|
||||
✓ Feature flags exported and accessible
|
||||
✓ Backward compatible with existing code
|
||||
@@ -1,189 +0,0 @@
|
||||
# Scripter Brand Identity
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** April 24, 2026
|
||||
**Owner:** CMO
|
||||
|
||||
---
|
||||
|
||||
## Product Name: Scripter
|
||||
|
||||
### Name Rationale
|
||||
|
||||
**Scripter** is the definitive name for our screenwriting platform. The name:
|
||||
|
||||
- **Clear category signal**: Immediately communicates "screenplay writing software"
|
||||
- **Professional credibility**: Matches industry terminology (screenwriters are "scripters")
|
||||
- **Memorable & concise**: One syllable, easy to spell, globally pronounceable
|
||||
- **Domain availability**: scripter.app, scripter.io, getscripter.com available
|
||||
- **Trademark clear**: No direct conflicts in software category
|
||||
|
||||
### Positioning Statement
|
||||
|
||||
**"Write screenplays faster, collaborate better, ship anywhere."**
|
||||
|
||||
Scripter is the modern screenwriting platform for professionals who demand speed, collaboration, and creative freedom. Built on a single codebase (Tauri + SolidJS) delivering native-feeling desktop apps and a blazing-fast web experience.
|
||||
|
||||
---
|
||||
|
||||
## Tagline
|
||||
|
||||
### Primary Tagline
|
||||
|
||||
**"Write Faster."**
|
||||
|
||||
Short, memorable, and speaks directly to the core benefit. Works across all channels:
|
||||
|
||||
- Website hero: "Scripter — Write Faster."
|
||||
- Social bio: "The modern screenwriting platform. Write Faster."
|
||||
- App store: "Scripter: Write Faster."
|
||||
|
||||
### Secondary Taglines (Context-Specific)
|
||||
|
||||
| Context | Tagline |
|
||||
|---------|---------|
|
||||
| Collaboration features | "Write together, in real-time." |
|
||||
| Performance messaging | "Faster than Final Draft. Smarter than WriterDuet." |
|
||||
| AI features | "Your AI co-writer, built in." |
|
||||
| Pricing page | "All the power. None of the bloat." |
|
||||
|
||||
---
|
||||
|
||||
## Logo Specification
|
||||
|
||||
### Existing Logo Asset
|
||||
|
||||
**File:** `/home/mike/code/scripter/src/assets/logo.svg`
|
||||
|
||||
**Description:** Abstract geometric mark featuring layered shapes in blue gradient tones, suggesting:
|
||||
- Pages of a screenplay
|
||||
- Forward momentum (upward angle)
|
||||
- Creative flow (organic curves within geometric structure)
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Scripter Blue (Primary) | `#518ac8` | Primary brand color, CTAs, links |
|
||||
| Sky Blue (Light) | `#76b3e1` | Gradients, backgrounds, highlights |
|
||||
| Deep Blue (Dark) | `#1a336b` | Text, borders, dark mode backgrounds |
|
||||
| Light Cyan (Accent) | `#dcf2fd` | Hover states, subtle backgrounds |
|
||||
|
||||
### Typography
|
||||
|
||||
**Primary Font:** System stack (SF Pro on macOS, Segoe UI on Windows, Inter fallback)
|
||||
|
||||
**Rationale:**
|
||||
- No web font loading = faster perceived performance
|
||||
- Native feel on each platform
|
||||
- Excellent readability for long-form writing
|
||||
|
||||
**Fallback Stack:**
|
||||
```css
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
```
|
||||
|
||||
### Logo Usage Rules
|
||||
|
||||
1. **Clear space:** Minimum 1x logo height on all sides
|
||||
2. **Minimum size:** 24px height for digital, 0.5" for print
|
||||
3. **Backgrounds:** Use white or Deep Blue backgrounds only
|
||||
4. **No effects:** No drop shadows, gradients, or distortions
|
||||
|
||||
### File Formats
|
||||
|
||||
| Format | Location | Usage |
|
||||
|--------|----------|-------|
|
||||
| SVG | `/src/assets/logo.svg` | Web, app, scalable print |
|
||||
| PNG (transparent) | `/public/logo.png` | Social media, presentations |
|
||||
| ICO | `/public/favicon.ico` | Browser favicon |
|
||||
| ICNS | Tauri build | macOS app icon |
|
||||
|
||||
---
|
||||
|
||||
## Brand Voice
|
||||
|
||||
### Personality Traits
|
||||
|
||||
| Trait | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| **Confident** | We know our product is better | "The last screenwriting tool you'll need." |
|
||||
| **Direct** | No marketing fluff | "$7.99/month. Cancel anytime." |
|
||||
| **Creative** | Speak to writers' aspirations | "Your next great scene starts here." |
|
||||
| **Technical** | Respect our audience's expertise | "Built on Tauri + SolidJS for native performance." |
|
||||
|
||||
### Tone by Channel
|
||||
|
||||
| Channel | Tone | Example |
|
||||
|---------|------|---------|
|
||||
| Website | Professional, benefit-focused | "Real-time collaboration. Zero latency." |
|
||||
| Social Media | Conversational, witty | "Final Draft costs $199. We cost less than your monthly coffee habit. ☕" |
|
||||
| Email | Helpful, personal | "Here's how to get the most out of Scripter." |
|
||||
| Documentation | Clear, technical | "Import Final Draft XML: File → Import → Select .fdx" |
|
||||
|
||||
---
|
||||
|
||||
## Messaging Framework
|
||||
|
||||
### Competitive Positioning
|
||||
|
||||
| Competitor | Our Advantage | Message |
|
||||
|------------|---------------|---------|
|
||||
| Final Draft | $199 one-time vs $7.99/month | "All the power, none of the price tag." |
|
||||
| WriterDuet | Modern stack, faster, more features | "Built for 2026, not 2012." |
|
||||
| Celtx | Professional tools, not freemium bait | "Pro tools for pro writers." |
|
||||
|
||||
### Core Value Propositions
|
||||
|
||||
1. **Speed:** "Native performance from a single codebase."
|
||||
2. **Collaboration:** "Write together in real-time, with video chat built in."
|
||||
3. **Affordability:** "Less than $8/month. Unlimited projects."
|
||||
4. **AI-Powered:** "Your AI co-writer helps you break through writer's block."
|
||||
5. **Cross-Platform:** "Mac, Windows, Linux, web. Your scripts go everywhere."
|
||||
|
||||
---
|
||||
|
||||
## Brand Applications
|
||||
|
||||
### Website
|
||||
|
||||
- **Hero:** "Scripter — Write Faster." + CTA "Start Writing Free"
|
||||
- **Features:** Icon + headline + 1-sentence benefit
|
||||
- **Pricing:** Simple 3-tier (Free / Pro $7.99 / Premium $10.99)
|
||||
- **Blog:** "The Scripter Blog" — screenwriting tips, feature updates
|
||||
|
||||
### App
|
||||
|
||||
- **Splash screen:** Logo on Deep Blue background
|
||||
- **Empty states:** Encouraging copy ("Your next masterpiece starts here.")
|
||||
- **Onboarding:** "Welcome to Scripter. Let's write something great."
|
||||
|
||||
### Social Media
|
||||
|
||||
- **Twitter/X:** @ScripterApp
|
||||
- **Instagram:** @scripterapp
|
||||
- **YouTube:** Scripter
|
||||
- **Reddit:** u/ScripterApp (r/Screenwriting, r/FinalDraft)
|
||||
|
||||
### Email
|
||||
|
||||
- **From:** "Scripter Team" <hello@scripter.app>
|
||||
- **Subject lines:** Benefit-focused, under 50 chars
|
||||
- **Signature:** "— The Scripter Team"
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Logo refinement:** Consider hiring designer on Fiverr/Upwork for polished SVG
|
||||
2. **Domain registration:** Secure scripter.app, getscripter.com
|
||||
3. **Social handles:** Reserve @ScripterApp across platforms
|
||||
4. **Brand guidelines PDF:** Expand this doc for external partners
|
||||
5. **Merch:** T-shirts, stickers for launch swag
|
||||
|
||||
---
|
||||
|
||||
## Related Issues
|
||||
|
||||
- [FRE-577](/FRE/issues/FRE-577) — Marketing website with pricing, features, and blog
|
||||
- [FRE-575](/FRE/issues/FRE-575) — Marketing expectations for WriterDuet competitor
|
||||
@@ -1,88 +0,0 @@
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Configuration
|
||||
const OUTPUT_DIR = path.join(__dirname, '..', 'marketing', 'product-hunt-assets', 'screenshots');
|
||||
const PAGES = [
|
||||
{ url: 'https://scripter.app/pricing', filename: 'ph-screenshot-01-pricing-1920x1080.png' },
|
||||
{ url: 'https://scripter.app/features', filename: 'ph-screenshot-02-features-1920x1080.png' },
|
||||
{ url: 'https://scripter.app/', filename: 'ph-screenshot-03-home-1920x1080.png' },
|
||||
{ url: 'https://scripter.app/waitlist', filename: 'ph-screenshot-04-waitlist-1920x1080.png' }
|
||||
];
|
||||
|
||||
// Chromium executable path (adjust if needed)
|
||||
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser';
|
||||
|
||||
async function captureScreenshots() {
|
||||
console.log('🎬 Starting Product Hunt screenshot capture...\n');
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
console.log(`✅ Created output directory: ${OUTPUT_DIR}\n`);
|
||||
}
|
||||
|
||||
let browser;
|
||||
try {
|
||||
// Launch browser
|
||||
console.log('🚀 Launching browser...');
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: CHROMIUM_PATH,
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
]
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
|
||||
// Capture each page
|
||||
for (const { url, filename } of PAGES) {
|
||||
try {
|
||||
console.log(`📸 Capturing: ${url}`);
|
||||
|
||||
await page.goto(url, {
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// Wait for any lazy-loaded content
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const outputPath = path.join(OUTPUT_DIR, filename);
|
||||
await page.screenshot({
|
||||
path: outputPath,
|
||||
fullPage: true,
|
||||
type: 'png'
|
||||
});
|
||||
|
||||
console.log(`✅ Saved: ${filename}\n`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to capture ${url}: ${error.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 Screenshot capture complete!');
|
||||
console.log(`📁 Files saved to: ${OUTPUT_DIR}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 Error:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
captureScreenshots();
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Deploy/update scripter.app frontend
|
||||
# Run from the FrenoCorp repo root after building
|
||||
# Usage: bash scripts/deploy-scripter.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Deploying scripter.app ==="
|
||||
|
||||
# 1. Build (if needed)
|
||||
if [ "$1" != "--skip-build" ]; then
|
||||
echo "[1/4] Building frontend..."
|
||||
npm run build
|
||||
else
|
||||
echo "[1/4] Skipping build (--skip-build)"
|
||||
fi
|
||||
|
||||
# 2. Copy to web directory
|
||||
echo "[2/4] Copying to web directory..."
|
||||
docker run --rm \
|
||||
-v /home/mike/code/FrenoCorp/dist:/dist:ro \
|
||||
-v /var/www/scripter:/target \
|
||||
alpine sh -c "cp -r /dist/* /target/ && chmod -R 755 /target/"
|
||||
echo " Copied $(find /var/www/scripter -type f | wc -l) files"
|
||||
|
||||
# 3. Reload nginx
|
||||
echo "[3/4] Reloading nginx..."
|
||||
if docker run --rm --pid=host --privileged alpine sh -c "kill -HUP 1280" 2>&1; then
|
||||
echo " Nginx reloaded"
|
||||
else
|
||||
echo " WARNING: Could not reload nginx (try manually: sudo systemctl reload nginx)"
|
||||
fi
|
||||
|
||||
# 4. Verify
|
||||
echo "[4/4] Verifying..."
|
||||
sleep 1
|
||||
HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" https://scripter.app/ --resolve scripter.app:443:66.108.41.120 2>/dev/null || echo "failed")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo " ✅ Site is serving HTTP 200"
|
||||
else
|
||||
echo " ❌ Site returned HTTP $HTTP_CODE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Deploy complete ==="
|
||||
echo "Verify at: curl -skI https://scripter.app/ --resolve scripter.app:443:66.108.41.120"
|
||||
@@ -1,58 +0,0 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { waitlistSignups } from "../src/db/schema/waitlist.js";
|
||||
import * as schema from "../src/db/schema/index.js";
|
||||
import { writeFile } from "fs/promises";
|
||||
|
||||
const DB_URL = process.env.TURSO_DATABASE_URL;
|
||||
const AUTH_TOKEN = process.env.TURSO_AUTH_TOKEN;
|
||||
|
||||
if (!DB_URL) {
|
||||
console.error("TURSO_DATABASE_URL is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function exportWaitlist() {
|
||||
const client = createClient({ url: DB_URL, authToken: AUTH_TOKEN });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
const signups = await db.select().from(waitlistSignups).orderBy(waitlistSignups.createdAt);
|
||||
|
||||
if (signups.length === 0) {
|
||||
console.log("No waitlist signups found.");
|
||||
await client.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonData = signups.map(s => ({
|
||||
id: s.id,
|
||||
email: s.email,
|
||||
name: s.name,
|
||||
source: s.source,
|
||||
status: s.status,
|
||||
metadata: s.metadata ? JSON.parse(s.metadata) : null,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
}));
|
||||
|
||||
const csvHeader = "id,email,name,source,status,metadata,createdAt,updatedAt";
|
||||
const csvRows = signups.map(s => {
|
||||
const meta = s.metadata ? `"${s.metadata.replace(/"/g, '""')}"` : "";
|
||||
return `${s.id},"${s.email}","${s.name || ""}","${s.source}","${s.status}",${meta},"${s.createdAt}","${s.updatedAt}"`;
|
||||
});
|
||||
const csv = [csvHeader, ...csvRows].join("\n");
|
||||
|
||||
await writeFile("waitlist-export.json", JSON.stringify(jsonData, null, 2));
|
||||
await writeFile("waitlist-export.csv", csv);
|
||||
|
||||
console.log(`Exported ${signups.length} signups`);
|
||||
console.log(" waitlist-export.json");
|
||||
console.log(" waitlist-export.csv");
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
exportWaitlist().catch(err => {
|
||||
console.error("Export failed:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { waitlistSignups } from "../src/db/schema/waitlist";
|
||||
import * as schema from "../src/db/schema";
|
||||
import { writeFile } from "fs/promises";
|
||||
|
||||
const DB_URL = process.env.TURSO_DATABASE_URL;
|
||||
const AUTH_TOKEN = process.env.TURSO_AUTH_TOKEN;
|
||||
|
||||
if (!DB_URL) {
|
||||
console.error("TURSO_DATABASE_URL is required");
|
||||
console.error("Set it via environment variable or .env file");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function exportWaitlist() {
|
||||
const client = createClient({ url: DB_URL!, authToken: AUTH_TOKEN ?? undefined });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
const signups = await db.select().from(waitlistSignups).orderBy(waitlistSignups.createdAt);
|
||||
|
||||
if (signups.length === 0) {
|
||||
console.log("No waitlist signups found.");
|
||||
await client.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${signups.length} signups\n`);
|
||||
|
||||
// JSON export
|
||||
const jsonData = signups.map(s => ({
|
||||
id: s.id,
|
||||
email: s.email,
|
||||
name: s.name,
|
||||
source: s.source,
|
||||
status: s.status,
|
||||
metadata: s.metadata ? JSON.parse(s.metadata) : null,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
}));
|
||||
|
||||
// CSV export
|
||||
const csvHeader = "id,email,name,source,status,metadata,createdAt,updatedAt";
|
||||
const csvRows = signups.map(s => {
|
||||
const meta = s.metadata ? `"${s.metadata.replace(/"/g, '""')}"` : "";
|
||||
return `${s.id},"${s.email}","${s.name || ""}","${s.source}","${s.status}",${meta},"${s.createdAt}","${s.updatedAt}"`;
|
||||
});
|
||||
const csv = [csvHeader, ...csvRows].join("\n");
|
||||
|
||||
const jsonFile = "waitlist-export.json";
|
||||
const csvFile = "waitlist-export.csv";
|
||||
|
||||
await writeFile(jsonFile, JSON.stringify(jsonData, null, 2));
|
||||
await writeFile(csvFile, csv);
|
||||
|
||||
console.log(`Exported ${signups.length} signups`);
|
||||
console.log(` JSON: ${jsonFile}`);
|
||||
console.log(` CSV: ${csvFile}`);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
exportWaitlist().catch(err => {
|
||||
console.error("Export failed:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
// Simple load test for FRE-634: Launch Week Technical Readiness
|
||||
// Tests concurrent user handling
|
||||
|
||||
import http from 'http';
|
||||
|
||||
const CONFIG = {
|
||||
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
|
||||
concurrentUsers: 1000,
|
||||
testDurationMs: 60000,
|
||||
endpoints: [
|
||||
'/',
|
||||
'/api/projects',
|
||||
'/api/characters',
|
||||
'/api/revisions'
|
||||
]
|
||||
};
|
||||
|
||||
async function makeRequest(endpoint) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
http.get(`${CONFIG.baseUrl}${endpoint}`, (res) => {
|
||||
resolve({
|
||||
endpoint,
|
||||
statusCode: res.statusCode,
|
||||
duration: Date.now() - start
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
reject({ endpoint, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTest() {
|
||||
console.log(`Starting load test: ${CONFIG.concurrentUsers} users, ${CONFIG.testDurationMs}ms`);
|
||||
console.log(`Target: ${CONFIG.baseUrl}`);
|
||||
|
||||
const results = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < CONFIG.testDurationMs) {
|
||||
const promises = [];
|
||||
for (let i = 0; i < CONFIG.concurrentUsers; i++) {
|
||||
const endpoint = CONFIG.endpoints[i % CONFIG.endpoints.length];
|
||||
promises.push(makeRequest(endpoint));
|
||||
}
|
||||
|
||||
const batchResults = await Promise.all(promises);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const successRate = results.filter(r => r.statusCode === 200).length / results.length * 100;
|
||||
const avgDuration = results.reduce((a, b) => a + b.duration, 0) / results.length;
|
||||
|
||||
console.log(`\nResults:`);
|
||||
console.log(`- Total requests: ${results.length}`);
|
||||
console.log(`- Success rate: ${successRate.toFixed(2)}%`);
|
||||
console.log(`- Average latency: ${avgDuration.toFixed(2)}ms`);
|
||||
console.log(`- Max concurrent: ${CONFIG.concurrentUsers}`);
|
||||
|
||||
return {
|
||||
totalRequests: results.length,
|
||||
successRate,
|
||||
avgLatency: avgDuration,
|
||||
maxConcurrent: CONFIG.concurrentUsers
|
||||
};
|
||||
}
|
||||
|
||||
loadTest().catch(console.error);
|
||||
@@ -1,316 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Priority 1 Influencer Outreach Email Sender
|
||||
* Issue: FRE-667
|
||||
* Send Date: April 26, 2026
|
||||
*
|
||||
* Contacts (5 total):
|
||||
* 1. John Finn - johnfinn@business.youtube.com
|
||||
* 2. No Film School - tips@nofilmschool.com
|
||||
* 3. Script Lab - info@scriptlab.com
|
||||
* 4. ScreenCraft - info@screencraft.org
|
||||
* 5. Go Into The Story - scott@thestorydepartment.com
|
||||
*/
|
||||
|
||||
import { Resend } from 'resend';
|
||||
|
||||
// Initialize Resend (free tier: 100 emails/day, 3000/month)
|
||||
const resend = new Resend(process.env.RESEND_API_KEY || 're_test_key');
|
||||
|
||||
const emails = [
|
||||
{
|
||||
to: 'johnfinn@business.youtube.com',
|
||||
subject: 'Free lifetime Pro account - modern screenwriting tool for your channel',
|
||||
name: 'John Finn',
|
||||
template: 'john-finn'
|
||||
},
|
||||
{
|
||||
to: 'tips@nofilmschool.com',
|
||||
subject: 'Beta access: Modern screenwriting platform for NFTS community',
|
||||
name: 'No Film School',
|
||||
template: 'no-film-school'
|
||||
},
|
||||
{
|
||||
to: 'info@scriptlab.com',
|
||||
subject: 'Collaboration: Beta access + potential partnership',
|
||||
name: 'Script Lab',
|
||||
template: 'script-lab'
|
||||
},
|
||||
{
|
||||
to: 'info@screencraft.org',
|
||||
subject: 'Beta partnership: Modern screenwriting tool for ScreenCraft community',
|
||||
name: 'ScreenCraft',
|
||||
template: 'screencraft'
|
||||
},
|
||||
{
|
||||
to: 'scott@thestorydepartment.com',
|
||||
subject: 'WGA blog + modern screenwriting tools - partnership opportunity?',
|
||||
name: 'Scott Myers',
|
||||
template: 'go-into-the-story'
|
||||
}
|
||||
];
|
||||
|
||||
// Email templates from /marketing/beta-outreach-priority-1.md
|
||||
const templates = {
|
||||
'john-finn': `
|
||||
<p>Hi John,</p>
|
||||
|
||||
<p>I've been following your channel for years - your Final Draft tutorials are legendary in the screenwriting community. The way you break down screenplay format is exactly what new writers need.</p>
|
||||
|
||||
<p>I'm reaching out from <strong>Scripter</strong>, a new screenwriting platform launching soon. We're building a modern alternative to Final Draft with:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Real-time collaboration</strong> (like Google Docs for screenplays)</li>
|
||||
<li><strong>AI writing assistant</strong> (optional, writer-controlled)</li>
|
||||
<li><strong>Cloud-native</strong> (works on any device, no install needed)</li>
|
||||
<li><strong>Affordable pricing</strong> (Pro at $9.99/month vs Final Draft's $200 one-time)</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>The Ask:</strong><br>
|
||||
I'd love to give you <strong>free lifetime Pro access</strong> in exchange for:</p>
|
||||
<ol>
|
||||
<li>Honest feedback on bugs, UX, features</li>
|
||||
<li>Optional: A video review if you genuinely like it (no pressure!)</li>
|
||||
</ol>
|
||||
|
||||
<p>We're limiting our beta to 500 writers, and I think your audience would love to see a modern alternative covered on your channel.</p>
|
||||
|
||||
<p><strong>Next Steps:</strong><br>
|
||||
Interested in a quick 15-min demo? Here's my Calendly: <a href="https://calendly.com/scripter-cmo">Calendly Link</a></p>
|
||||
|
||||
<p>Or just reply to this email and I'll get you set up with beta access immediately.</p>
|
||||
|
||||
<p>Thanks for all the amazing content you create for the screenwriting community!</p>
|
||||
|
||||
<p>Best,<br>
|
||||
CMO, Scripter</p>
|
||||
|
||||
<p><strong>P.S.</strong> Happy to provide an exclusive discount code for your viewers if/when we launch!</p>
|
||||
`,
|
||||
'no-film-school': `
|
||||
<p>Hi NFTS Team,</p>
|
||||
|
||||
<p>Love what you're doing with No Film School - it's the go-to resource for indie filmmakers and screenwriters.</p>
|
||||
|
||||
<p>I'm reaching out from <strong>Scripter</strong>, a new screenwriting platform built for how writers actually work in 2026:</p>
|
||||
|
||||
<p><strong>Key Features:</strong></p>
|
||||
<ul>
|
||||
<li>Real-time collaboration (multiple writers in the same script)</li>
|
||||
<li>AI-assisted outlining and dialogue suggestions</li>
|
||||
<li>Cloud-native, works on any device</li>
|
||||
<li>Free tier + Pro at $9.99/month (vs Final Draft's $200)</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>The Opportunity:</strong><br>
|
||||
We're launching our beta program (500 users max) and would love to have the NFTS community represented. We can offer:</p>
|
||||
|
||||
<ol>
|
||||
<li><strong>Free lifetime Pro accounts</strong> for your team</li>
|
||||
<li><strong>Exclusive discount code</strong> for your readers/viewers</li>
|
||||
<li><strong>Guest post opportunity</strong>: "How AI and collaboration tools are changing screenwriting" (no pitch, pure value)</li>
|
||||
</ol>
|
||||
|
||||
<p>We're not asking for coverage - just honest feedback from people who actually know filmmaking.</p>
|
||||
|
||||
<p>Interested in early access?</p>
|
||||
|
||||
<p>Best,<br>
|
||||
CMO, Scripter</p>
|
||||
|
||||
<p><strong>P.S.</strong> We're launching on Product Hunt May 7 - happy to coordinate if you're interested in featuring us!</p>
|
||||
`,
|
||||
'script-lab': `
|
||||
<p>Hi Script Lab Team,</p>
|
||||
|
||||
<p>I've been following Script Lab for years - your screenplay analysis videos and software reviews are incredibly valuable to the screenwriting community.</p>
|
||||
|
||||
<p>I'm reaching out from <strong>Scripter</strong>, a new screenwriting platform launching soon. Given that you've reviewed Final Draft, WriterDuet, and other tools, I thought you might be interested in what we're building.</p>
|
||||
|
||||
<p><strong>What Makes Scripter Different:</strong></p>
|
||||
<ul>
|
||||
<li><strong>Real-time collaboration</strong> (Final Draft wishes it had this)</li>
|
||||
<li><strong>AI writing assistant</strong> (writer-controlled, optional)</li>
|
||||
<li><strong>Cloud-native</strong> (no install, works anywhere)</li>
|
||||
<li><strong>Modern pricing</strong> (Free tier + $9.99/month Pro)</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Partnership Opportunity:</strong><br>
|
||||
We're launching our beta program and would love to partner with Script Lab:</p>
|
||||
|
||||
<ol>
|
||||
<li><strong>Free lifetime Pro access</strong> for your team</li>
|
||||
<li><strong>Exclusive early review opportunity</strong> (embargoed access if you want)</li>
|
||||
<li><strong>Affiliate program</strong> (we can discuss revenue share)</li>
|
||||
<li><strong>Guest content exchange</strong> (we'll write for your blog, you guest post on ours)</li>
|
||||
</ol>
|
||||
|
||||
<p>We're limiting beta to 500 users, and I'd love to have Script Lab as one of our founding partners.</p>
|
||||
|
||||
<p>Interested in chatting?</p>
|
||||
|
||||
<p>Best,<br>
|
||||
CMO, Scripter</p>
|
||||
`,
|
||||
'screencraft': `
|
||||
<p>Hi ScreenCraft Team,</p>
|
||||
|
||||
<p>Huge fan of what you're doing with ScreenCraft - the competitions, resources, and blog are incredibly valuable for working screenwriters.</p>
|
||||
|
||||
<p>I'm reaching out from <strong>Scripter</strong>, a new screenwriting platform launching in May 2026. We're building a modern alternative to Final Draft with real-time collaboration and AI assistance.</p>
|
||||
|
||||
<p><strong>Why I'm Reaching Out:</strong><br>
|
||||
Your community is exactly who we're building for - serious writers who want professional tools without the $200 price tag.</p>
|
||||
|
||||
<p><strong>Partnership Ideas:</strong></p>
|
||||
<ol>
|
||||
<li><strong>Beta access for ScreenCraft community</strong> - Free Pro accounts for competition winners/finalists</li>
|
||||
<li><strong>Educational discount</strong> - Special pricing for your readers</li>
|
||||
<li><strong>Co-hosted webinar</strong> - "The Future of Screenwriting Tools" (no pitch, pure education)</li>
|
||||
<li><strong>Sponsored content</strong> - We'll write educational posts for your blog</li>
|
||||
</ol>
|
||||
|
||||
<p><strong>What We're Asking:</strong></p>
|
||||
<ul>
|
||||
<li>Honest feedback from your team on our beta</li>
|
||||
<li>Willingness to explore partnership opportunities</li>
|
||||
<li>Optional: Mention in your newsletter if you think it's valuable for your readers</li>
|
||||
</ul>
|
||||
|
||||
<p>We're not asking for free coverage - we want to provide genuine value to your community.</p>
|
||||
|
||||
<p>Interested in exploring this?</p>
|
||||
|
||||
<p>Best,<br>
|
||||
CMO, Scripter</p>
|
||||
`,
|
||||
'go-into-the-story': `
|
||||
<p>Hi Scott,</p>
|
||||
|
||||
<p>I've been reading Go Into The Story since the beginning - it's the gold standard for screenwriting education. Your posts on story structure have taught me more than any book.</p>
|
||||
|
||||
<p>I'm reaching out from <strong>Scripter</strong>, a new screenwriting platform launching soon. Given that you write about the craft (not just tools), I wanted to get your perspective on what we're building.</p>
|
||||
|
||||
<p><strong>The Vision:</strong><br>
|
||||
We believe screenwriting tools should:</p>
|
||||
<ol>
|
||||
<li><strong>Get out of the way</strong> and let you write</li>
|
||||
<li><strong>Enable collaboration</strong> (writing is often a team sport)</li>
|
||||
<li><strong>Use AI thoughtfully</strong> (assist, don't replace)</li>
|
||||
<li><strong>Be accessible</strong> (free tier, affordable Pro)</li>
|
||||
</ol>
|
||||
|
||||
<p><strong>The Ask:</strong><br>
|
||||
I'd love to offer you <strong>free lifetime Pro access</strong> for your own writing, no strings attached. If you find it valuable and want to mention it to your readers, that's great - but no pressure at all.</p>
|
||||
|
||||
<p>We're also happy to:</p>
|
||||
<ul>
|
||||
<li>Write a guest post on "How Technology is Changing Screenwriting"</li>
|
||||
<li>Sponsor a Screenwriting Soirée or event</li>
|
||||
<li>Provide beta access for WGA members</li>
|
||||
</ul>
|
||||
|
||||
<p>Would you be open to a quick call to discuss?</p>
|
||||
|
||||
<p>Best,<br>
|
||||
CMO, Scripter</p>
|
||||
|
||||
<p><strong>P.S.</strong> I know you get pitched constantly - this isn't a pitch for coverage. Just offering a tool that might help your writing.</p>
|
||||
`
|
||||
};
|
||||
|
||||
async function sendEmails() {
|
||||
console.log('🚀 Starting Priority 1 influencer outreach...\n');
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const email of emails) {
|
||||
try {
|
||||
console.log(`📧 Sending to ${email.name} (${email.to})...`);
|
||||
|
||||
const data = await resend.emails.send({
|
||||
from: 'Scripter CMO <cmo@scripter.app>',
|
||||
to: [email.to],
|
||||
subject: email.subject,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #1a336b; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
a { color: #518ac8; }
|
||||
ul, ol { padding-left: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${templates[email.template]}
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
headers: {
|
||||
'X-Priority': '1',
|
||||
'X-Campaign': 'FRE-667-Priority1-Outreach'
|
||||
}
|
||||
});
|
||||
|
||||
results.push({
|
||||
contact: email.name,
|
||||
email: email.to,
|
||||
status: 'sent',
|
||||
id: data.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`✅ Sent successfully (ID: ${data.id})\n`);
|
||||
|
||||
// Rate limiting: wait 2 seconds between emails
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to send to ${email.name}:`, error.message);
|
||||
results.push({
|
||||
contact: email.name,
|
||||
email: email.to,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 SEND SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const sent = results.filter(r => r.status === 'sent').length;
|
||||
const failed = results.filter(r => r.status === 'failed').length;
|
||||
|
||||
console.log(`\n✅ Sent: ${sent}/${emails.length}`);
|
||||
console.log(`❌ Failed: ${failed}/${emails.length}`);
|
||||
console.log('\nDetailed Results:');
|
||||
|
||||
results.forEach(r => {
|
||||
console.log(` ${r.status === 'sent' ? '✅' : '❌'} ${r.contact} (${r.email})`);
|
||||
if (r.id) console.log(` ID: ${r.id}`);
|
||||
if (r.error) console.log(` Error: ${r.error}`);
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📅 Follow-up Schedule:');
|
||||
console.log(' • Follow-up #1: April 29 (Day 3)');
|
||||
console.log(' • Follow-up #2: May 3 (Day 7)');
|
||||
console.log(' • Follow-up #3: May 10 (Day 14 - break up)');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (process.argv[1]?.includes('send-priority-1-outreach.js')) {
|
||||
sendEmails().catch(console.error);
|
||||
}
|
||||
|
||||
export { sendEmails };
|
||||
@@ -1,20 +0,0 @@
|
||||
import type {
|
||||
Project as DrizzleProject,
|
||||
Character as DrizzleCharacter,
|
||||
CharacterRelationship as DrizzleCharacterRelationship,
|
||||
Scene as DrizzleScene,
|
||||
} from '../../src/db/schema';
|
||||
|
||||
// Re-export Drizzle types as the canonical types
|
||||
export type Project = DrizzleProject;
|
||||
export type Character = DrizzleCharacter;
|
||||
export type CharacterRelationship = DrizzleCharacterRelationship;
|
||||
export type Scene = DrizzleScene;
|
||||
|
||||
export interface CharacterStats {
|
||||
characterId: number;
|
||||
totalScreenTime: number;
|
||||
totalDialogueLines: number;
|
||||
sceneCount: number;
|
||||
relationshipCount: number;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* WebSocket Server Entry Point
|
||||
* Starts the Yjs sync server with JWT authentication
|
||||
*/
|
||||
|
||||
import { createWebSocketServer } from './websocket/server';
|
||||
|
||||
interface ServerConfig {
|
||||
port: number;
|
||||
jwtSecret: string;
|
||||
enableAuth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the WebSocket sync server
|
||||
*/
|
||||
export async function startServer(config: ServerConfig) {
|
||||
const { port, jwtSecret, enableAuth } = config;
|
||||
|
||||
// Auth middleware for JWT token validation
|
||||
const authMiddleware = async (token: string) => {
|
||||
if (!enableAuth) {
|
||||
return { userId: 'anonymous', projectId: 'default' };
|
||||
}
|
||||
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const decoded = jwt.verify(token, jwtSecret);
|
||||
return {
|
||||
userId: (decoded as any).userId,
|
||||
projectId: (decoded as any).projectId,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error('Invalid JWT token');
|
||||
}
|
||||
};
|
||||
|
||||
const server = createWebSocketServer(port, {
|
||||
authMiddleware: enableAuth ? authMiddleware : undefined,
|
||||
});
|
||||
|
||||
server.on('listening', () => {
|
||||
console.log(`WebSocket sync server listening on port ${port}`);
|
||||
console.log(`Authentication ${enableAuth ? 'enabled' : 'disabled'}`);
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
console.error('WebSocket server error:', error);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down WebSocket server...');
|
||||
server.clients.forEach((client) => client.close());
|
||||
server.close(() => {
|
||||
console.log('WebSocket server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// If run directly, start the server
|
||||
if (require.main === module) {
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_SECRET environment variable is required. Please set it before starting the server.');
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
port: parseInt(process.env.WS_PORT || '8080', 10),
|
||||
jwtSecret,
|
||||
enableAuth: process.env.ENABLE_AUTH !== 'false',
|
||||
};
|
||||
|
||||
startServer(config).catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* WebSocket Server for Yjs CRDT Sync
|
||||
* Node.js server using y-websocket adapter
|
||||
*/
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { applyUpdate, encodeStateAsUpdate, Doc } from 'yjs';
|
||||
|
||||
type DocMessage = {
|
||||
type: 'sync';
|
||||
args: [Uint8Array];
|
||||
};
|
||||
|
||||
type SyncStep1Message = {
|
||||
type: 'sync';
|
||||
args: [Uint8Array];
|
||||
};
|
||||
|
||||
type SyncStep2Message = {
|
||||
type: 'sync';
|
||||
args: [Uint8Array, Uint8Array];
|
||||
};
|
||||
|
||||
type UpdateMessage = {
|
||||
type: 'update';
|
||||
args: [Uint8Array];
|
||||
};
|
||||
|
||||
export type Message = DocMessage | SyncStep1Message | SyncStep2Message | UpdateMessage;
|
||||
|
||||
// Store document states in memory (in production, use Redis or persistent storage)
|
||||
const docs: Map<string, Uint8Array> = new Map();
|
||||
const clients: Map<string, Set<WebSocket>> = new Map();
|
||||
|
||||
interface WebSocketWithDoc extends WebSocket {
|
||||
docName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the WebSocket server
|
||||
*/
|
||||
export function createWebSocketServer(
|
||||
port: number,
|
||||
options: {
|
||||
authMiddleware?: (token: string) => Promise<{ userId: string; projectId: string }>;
|
||||
} = {}
|
||||
): WebSocketServer {
|
||||
const { authMiddleware } = options;
|
||||
|
||||
const server = new WebSocketServer({ port });
|
||||
|
||||
server.on('connection', async (ws: WebSocketWithDoc, req) => {
|
||||
// Extract document name from URL query params
|
||||
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
||||
const docName = url.pathname.split('/').pop() || 'default';
|
||||
|
||||
// Validate origin to prevent WebSocket CSRF
|
||||
const origin = req.headers.origin;
|
||||
if (authMiddleware && origin) {
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'];
|
||||
if (!allowedOrigins.includes(origin)) {
|
||||
console.warn(`Origin validation failed: ${origin}`);
|
||||
ws.close(4002);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate connection if auth middleware provided
|
||||
const token = url.searchParams.get('token');
|
||||
let userId: string | undefined;
|
||||
let projectId: string | undefined;
|
||||
|
||||
if (authMiddleware) {
|
||||
if (!token) {
|
||||
console.warn('Authentication required but no token provided');
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
||||
ws.close(4001);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await authMiddleware(token);
|
||||
userId = auth.userId;
|
||||
projectId = auth.projectId;
|
||||
console.log(`WebSocket connection authenticated: ${userId} for ${docName}`);
|
||||
} catch (error) {
|
||||
console.error('Authentication failed:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Authentication failed' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ws.docName = docName;
|
||||
|
||||
// Initialize document state if not exists
|
||||
if (!docs.has(docName)) {
|
||||
docs.set(docName, new Uint8Array());
|
||||
clients.set(docName, new Set());
|
||||
}
|
||||
|
||||
// Add client to the document's client set
|
||||
clients.get(docName)!.add(ws);
|
||||
|
||||
// Send initial sync
|
||||
const initialState = docs.get(docName)!;
|
||||
ws.send(encodeSyncStep1(initialState));
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on('message', (data: Buffer | ArrayBuffer) => {
|
||||
handleMessage(ws, docName, data, userId, projectId);
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
ws.on('close', () => {
|
||||
clients.get(docName)?.delete(ws);
|
||||
console.log(`Client disconnected from ${docName}. Remaining clients: ${clients.get(docName)?.size || 0}`);
|
||||
});
|
||||
|
||||
console.log(`Client connected to ${docName}${userId ? ` (user: ${userId})` : ''}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode sync step 1 (send document state as binary)
|
||||
*/
|
||||
function encodeSyncStep1(state: Uint8Array): Uint8Array {
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket message
|
||||
*/
|
||||
function handleMessage(
|
||||
ws: WebSocketWithDoc,
|
||||
docName: string,
|
||||
data: Buffer | ArrayBuffer,
|
||||
userId?: string,
|
||||
projectId?: string
|
||||
) {
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as Message;
|
||||
|
||||
switch (message.type) {
|
||||
case 'sync':
|
||||
handleSync(ws, docName, message, userId, projectId);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
handleUpdate(ws, docName, message, userId);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling message:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sync message
|
||||
*/
|
||||
function handleSync(ws: WebSocketWithDoc, docName: string, message: SyncStep1Message | SyncStep2Message) {
|
||||
const currentState = docs.get(docName) || new Uint8Array();
|
||||
|
||||
if (message.args.length === 1) {
|
||||
// Sync step 1: client sends its state, server responds with full state
|
||||
const clientState = message.args[0];
|
||||
|
||||
// Send full document state to client
|
||||
const response = encodeSyncStep1(currentState);
|
||||
ws.send(response);
|
||||
} else if (message.args.length === 2) {
|
||||
// Sync step 2: client sends its state, server sends missing updates
|
||||
const clientState = message.args[0];
|
||||
|
||||
// Calculate missing updates (simplified - in production use Yjs protocol)
|
||||
const missingUpdates = currentState;
|
||||
|
||||
const response = JSON.stringify({
|
||||
type: 'sync',
|
||||
args: [Array.from(missingUpdates)],
|
||||
});
|
||||
ws.send(new TextEncoder().encode(response));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle update message
|
||||
*/
|
||||
function handleUpdate(ws: WebSocketWithDoc, docName: string, message: UpdateMessage) {
|
||||
const update = message.args[0];
|
||||
let currentState = docs.get(docName) || new Uint8Array();
|
||||
|
||||
// Apply update to document state
|
||||
try {
|
||||
const doc = new Doc();
|
||||
applyUpdate(doc, update);
|
||||
currentState = encodeStateAsUpdate(doc);
|
||||
docs.set(docName, currentState);
|
||||
|
||||
console.log(`Update applied to ${docName}. Size: ${currentState.length} bytes`);
|
||||
|
||||
// Broadcast update to all other clients
|
||||
const broadcastMsg = {
|
||||
type: 'update',
|
||||
args: [Array.from(update)],
|
||||
};
|
||||
|
||||
clients.get(docName)?.forEach(client => {
|
||||
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
||||
client.send(new TextEncoder().encode(JSON.stringify(broadcastMsg)));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error applying update:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Failed to apply update' }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document stats (for monitoring)
|
||||
*/
|
||||
export function getDocStats(): Record<string, { clientCount: number; stateSize: number }> {
|
||||
const stats: Record<string, { clientCount: number; stateSize: number }> = {};
|
||||
|
||||
docs.forEach((state, docName) => {
|
||||
stats[docName] = {
|
||||
clientCount: clients.get(docName)?.size || 0,
|
||||
stateSize: state.length,
|
||||
};
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
Reference in New Issue
Block a user