longurl-js
Version:
LongURL - Programmable URL management framework with entity-driven design and production-ready infrastructure
315 lines (314 loc) • 13.7 kB
JavaScript
;
/**
* 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
urlSlugShort: result.url_slug_short && result.url_slug_short !== result.urlId
? result.url_slug_short
: undefined, // Store short slug in same row if different from main slug
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);
// Resolve entity to get qrCodeUrl (if bucket storage was used)
// This is needed because adapter.save() uploads QR and stores URL, but doesn't return it
const savedEntity = await this.adapter.resolve(result.urlId);
// 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: use qrCodeUrl from bucket (default) or qrCode base64 (if storeQRInTable: true)
qrCode: (savedEntity === null || savedEntity === void 0 ? void 0 : savedEntity.qrCode) || result.qrCode, // Only if storeQRInTable: true
qrCodeUrl: savedEntity === null || savedEntity === void 0 ? void 0 : savedEntity.qrCodeUrl, // From bucket storage (default)
// 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; } });