This commit is contained in:
2026-07-31 11:51:56 +09:00
parent 3271693051
commit d90a25919e
20 changed files with 14370 additions and 0 deletions
+763
View File
@@ -0,0 +1,763 @@
# @linkforty/core
> Open-source deeplink management engine built on Fastify + PostgreSQL + optional Redis. Provides smart link routing with device detection, click analytics, UTM tracking, deferred deep linking, device fingerprinting, webhooks, QR codes, link templates, and mobile SDK endpoints. No auth included — bring your own. Install via npm, connect to PostgreSQL, and you have a full deep linking platform. Licensed AGPL-3.0-only.
## Installation
```bash
npm install @linkforty/core
```
Requires:
- Node.js 20+
- PostgreSQL 14+
- Redis (optional, recommended for caching)
## Quick Start
```typescript
import { createServer } from '@linkforty/core';
const server = await createServer({
database: {
url: 'postgresql://postgres:password@localhost:5432/linkforty',
pool: { min: 2, max: 10 },
},
redis: {
url: 'redis://localhost:6379', // optional
},
cors: {
origin: ['https://yourdomain.com'],
},
logger: true,
});
await server.listen({ port: 3000, host: '0.0.0.0' });
```
That's it. The server auto-creates all database tables on first startup, registers all API routes, and starts serving.
## Docker Quick Start
```yaml
# docker-compose.yml
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: linkforty
POSTGRES_USER: linkforty
POSTGRES_PASSWORD: changeme
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
linkforty:
image: node:20-alpine
working_dir: /app
command: node dist/index.js
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://linkforty:changeme@postgres:5432/linkforty
REDIS_URL: redis://redis:6379
PORT: 3000
NODE_ENV: production
depends_on:
- postgres
- redis
volumes:
pgdata:
```
```bash
docker compose up -d
curl http://localhost:3000/health
```
## Configuration
### createServer(options)
```typescript
interface ServerOptions {
database?: {
url?: string; // PostgreSQL connection string
pool?: {
min?: number; // Default: 2
max?: number; // Default: 10
};
};
redis?: {
url: string; // Redis connection string
};
cors?: {
origin: string | string[];
};
logger?: boolean; // Enable Fastify logger
}
```
### Environment Variables
```bash
# Database (PostgreSQL)
DATABASE_URL=postgresql://linkforty:changeme@localhost:5432/linkforty
# Redis (optional — falls back to database on miss or error)
REDIS_URL=redis://localhost:6379
# Server
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
CORS_ORIGIN=*
# Short link domain (used in QR codes and SDK responses)
SHORTLINK_DOMAIN=https://go.yourdomain.com
# iOS Universal Links (optional)
IOS_TEAM_ID=ABC123XYZ
IOS_BUNDLE_ID=com.yourcompany.yourapp
# Android App Links (optional)
ANDROID_PACKAGE_NAME=com.yourcompany.yourapp
ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:...
```
## Module Exports
```typescript
import { createServer } from '@linkforty/core'; // Server factory
import { generateShortCode } from '@linkforty/core/utils'; // Utility functions
import { db } from '@linkforty/core/database'; // PostgreSQL pool
import { linkRoutes } from '@linkforty/core/routes'; // Fastify route plugins
import type { Link, AnalyticsData } from '@linkforty/core/types'; // TypeScript types
```
## TypeScript Types
### Link
```typescript
interface Link {
id: string; // UUID
userId?: string; // Optional (multi-tenant scoping)
template_id?: string;
template_slug?: string;
short_code: string; // Unique, immutable after creation
original_url: string;
title?: string;
description?: string;
// Platform-specific URLs
ios_app_store_url?: string;
android_app_store_url?: string;
web_fallback_url?: string;
// Deep linking
app_scheme?: string; // URI scheme (e.g., "myapp")
ios_universal_link?: string;
android_app_link?: string;
deep_link_path?: string; // In-app destination (e.g., "/product/123")
deep_link_parameters?: Record<string, any>;
// Analytics
utmParameters?: UTMParameters;
targeting_rules?: TargetingRules;
// Social preview (Open Graph)
og_title?: string;
og_description?: string;
og_image_url?: string;
og_type?: string;
// Lifecycle
attribution_window_hours?: number; // Default: 168 (7 days)
is_active: boolean;
expires_at?: string;
created_at: string;
updated_at: string;
click_count?: number;
}
interface UTMParameters {
source?: string;
medium?: string;
campaign?: string;
term?: string;
content?: string;
}
interface TargetingRules {
countries?: string[]; // ISO country codes (e.g., ["US", "GB"])
devices?: ('ios' | 'android' | 'web')[];
languages?: string[]; // BCP 47 codes (e.g., ["en", "es"])
}
```
### Create/Update Link Request
```typescript
interface CreateLinkRequest {
userId?: string;
templateId?: string;
originalUrl: string; // Required, must be valid URL
title?: string;
description?: string;
iosAppStoreUrl?: string;
androidAppStoreUrl?: string;
webFallbackUrl?: string;
appScheme?: string;
iosUniversalLink?: string;
androidAppLink?: string;
deepLinkPath?: string;
deepLinkParameters?: Record<string, any>;
utmParameters?: UTMParameters;
targetingRules?: TargetingRules;
ogTitle?: string;
ogDescription?: string;
ogImageUrl?: string;
ogType?: string;
attributionWindowHours?: number; // 1-2160
customCode?: string; // Custom short code (auto-generated if omitted)
expiresAt?: string; // ISO 8601 datetime
}
// UpdateLinkRequest is Partial<CreateLinkRequest> plus:
interface UpdateLinkRequest extends Partial<CreateLinkRequest> {
isActive?: boolean;
}
```
### Link Template
```typescript
interface LinkTemplate {
id: string;
userId?: string;
name: string;
slug: string; // Auto-generated 8-char alphanumeric
description?: string;
settings: LinkTemplateSettings;
is_default: boolean;
created_at: string;
updated_at: string;
}
interface LinkTemplateSettings {
defaultIosUrl?: string;
defaultAndroidUrl?: string;
defaultWebFallbackUrl?: string;
defaultAttributionWindowHours?: number;
utmParameters?: UTMParameters;
targetingRules?: TargetingRules;
expiresAfterDays?: number;
}
```
### Analytics
```typescript
interface AnalyticsData {
totalClicks: number;
uniqueClicks: number;
clicksByDate: Array<{ date: string; clicks: number }>;
clicksByCountry: Array<{ country: string; countryCode: string; clicks: number }>;
clicksByDevice: Array<{ device: string; clicks: number }>;
clicksByPlatform: Array<{ platform: string; clicks: number }>;
clicksByBrowser: Array<{ browser: string; clicks: number }>;
clicksByHour: Array<{ hour: number; clicks: number }>;
clicksByUtmSource: Array<{ source: string; clicks: number }>;
clicksByUtmMedium: Array<{ medium: string; clicks: number }>;
clicksByUtmCampaign: Array<{ campaign: string; clicks: number }>;
clicksByReferrer: Array<{ source: string; clicks: number }>;
topLinks: Array<{
id: string;
shortCode: string;
title: string | null;
originalUrl: string;
totalClicks: number;
uniqueClicks: number;
}>;
}
```
### Webhook
```typescript
type WebhookEvent = 'click_event' | 'install_event' | 'conversion_event';
interface Webhook {
id: string;
user_id?: string;
name: string;
url: string;
secret: string; // Auto-generated, HMAC-SHA256 signing key
events: WebhookEvent[];
is_active: boolean;
retry_count: number; // 1-10
timeout_ms: number; // 1000-60000
headers: Record<string, string>;
created_at: string;
updated_at: string;
}
```
## API Endpoints
All endpoints accept JSON request/response bodies. The `userId` query parameter is optional on all routes — when provided, queries are scoped to that user (multi-tenant mode). When omitted, all records are accessible (single-tenant mode).
### Link Management
#### POST /api/links — Create Link
```bash
curl -X POST http://localhost:3000/api/links \
-H "Content-Type: application/json" \
-d '{
"originalUrl": "https://example.com/product/789",
"title": "Summer Campaign",
"iosAppStoreUrl": "https://apps.apple.com/app/id123456789",
"androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.example.app",
"webFallbackUrl": "https://example.com/product/789",
"deepLinkParameters": { "route": "product", "productId": "789" },
"utmParameters": { "source": "instagram", "medium": "social", "campaign": "summer" },
"templateId": "550e8400-e29b-41d4-a716-446655440000"
}'
```
Response (201):
```json
{
"id": "uuid",
"shortCode": "abc12345",
"originalUrl": "https://example.com/product/789",
"title": "Summer Campaign",
"deepLinkParameters": { "route": "product", "productId": "789" },
"utmParameters": { "source": "instagram", "medium": "social", "campaign": "summer" },
"isActive": true,
"clickCount": 0,
"createdAt": "2025-01-15T10:30:00Z",
"updatedAt": "2025-01-15T10:30:00Z"
}
```
#### GET /api/links — List Links
```bash
curl "http://localhost:3000/api/links?userId=optional-user-id"
```
#### GET /api/links/:id — Get Link
```bash
curl "http://localhost:3000/api/links/uuid"
```
#### PUT /api/links/:id — Update Link
```bash
curl -X PUT "http://localhost:3000/api/links/uuid" \
-H "Content-Type: application/json" \
-d '{ "title": "Updated Title", "isActive": false }'
```
#### DELETE /api/links/:id — Delete Link
```bash
curl -X DELETE "http://localhost:3000/api/links/uuid"
```
Response: `{ "success": true }`
#### POST /api/links/:id/duplicate — Clone Link
```bash
curl -X POST "http://localhost:3000/api/links/uuid/duplicate"
```
### Templates
#### POST /api/templates — Create Template
```bash
curl -X POST http://localhost:3000/api/templates \
-H "Content-Type: application/json" \
-d '{
"name": "E-commerce Links",
"settings": {
"defaultIosUrl": "https://apps.apple.com/app/id123",
"defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.example",
"defaultWebFallbackUrl": "https://example.com",
"defaultAttributionWindowHours": 168,
"utmParameters": { "source": "app", "medium": "deeplink" }
},
"isDefault": true
}'
```
#### GET /api/templates — List Templates
#### GET /api/templates/:id — Get Template
#### PUT /api/templates/:id — Update Template
#### DELETE /api/templates/:id — Delete Template
#### PUT /api/templates/:id/set-default — Set Default Template
### Analytics
#### GET /api/analytics/overview — Aggregate Analytics
```bash
curl "http://localhost:3000/api/analytics/overview?days=30"
```
Returns full `AnalyticsData` object (see types above).
#### GET /api/analytics/links/:linkId — Link-Specific Analytics
```bash
curl "http://localhost:3000/api/analytics/links/uuid?days=7"
```
### Webhooks
#### POST /api/webhooks — Create Webhook
```bash
curl -X POST http://localhost:3000/api/webhooks \
-H "Content-Type: application/json" \
-d '{
"name": "My Webhook",
"url": "https://example.com/webhooks/linkforty",
"events": ["click_event", "install_event", "conversion_event"],
"retryCount": 3,
"timeoutMs": 10000
}'
```
Response includes auto-generated `secret` for HMAC verification.
#### GET /api/webhooks — List Webhooks
#### GET /api/webhooks/:id — Get Webhook (includes secret)
#### PUT /api/webhooks/:id — Update Webhook
#### DELETE /api/webhooks/:id — Delete Webhook
#### POST /api/webhooks/:id/test — Send Test Payload
**Webhook signature verification:**
```typescript
import crypto from 'crypto';
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(`sha256=${expected}`));
}
// Signature header: X-LinkForty-Signature
```
### Redirects (Public)
#### GET /:shortCode — Follow Short Link
Redirects (302) based on device:
1. iOS device → `ios_app_store_url`
2. Android device → `android_app_store_url`
3. Desktop/other → `web_fallback_url` → `original_url`
UTM parameters are appended to the redirect URL. Click is tracked asynchronously.
#### GET /:templateSlug/:shortCode — Template-Based Redirect
Same behavior, resolves via template slug + short code.
### QR Codes
#### GET /api/links/:id/qr — Generate QR Code
```bash
# PNG (default)
curl "http://localhost:3000/api/links/uuid/qr" -o qr.png
# SVG
curl "http://localhost:3000/api/links/uuid/qr?format=svg" -o qr.svg
```
### Mobile SDK Endpoints (Public)
These are called by the LinkForty mobile SDKs (`@linkforty/mobile-sdk-react-native`, `@linkforty/mobile-sdk-expo`, iOS SDK, Android SDK).
#### POST /api/sdk/v1/install — Report App Install
```bash
curl -X POST http://localhost:3000/api/sdk/v1/install \
-H "Content-Type: application/json" \
-d '{
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
"timezone": "America/New_York",
"language": "en-US",
"screenWidth": 390,
"screenHeight": 844,
"platform": "iOS",
"platformVersion": "17.0",
"attributionWindowHours": 168
}'
```
Response:
```json
{
"installId": "uuid",
"attributed": true,
"confidenceScore": 85,
"matchedFactors": ["ip", "user_agent", "timezone", "language", "screen"],
"deepLinkData": {
"shortCode": "abc123",
"originalUrl": "https://example.com/product/789",
"iosUrl": "https://apps.apple.com/app/id123",
"androidUrl": "https://play.google.com/store/apps/details?id=com.example",
"webFallbackUrl": "https://example.com/product/789",
"utmParameters": { "source": "instagram" },
"deepLinkParameters": { "route": "product", "productId": "789" }
}
}
```
#### GET /api/sdk/v1/resolve/:shortCode — Resolve Deep Link Data
```bash
curl "http://localhost:3000/api/sdk/v1/resolve/abc123?fp_tz=America/New_York&fp_platform=ios"
```
Response:
```json
{
"shortCode": "abc123",
"linkId": "uuid",
"deepLinkPath": "/product/789",
"appScheme": "myapp",
"iosUrl": "https://apps.apple.com/app/id123",
"androidUrl": "https://play.google.com/store/apps/details?id=com.example",
"webUrl": "https://example.com/product/789",
"utmParameters": { "source": "instagram" },
"customParameters": { "route": "product", "productId": "789" },
"clickedAt": "2025-01-15T10:30:00Z"
}
```
#### POST /api/sdk/v1/event — Track In-App Event
```bash
curl -X POST http://localhost:3000/api/sdk/v1/event \
-H "Content-Type: application/json" \
-d '{
"installId": "uuid",
"eventName": "purchase",
"eventData": { "amount": 29.99, "currency": "USD" }
}'
```
Response: `{ "eventId": "uuid", "acknowledged": true }`
### Well-Known (Auto-Served)
#### GET /.well-known/apple-app-site-association
Serves the AASA file for iOS Universal Links. Configure via `IOS_TEAM_ID` and `IOS_BUNDLE_ID` env vars.
#### GET /.well-known/assetlinks.json
Serves Digital Asset Links for Android App Links. Configure via `ANDROID_PACKAGE_NAME` and `ANDROID_SHA256_FINGERPRINTS` env vars.
## Redirect Flow
When a user clicks a short link (e.g., `https://go.yourdomain.com/abc123`):
1. Check Redis cache (`link:{shortCode}`, 5-min TTL)
2. Fall back to PostgreSQL if cache miss or Redis unavailable
3. Evaluate targeting rules (countries, devices, languages) — return 404 if no match
4. Track click asynchronously (does not block redirect)
5. Select destination URL based on device (iOS → Android → web → original)
6. Append UTM parameters to destination URL
7. Return 302 redirect
## Fingerprint Attribution
When a mobile SDK reports an install, Core matches it to a previous click using probabilistic fingerprinting:
| Factor | Weight |
|--------|--------|
| IP address | 40 points |
| User agent | 30 points |
| Timezone | 10 points |
| Language | 10 points |
| Screen resolution | 10 points |
A match requires 70+ confidence score out of 100. Default attribution window is 168 hours (7 days), configurable per link (1-2160 hours).
## Database Schema
Tables are auto-created on first startup by `initializeDatabase()`. No separate migration step needed.
| Table | Purpose |
|-------|---------|
| `link_templates` | Reusable link configuration templates |
| `links` | Short links with all configuration |
| `click_events` | Click analytics with geolocation |
| `device_fingerprints` | Fingerprint components for attribution matching |
| `install_events` | Mobile app install tracking |
| `in_app_events` | Conversion events from mobile apps |
| `webhooks` | Webhook endpoint configurations |
All tables use UUID primary keys (`gen_random_uuid()`), `created_at`/`updated_at` timestamps, and snake_case column names.
## Extending Core
Core returns a standard Fastify instance, so you can add custom routes, plugins, and hooks:
```typescript
import { createServer } from '@linkforty/core';
const server = await createServer({
database: { url: process.env.DATABASE_URL },
logger: true,
});
// Add custom authentication
server.addHook('onRequest', async (request, reply) => {
const apiKey = request.headers['x-api-key'];
if (!apiKey || apiKey !== process.env.API_KEY) {
reply.code(401).send({ error: 'Unauthorized' });
}
});
// Add custom routes
server.get('/api/custom/stats', async (request, reply) => {
const { db } = await import('@linkforty/core/database');
const result = await db.query('SELECT COUNT(*) FROM links');
return { totalLinks: parseInt(result.rows[0].count) };
});
await server.listen({ port: 3000, host: '0.0.0.0' });
```
## Complete Self-Hosted Server Example
```typescript
// server.ts
import 'dotenv/config';
import { createServer } from '@linkforty/core';
async function start() {
const server = await createServer({
database: {
url: process.env.DATABASE_URL || 'postgresql://linkforty:changeme@localhost:5432/linkforty',
pool: { min: 2, max: 10 },
},
redis: process.env.REDIS_URL
? { url: process.env.REDIS_URL }
: undefined,
cors: {
origin: process.env.CORS_ORIGIN?.split(',') || ['*'],
},
logger: true,
});
const port = parseInt(process.env.PORT || '3000');
const host = process.env.HOST || '0.0.0.0';
await server.listen({ port, host });
console.log(`LinkForty Core running at http://${host}:${port}`);
}
start().catch((err) => {
console.error('Failed to start:', err);
process.exit(1);
});
```
```json
// package.json
{
"type": "module",
"scripts": {
"start": "node --loader tsx server.ts",
"dev": "tsx watch server.ts"
},
"dependencies": {
"@linkforty/core": "^1.6.0",
"dotenv": "^16.3.0"
},
"devDependencies": {
"tsx": "^4.0.0",
"typescript": "^5.2.0"
}
}
```
## Utility Functions
```typescript
import {
generateShortCode,
parseUserAgent,
getLocationFromIP,
buildRedirectUrl,
detectDevice,
} from '@linkforty/core/utils';
generateShortCode(8);
// → "aB3kX9mQ" (nanoid-based, configurable length)
parseUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...');
// → { deviceType: "mobile", platform: "iOS", platformVersion: "17.0", browser: "Safari" }
getLocationFromIP('8.8.8.8');
// → { countryCode: "US", countryName: "United States", city: "Mountain View", ... }
detectDevice('Mozilla/5.0 (iPhone...)');
// → "ios"
buildRedirectUrl('https://example.com', { source: 'email', medium: 'campaign' });
// → "https://example.com?utm_source=email&utm_medium=campaign"
```
## Error Responses
All errors follow this format:
```json
{
"error": "Error message",
"statusCode": 400
}
```
| Status | Meaning |
|--------|---------|
| 400 | Validation error (bad request body) |
| 404 | Link/resource not found, or targeting rules excluded user |
| 500 | Internal server error |
## Real-Time Events
Core emits click events via an internal event emitter and optional WebSocket:
```typescript
import { subscribeToClickEvents } from '@linkforty/core';
const unsubscribe = subscribeToClickEvents((event) => {
console.log('Click:', event.shortCode, event.deviceType, event.country);
});
// WebSocket endpoint: ws://localhost:3000/api/debug/live
```