UNPKG

longurl-js

Version:

LongURL - Programmable URL management framework with entity-driven design and production-ready infrastructure

324 lines (323 loc) 13.7 kB
"use strict"; /** * LongURL - Programmable URL Shortener * * Infrastructure-as-code for URLs. Built for developers who need control. * * A developer-friendly URL shortener with entity-based organization, * collision detection, and analytics tracking. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SupabaseAdapter = exports.StorageAdapter = exports.LongURL = void 0; const generator_1 = require("./generator"); const adapters_1 = require("./adapters"); const types_1 = require("../types"); const utils_1 = require("../utils"); class LongURL { constructor(config = {}) { var _a, _b; // Handle includeEntityInPath with environment variable fallback const includeEntityInPath = (_a = config.includeEntityInPath) !== null && _a !== void 0 ? _a : (process.env.LONGURL_INCLUDE_ENTITY_IN_PATH === 'true'); // Handle enableShortening with environment variable fallback const enableShortening = (_b = config.enableShortening) !== null && _b !== void 0 ? _b : (process.env.LONGURL_SHORTEN !== 'false'); // Default to true unless explicitly set to 'false' this.config = { ...config, includeEntityInPath, enableShortening }; // Progressive disclosure: Zero config -> Simple config -> Advanced config if (config.adapter) { // Level 3: Advanced - Custom adapter injection this.adapter = config.adapter; } else if (config.supabase || this.hasSupabaseEnvVars()) { // Level 1 & 2: Zero config or Simple Supabase config const supabaseConfig = this.buildSupabaseConfig(config.supabase); this.adapter = new adapters_1.SupabaseAdapter(supabaseConfig); } else if (config.database) { // Legacy: Backward compatibility this.adapter = new adapters_1.SupabaseAdapter({ url: config.database.connection.url, key: config.database.connection.key }); } else { throw new Error('LongURL requires configuration. Choose one:\n\n' + '1. Environment variables (recommended):\n' + ' • Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY\n' + ' • Then: new LongURL()\n\n' + '2. Direct configuration:\n' + ' • new LongURL({ supabase: { url, key } })\n\n' + '3. Custom adapter:\n' + ' • new LongURL({ adapter: customAdapter })\n\n' + 'Environment setup help:\n' + '• Node.js: import "dotenv/config" before importing LongURL\n' + '• Next.js: Add to .env.local file\n' + '• Vercel/Netlify: Set in project settings'); } } /** * Check if Supabase environment variables are available */ hasSupabaseEnvVars() { return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY); } /** * Build Supabase configuration with environment variable fallbacks */ buildSupabaseConfig(userConfig) { const url = (userConfig === null || userConfig === void 0 ? void 0 : userConfig.url) || process.env.SUPABASE_URL; const key = (userConfig === null || userConfig === void 0 ? void 0 : userConfig.key) || process.env.SUPABASE_SERVICE_ROLE_KEY; if (!url || !key) { throw new Error('Supabase configuration incomplete. Provide:\n' + '• supabase.url and supabase.key in config, OR\n' + '• SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables\n\n' + 'Environment setup examples:\n' + '• Node.js: import "dotenv/config" before importing LongURL\n' + '• Next.js: Add variables to .env.local\n' + '• Vercel: Set in project settings\n' + '• Docker: Use -e flags or env_file'); } return { url, key, options: userConfig === null || userConfig === void 0 ? void 0 : userConfig.options }; } /** * Initialize the LongURL instance */ async initialize() { await this.adapter.initialize(); } /** * Transform result to include both new and legacy field names for backward compatibility */ enhanceGenerationResult(result) { if (result.success && result.urlId && result.shortUrl && result.originalUrl) { return { ...result, // NEW: Clear naming urlSlug: result.urlSlug || result.urlId, urlBase: result.urlBase || result.originalUrl, urlOutput: result.urlOutput || result.shortUrl, // LEGACY: Keep existing fields for backward compatibility urlId: result.urlSlug || result.urlId, shortUrl: result.urlOutput || result.shortUrl, originalUrl: result.urlBase || result.originalUrl }; } return result; } /** * Transform resolution result to include both new and legacy field names */ enhanceResolutionResult(result) { if (result.success && result.urlId && result.originalUrl) { return { ...result, // NEW: Clear naming urlSlug: result.urlId, urlBase: result.originalUrl, // LEGACY: Keep existing fields for backward compatibility urlId: result.urlId, originalUrl: result.originalUrl }; } return result; } /** * Manage a URL for a specific entity * * Primary method for the LongURL framework. Handles both shortening mode * and framework mode with readable URLs. */ async manageUrl(entityType, entityId, originalUrl, metadata, options) { var _a, _b; try { // Validate entity type if (this.config.entities && !this.config.entities[entityType]) { return { success: false, error: `Unknown entity type: ${entityType}` }; } // Support both publicId (new) and endpointId (deprecated) const publicId = (options === null || options === void 0 ? void 0 : options.publicId) || (options === null || options === void 0 ? void 0 : options.endpointId); // Generate URL ID with collision detection const result = await (0, generator_1.generateUrlId)(entityType, entityId, { enableShortening: this.config.enableShortening, includeEntityInPath: this.config.includeEntityInPath, domain: this.config.baseUrl || 'https://longurl.co', urlPattern: options === null || options === void 0 ? void 0 : options.urlPattern, publicId: publicId, // NEW: Use publicId parameter includeInSlug: (_a = options === null || options === void 0 ? void 0 : options.includeInSlug) !== null && _a !== void 0 ? _a : true, // Default to true for backward compatibility generate_qr_code: (_b = options === null || options === void 0 ? void 0 : options.generate_qr_code) !== null && _b !== void 0 ? _b : true // Default to true for QR codes }, this.getLegacyDbConfig()); if (!result.success || !result.urlId) { return result; } // Resolve {publicId} placeholder in originalUrl for url_base const resolvedOriginalUrl = originalUrl.replace('{publicId}', result.publicId || ''); // Save to storage via adapter const entityData = { urlId: result.urlId, urlSlug: result.urlId, // NEW: Clear naming entityType, entityId, originalUrl: resolvedOriginalUrl, // Resolved URL for url_base urlBase: resolvedOriginalUrl, // NEW: Clear naming - always resolved metadata: metadata || {}, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), qrCode: result.qrCode // Include QR code if generated }; await this.adapter.save(result.urlId, entityData); // If we have a short URL slug (Framework Mode), save it too if (result.url_slug_short && result.url_slug_short !== result.urlId) { const shortEntityData = { urlId: result.url_slug_short, urlSlug: result.url_slug_short, entityType, entityId, originalUrl: resolvedOriginalUrl, // Same url_base urlBase: resolvedOriginalUrl, // Same url_base metadata: metadata || {}, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), qrCode: result.qrCode // Same QR code }; await this.adapter.save(result.url_slug_short, shortEntityData); } // Build short URL const baseUrl = this.config.baseUrl || 'https://longurl.co'; const shortUrl = this.config.includeEntityInPath ? (0, utils_1.buildEntityUrl)(baseUrl, entityType, result.urlId) : `${baseUrl}/${result.urlId}`; const generationResult = { success: true, urlId: result.urlId, shortUrl, originalUrl: resolvedOriginalUrl, // Use resolved URL entityType, entityId, // NEW: Clear naming urlSlug: result.urlId, urlBase: resolvedOriginalUrl, // Use resolved URL urlOutput: shortUrl, // Include publicId from result publicId: result.publicId, // QR code from result qrCode: result.qrCode, // Short URL slug (always generated in Framework Mode) url_slug_short: result.url_slug_short }; return this.enhanceGenerationResult(generationResult); } catch (error) { return { success: false, error: `Failed to manage URL: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Shorten a URL for a specific entity (legacy alias) * * @deprecated Use manageUrl() instead for clearer semantics. * This method is maintained for backward compatibility. */ async shorten(entityType, entityId, originalUrl, metadata, options) { return this.manageUrl(entityType, entityId, originalUrl, metadata, options); } /** * Resolve a short URL back to its original URL and entity */ async resolve(urlId) { try { const entityData = await this.adapter.resolve(urlId); if (!entityData) { return { success: false, error: 'URL not found' }; } // Increment click count await this.adapter.incrementClicks(urlId); return { success: true, urlId, originalUrl: entityData.originalUrl, entityType: entityData.entityType, entityId: entityData.entityId, metadata: entityData.metadata, clickCount: 1 // Will be updated by analytics }; } catch (error) { return { success: false, error: `Failed to resolve URL: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Get analytics data for a URL */ async analytics(urlId) { try { const data = await this.adapter.getAnalytics(urlId); if (!data) { return { success: false, error: 'URL not found' }; } return { success: true, data }; } catch (error) { return { success: false, error: `Failed to get analytics: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Close the adapter connection */ async close() { await this.adapter.close(); } /** * Health check */ async healthCheck() { return this.adapter.healthCheck ? await this.adapter.healthCheck() : true; } /** * Get legacy database config for backward compatibility */ getLegacyDbConfig() { if (this.config.database) { return this.config.database; } // Default config for adapter-based setup return { strategy: types_1.StorageStrategy.LOOKUP_TABLE, connection: { url: 'adapter-managed', key: 'adapter-managed' }, lookupTable: process.env.LONGURL_TABLE_NAME || 'short_urls' }; } } exports.LongURL = LongURL; // Export everything var adapters_2 = require("./adapters"); Object.defineProperty(exports, "StorageAdapter", { enumerable: true, get: function () { return adapters_2.StorageAdapter; } }); Object.defineProperty(exports, "SupabaseAdapter", { enumerable: true, get: function () { return adapters_2.SupabaseAdapter; } });