xpandurl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/cli.cjs +417 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +394 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +239 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +70 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +197 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
package/dist/cli.cjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/expand.ts","../src/errors.ts","../src/expand-many.ts","../src/cli.ts"],"sourcesContent":["import http from 'node:http'\nimport https from 'node:https'\nimport { ExpandError, InvalidUrlError, MaxRedirectsError, TimeoutError } from './errors.js'\nimport type { ExpandOptions, ExpandResult } from './types.js'\n\nconst DEFAULT_TIMEOUT = 10_000\nconst DEFAULT_MAX_REDIRECTS = 10\nconst DEFAULT_UA =\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'\n\n// ─── Private / local IP detection ─────────────────────────────────────────────\n\nconst BLOCKED_HOSTNAMES = new Set([\n 'localhost',\n 'metadata.google.internal', // GCP metadata\n])\n\nfunction isPrivateHost(hostname: string): boolean {\n const h = hostname.toLowerCase()\n\n if (BLOCKED_HOSTNAMES.has(h)) return true\n\n // IPv6 loopback, link-local, ULA (fc00::/7)\n if (h === '::1' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) {\n return true\n }\n\n // IPv4: must be exactly 4 dot-separated octets\n const parts = h.split('.')\n if (parts.length !== 4) return false\n const octets = parts.map(Number)\n if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false\n\n const [a, b] = octets as [number, number, number, number]\n return (\n a === 0 || // 0.0.0.0/8 unspecified\n a === 10 || // 10.0.0.0/8 RFC1918\n a === 127 || // 127.0.0.0/8 loopback\n (a === 169 && b === 254) || // 169.254.0.0/16 link-local + cloud metadata\n (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 RFC1918\n (a === 192 && b === 168) || // 192.168.0.0/16 RFC1918\n a === 255 // 255.x.x.x broadcast\n )\n}\n\n// ─── Request (headers only — body is never read) ───────────────────────────────\n\ninterface RequestResult {\n statusCode: number\n location: string | undefined\n}\n\nfunction makeRequest(url: string, options: ExpandOptions): Promise<RequestResult> {\n return new Promise((resolve, reject) => {\n const timeout = options.timeout ?? DEFAULT_TIMEOUT\n const lib = url.startsWith('https') ? https : http\n\n const req = lib.get(\n url,\n {\n headers: {\n 'User-Agent': options.userAgent ?? DEFAULT_UA,\n Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',\n ...options.headers,\n },\n },\n (res) => {\n // Capture headers then immediately close — never read the response body.\n // This prevents downloading large or malicious payloads (e.g. shell scripts).\n const result: RequestResult = {\n statusCode: res.statusCode ?? 0,\n location: Array.isArray(res.headers.location)\n ? res.headers.location[0]\n : res.headers.location,\n }\n resolve(result)\n res.destroy()\n },\n )\n\n req.setTimeout(timeout, () => {\n req.destroy(new TimeoutError(url, timeout))\n })\n\n req.on('error', (err) => {\n if (err instanceof TimeoutError) {\n reject(err)\n return\n }\n // Ignore socket errors that result from our own res.destroy() call\n // (promise is already resolved at that point, so reject is a no-op,\n // but we guard here to avoid unhandled-rejection noise in edge cases)\n reject(new ExpandError(err.message, url))\n })\n })\n}\n\n// ─── URL validation helpers ────────────────────────────────────────────────────\n\nfunction assertHttpProtocol(url: string): void {\n let protocol: string\n try {\n protocol = new URL(url).protocol\n } catch {\n throw new InvalidUrlError(url)\n }\n if (protocol !== 'http:' && protocol !== 'https:') {\n throw new InvalidUrlError(url)\n }\n}\n\nfunction assertHostPolicy(url: string, options: ExpandOptions): void {\n const { hostname } = new URL(url)\n\n if (options.blockPrivateIPs !== false && isPrivateHost(hostname)) {\n throw new ExpandError(\n `Requests to private/local hosts are blocked (\"${hostname}\"). Set blockPrivateIPs: false to allow.`,\n url,\n )\n }\n\n if (options.allowedHosts && options.allowedHosts.length > 0) {\n if (!options.allowedHosts.includes(hostname)) {\n throw new ExpandError(\n `\"${hostname}\" is not in the allowedHosts list`,\n url,\n )\n }\n }\n}\n\nfunction assertRedirectPolicy(from: string, to: string, options: ExpandOptions): void {\n if (options.allowHttpDowngrade === false) {\n const fromProto = new URL(from).protocol\n const toProto = new URL(to).protocol\n if (fromProto === 'https:' && toProto === 'http:') {\n throw new ExpandError(\n `HTTPS to HTTP downgrade blocked (set allowHttpDowngrade: true to permit)`,\n from,\n )\n }\n }\n\n assertHostPolicy(to, options)\n}\n\n// ─── Public API ────────────────────────────────────────────────────────────────\n\nexport async function expand(url: string, options: ExpandOptions = {}): Promise<ExpandResult> {\n assertHttpProtocol(url)\n assertHostPolicy(url, options)\n\n const max = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS\n const chain: string[] = []\n let current = url\n\n for (let i = 0; i <= max; i++) {\n const { statusCode, location } = await makeRequest(current, options)\n\n const isRedirect = statusCode >= 300 && statusCode < 400\n if (isRedirect && location) {\n chain.push(current)\n const next = new URL(location, current).href\n assertHttpProtocol(next)\n assertRedirectPolicy(current, next, options)\n current = next\n continue\n }\n\n return {\n originalUrl: url,\n expandedUrl: current,\n statusCode,\n redirectChain: chain,\n }\n }\n\n throw new MaxRedirectsError(url, max)\n}\n","export class ExpandError extends Error {\n constructor(\n message: string,\n public readonly url: string,\n ) {\n super(message)\n this.name = 'ExpandError'\n Object.setPrototypeOf(this, new.target.prototype)\n }\n}\n\nexport class TimeoutError extends ExpandError {\n constructor(url: string, timeoutMs: number) {\n super(`Request timed out after ${timeoutMs}ms`, url)\n this.name = 'TimeoutError'\n }\n}\n\nexport class MaxRedirectsError extends ExpandError {\n constructor(url: string, max: number) {\n super(`Exceeded maximum of ${max} redirects`, url)\n this.name = 'MaxRedirectsError'\n }\n}\n\nexport class InvalidUrlError extends ExpandError {\n constructor(url: string) {\n super(`Invalid or unsupported URL: ${url}`, url)\n this.name = 'InvalidUrlError'\n }\n}\n","import { expand } from './expand.js'\nimport type { ExpandManyOptions, ExpandResult } from './types.js'\n\nconst DEFAULT_CONCURRENCY = 5\n\nexport async function expandMany(\n urls: string[],\n options: ExpandManyOptions = {},\n): Promise<ExpandResult[]> {\n const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY\n const results: ExpandResult[] = []\n\n for (let i = 0; i < urls.length; i += concurrency) {\n const batch = urls.slice(i, i + concurrency)\n const settled = await Promise.allSettled(batch.map((url) => expand(url, options)))\n\n for (let j = 0; j < settled.length; j++) {\n const outcome = settled[j]!\n if (outcome.status === 'fulfilled') {\n results.push(outcome.value)\n } else {\n results.push({\n originalUrl: urls[i + j]!,\n expandedUrl: urls[i + j]!,\n statusCode: 0,\n redirectChain: [],\n error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason),\n })\n }\n }\n }\n\n return results\n}\n","#!/usr/bin/env node\nimport { expand, expandMany } from './index.js'\nimport type { ExpandOptions, ExpandResult } from './types.js'\n\n// ─── ANSI colours (disabled when piped or --json) ─────────────────────────────\n\nconst USE_COLOR = process.stdout.isTTY === true\n\nconst c = {\n bold: (s: string) => USE_COLOR ? `\\x1b[1m${s}\\x1b[0m` : s,\n dim: (s: string) => USE_COLOR ? `\\x1b[2m${s}\\x1b[0m` : s,\n green: (s: string) => USE_COLOR ? `\\x1b[32m${s}\\x1b[0m` : s,\n yellow: (s: string) => USE_COLOR ? `\\x1b[33m${s}\\x1b[0m` : s,\n red: (s: string) => USE_COLOR ? `\\x1b[31m${s}\\x1b[0m` : s,\n cyan: (s: string) => USE_COLOR ? `\\x1b[36m${s}\\x1b[0m` : s,\n}\n\n// ─── URL param classification ──────────────────────────────────────────────────\n\nconst AFFILIATE_PARAMS = new Set([\n 'ref', 'ref_', 'affiliate', 'aff_id', 'aff_sub', 'partner',\n 'social_share', 'referral', 'tag', 'associate',\n])\n\nconst TRACKING_PARAMS = new Set([\n 'fbclid', 'gclid', 'msclkid', 'twclid', 'ttclid', 'li_fat_id',\n 'clickid', 'click_id', '_ga', 'mc_eid',\n])\n\nfunction paramType(key: string): 'AFFILIATE' | 'TRACKING' | 'OTHER' {\n const k = key.toLowerCase()\n if (k.startsWith('utm_')) return 'TRACKING'\n if (TRACKING_PARAMS.has(k)) return 'TRACKING'\n if (AFFILIATE_PARAMS.has(k)) return 'AFFILIATE'\n return 'OTHER'\n}\n\n// ─── Status helpers ────────────────────────────────────────────────────────────\n\nconst STATUS_TEXT: Record<number, string> = {\n 200: 'OK', 201: 'Created', 204: 'No Content',\n 301: 'Moved Permanently', 302: 'Found', 303: 'See Other',\n 307: 'Temporary Redirect', 308: 'Permanent Redirect',\n 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden',\n 404: 'Not Found', 429: 'Too Many Requests',\n 500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable',\n}\n\nfunction colorStatus(code: number): string {\n const text = `${code} ${STATUS_TEXT[code] ?? ''}`\n if (code >= 200 && code < 300) return c.green(text)\n if (code >= 300 && code < 400) return c.yellow(text)\n return c.red(text)\n}\n\n// ─── URL safety: sanitize + truncate for terminal display ─────────────────────\n// --json output is always raw — sanitization is display-only.\n\nconst MAX_URL_DISPLAY = 120\n\n/**\n * Strip characters that can be weaponised in a terminal:\n * - ANSI escape sequences (clear screen, cursor moves, colour injections)\n * - Bidirectional overrides (RTL/LTR marks that flip how text renders)\n * - Zero-width / invisible characters\n * - C0/C1 control characters\n */\nfunction sanitizeForDisplay(url: string): string {\n return url\n .replace(/\\x1b\\[[0-9;]*[a-zA-Z]/g, '') // ANSI CSI sequences e.g. \\x1b[2J\n .replace(/\\x1b[^[]/g, '') // other ESC sequences e.g. \\x1bM\n .replace(/[\\u202A-\\u202E\\u2066-\\u2069]/g, '') // bidi override/isolate\n .replace(/[\\u200B-\\u200F\\u2028\\u2029\\uFEFF]/g, '') // zero-width + line/para sep\n .replace(/[\\x00-\\x1f\\x7f]/g, '') // remaining control chars\n}\n\n/**\n * Keep the first 80 chars (domain + path start) and last 37 chars\n * (end of query string) so neither the origin nor the destination is hidden.\n */\nfunction truncateUrl(url: string): { display: string; truncated: boolean } {\n if (url.length <= MAX_URL_DISPLAY) return { display: url, truncated: false }\n return { display: `${url.slice(0, 80)}...${url.slice(-37)}`, truncated: true }\n}\n\nfunction safeUrl(raw: string): { display: string; truncated: boolean } {\n return truncateUrl(sanitizeForDisplay(raw))\n}\n\n// ─── Rich human-readable output ────────────────────────────────────────────────\n\nfunction printRich(result: ExpandResult, showChain: boolean): void {\n if (result.error) {\n console.error(` ${c.red('✗')} ${safeUrl(result.originalUrl).display}`)\n console.error(` ${c.dim(result.error)}`)\n return\n }\n\n const isHttps = result.expandedUrl.startsWith('https://')\n const hopCount = result.redirectChain.length\n const lbl = (s: string) => c.dim(s.padEnd(10))\n\n const expanded = safeUrl(result.expandedUrl)\n\n console.log()\n console.log(` ${lbl('Original')} ${safeUrl(result.originalUrl).display}`)\n console.log(` ${lbl('Expanded')} ${c.cyan(expanded.display)}`)\n if (expanded.truncated) {\n console.log(` ${lbl('')} ${c.dim('full URL available via --json')}`)\n }\n console.log(` ${lbl('Status')} ${colorStatus(result.statusCode)}`)\n console.log(` ${lbl('Security')} ${isHttps ? c.green('✓ HTTPS') : c.yellow('⚠ HTTP')}`)\n if (hopCount > 0) {\n console.log(` ${lbl('Hops')} ${hopCount} redirect${hopCount === 1 ? '' : 's'}`)\n }\n\n if (showChain && hopCount > 0) {\n console.log()\n console.log(` ${c.bold('Redirect Chain')}`)\n result.redirectChain.forEach((url, i) => {\n console.log(` ${c.dim(`${i + 1}.`)} ${safeUrl(url).display}`)\n })\n console.log(` ${c.dim('→')} ${expanded.display}`)\n }\n\n // Query parameters of the final URL\n let params: URLSearchParams\n try {\n params = new URL(result.expandedUrl).searchParams\n } catch {\n console.log()\n return\n }\n\n const entries = [...params.entries()]\n if (entries.length > 0) {\n console.log()\n console.log(` ${c.bold('Query Parameters')} ${c.dim(`(${entries.length})`)}`)\n\n const keyWidth = Math.min(Math.max(...entries.map(([k]) => k.length), 8), 24)\n for (const [key, value] of entries) {\n const type = paramType(key)\n const typeStr = type === 'AFFILIATE' ? c.red(type) : type === 'TRACKING' ? c.yellow(type) : c.dim(type)\n const truncated = value.length > 40 ? `${value.slice(0, 37)}...` : value\n console.log(` ${c.dim(key.padEnd(keyWidth + 2))} ${truncated.padEnd(42)} ${typeStr}`)\n }\n }\n\n console.log()\n}\n\n// ─── Arg parsing ───────────────────────────────────────────────────────────────\n\nconst HELP = `\nUsage: xpandurl <url> [url2 ...] [options]\n\nArguments:\n url One or more URLs to expand\n\nOptions:\n --chain Show the full redirect chain\n --json Output results as JSON\n --timeout Request timeout in milliseconds (default: 10000)\n --help Show this help message\n\nExamples:\n xpandurl https://bit.ly/xyz\n xpandurl https://bit.ly/xyz --chain\n xpandurl https://bit.ly/a https://t.co/b --json\n`.trim()\n\nfunction parseArgs(argv: string[]): {\n urls: string[]\n options: ExpandOptions\n showChain: boolean\n jsonOutput: boolean\n} {\n const urls: string[] = []\n const options: ExpandOptions = {}\n let showChain = false\n let jsonOutput = false\n\n for (let i = 0; i < argv.length; i++) {\n const arg = argv[i]!\n if (arg === '--help') {\n console.log(HELP)\n process.exit(0)\n } else if (arg === '--chain') {\n showChain = true\n } else if (arg === '--json') {\n jsonOutput = true\n } else if (arg === '--timeout') {\n const val = Number(argv[++i])\n if (!Number.isFinite(val) || val <= 0) {\n console.error('Error: --timeout must be a positive number')\n process.exit(1)\n }\n options.timeout = val\n } else if (arg.startsWith('--')) {\n console.error(`Error: Unknown option \"${arg}\". Run with --help for usage.`)\n process.exit(1)\n } else {\n urls.push(arg)\n }\n }\n\n return { urls, options, showChain, jsonOutput }\n}\n\n// ─── Entry point ───────────────────────────────────────────────────────────────\n\nasync function main(): Promise<void> {\n const { urls, options, showChain, jsonOutput } = parseArgs(process.argv.slice(2))\n\n if (urls.length === 0) {\n console.error('Error: No URL provided.\\n')\n console.log(HELP)\n process.exit(1)\n }\n\n try {\n if (urls.length === 1) {\n const result = await expand(urls[0]!, options)\n if (jsonOutput) {\n console.log(JSON.stringify(result, null, 2))\n } else {\n printRich(result, showChain)\n }\n } else {\n const results = await expandMany(urls, options)\n if (jsonOutput) {\n console.log(JSON.stringify(results, null, 2))\n } else {\n results.forEach((result) => printRich(result, showChain))\n }\n }\n } catch (err) {\n console.error(`Error: ${err instanceof Error ? err.message : String(err)}`)\n process.exit(1)\n }\n}\n\nmain()\n"],"mappings":"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","names":["https","http"]}
|
package/dist/cli.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/expand.ts
|
|
4
|
+
import http from "http";
|
|
5
|
+
import https from "https";
|
|
6
|
+
|
|
7
|
+
// src/errors.ts
|
|
8
|
+
var ExpandError = class extends Error {
|
|
9
|
+
constructor(message, url) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.url = url;
|
|
12
|
+
this.name = "ExpandError";
|
|
13
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
14
|
+
}
|
|
15
|
+
url;
|
|
16
|
+
};
|
|
17
|
+
var TimeoutError = class extends ExpandError {
|
|
18
|
+
constructor(url, timeoutMs) {
|
|
19
|
+
super(`Request timed out after ${timeoutMs}ms`, url);
|
|
20
|
+
this.name = "TimeoutError";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var MaxRedirectsError = class extends ExpandError {
|
|
24
|
+
constructor(url, max) {
|
|
25
|
+
super(`Exceeded maximum of ${max} redirects`, url);
|
|
26
|
+
this.name = "MaxRedirectsError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var InvalidUrlError = class extends ExpandError {
|
|
30
|
+
constructor(url) {
|
|
31
|
+
super(`Invalid or unsupported URL: ${url}`, url);
|
|
32
|
+
this.name = "InvalidUrlError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/expand.ts
|
|
37
|
+
var DEFAULT_TIMEOUT = 1e4;
|
|
38
|
+
var DEFAULT_MAX_REDIRECTS = 10;
|
|
39
|
+
var DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
40
|
+
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
41
|
+
"localhost",
|
|
42
|
+
"metadata.google.internal"
|
|
43
|
+
// GCP metadata
|
|
44
|
+
]);
|
|
45
|
+
function isPrivateHost(hostname) {
|
|
46
|
+
const h = hostname.toLowerCase();
|
|
47
|
+
if (BLOCKED_HOSTNAMES.has(h)) return true;
|
|
48
|
+
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
const parts = h.split(".");
|
|
52
|
+
if (parts.length !== 4) return false;
|
|
53
|
+
const octets = parts.map(Number);
|
|
54
|
+
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false;
|
|
55
|
+
const [a, b] = octets;
|
|
56
|
+
return a === 0 || // 0.0.0.0/8 unspecified
|
|
57
|
+
a === 10 || // 10.0.0.0/8 RFC1918
|
|
58
|
+
a === 127 || // 127.0.0.0/8 loopback
|
|
59
|
+
a === 169 && b === 254 || // 169.254.0.0/16 link-local + cloud metadata
|
|
60
|
+
a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 RFC1918
|
|
61
|
+
a === 192 && b === 168 || // 192.168.0.0/16 RFC1918
|
|
62
|
+
a === 255;
|
|
63
|
+
}
|
|
64
|
+
function makeRequest(url, options) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
67
|
+
const lib = url.startsWith("https") ? https : http;
|
|
68
|
+
const req = lib.get(
|
|
69
|
+
url,
|
|
70
|
+
{
|
|
71
|
+
headers: {
|
|
72
|
+
"User-Agent": options.userAgent ?? DEFAULT_UA,
|
|
73
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
74
|
+
...options.headers
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
(res) => {
|
|
78
|
+
const result = {
|
|
79
|
+
statusCode: res.statusCode ?? 0,
|
|
80
|
+
location: Array.isArray(res.headers.location) ? res.headers.location[0] : res.headers.location
|
|
81
|
+
};
|
|
82
|
+
resolve(result);
|
|
83
|
+
res.destroy();
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
req.setTimeout(timeout, () => {
|
|
87
|
+
req.destroy(new TimeoutError(url, timeout));
|
|
88
|
+
});
|
|
89
|
+
req.on("error", (err) => {
|
|
90
|
+
if (err instanceof TimeoutError) {
|
|
91
|
+
reject(err);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
reject(new ExpandError(err.message, url));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function assertHttpProtocol(url) {
|
|
99
|
+
let protocol;
|
|
100
|
+
try {
|
|
101
|
+
protocol = new URL(url).protocol;
|
|
102
|
+
} catch {
|
|
103
|
+
throw new InvalidUrlError(url);
|
|
104
|
+
}
|
|
105
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
106
|
+
throw new InvalidUrlError(url);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function assertHostPolicy(url, options) {
|
|
110
|
+
const { hostname } = new URL(url);
|
|
111
|
+
if (options.blockPrivateIPs !== false && isPrivateHost(hostname)) {
|
|
112
|
+
throw new ExpandError(
|
|
113
|
+
`Requests to private/local hosts are blocked ("${hostname}"). Set blockPrivateIPs: false to allow.`,
|
|
114
|
+
url
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (options.allowedHosts && options.allowedHosts.length > 0) {
|
|
118
|
+
if (!options.allowedHosts.includes(hostname)) {
|
|
119
|
+
throw new ExpandError(
|
|
120
|
+
`"${hostname}" is not in the allowedHosts list`,
|
|
121
|
+
url
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function assertRedirectPolicy(from, to, options) {
|
|
127
|
+
if (options.allowHttpDowngrade === false) {
|
|
128
|
+
const fromProto = new URL(from).protocol;
|
|
129
|
+
const toProto = new URL(to).protocol;
|
|
130
|
+
if (fromProto === "https:" && toProto === "http:") {
|
|
131
|
+
throw new ExpandError(
|
|
132
|
+
`HTTPS to HTTP downgrade blocked (set allowHttpDowngrade: true to permit)`,
|
|
133
|
+
from
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
assertHostPolicy(to, options);
|
|
138
|
+
}
|
|
139
|
+
async function expand(url, options = {}) {
|
|
140
|
+
assertHttpProtocol(url);
|
|
141
|
+
assertHostPolicy(url, options);
|
|
142
|
+
const max = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
143
|
+
const chain = [];
|
|
144
|
+
let current = url;
|
|
145
|
+
for (let i = 0; i <= max; i++) {
|
|
146
|
+
const { statusCode, location } = await makeRequest(current, options);
|
|
147
|
+
const isRedirect = statusCode >= 300 && statusCode < 400;
|
|
148
|
+
if (isRedirect && location) {
|
|
149
|
+
chain.push(current);
|
|
150
|
+
const next = new URL(location, current).href;
|
|
151
|
+
assertHttpProtocol(next);
|
|
152
|
+
assertRedirectPolicy(current, next, options);
|
|
153
|
+
current = next;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
originalUrl: url,
|
|
158
|
+
expandedUrl: current,
|
|
159
|
+
statusCode,
|
|
160
|
+
redirectChain: chain
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
throw new MaxRedirectsError(url, max);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/expand-many.ts
|
|
167
|
+
var DEFAULT_CONCURRENCY = 5;
|
|
168
|
+
async function expandMany(urls, options = {}) {
|
|
169
|
+
const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
|
170
|
+
const results = [];
|
|
171
|
+
for (let i = 0; i < urls.length; i += concurrency) {
|
|
172
|
+
const batch = urls.slice(i, i + concurrency);
|
|
173
|
+
const settled = await Promise.allSettled(batch.map((url) => expand(url, options)));
|
|
174
|
+
for (let j = 0; j < settled.length; j++) {
|
|
175
|
+
const outcome = settled[j];
|
|
176
|
+
if (outcome.status === "fulfilled") {
|
|
177
|
+
results.push(outcome.value);
|
|
178
|
+
} else {
|
|
179
|
+
results.push({
|
|
180
|
+
originalUrl: urls[i + j],
|
|
181
|
+
expandedUrl: urls[i + j],
|
|
182
|
+
statusCode: 0,
|
|
183
|
+
redirectChain: [],
|
|
184
|
+
error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/cli.ts
|
|
193
|
+
var USE_COLOR = process.stdout.isTTY === true;
|
|
194
|
+
var c = {
|
|
195
|
+
bold: (s) => USE_COLOR ? `\x1B[1m${s}\x1B[0m` : s,
|
|
196
|
+
dim: (s) => USE_COLOR ? `\x1B[2m${s}\x1B[0m` : s,
|
|
197
|
+
green: (s) => USE_COLOR ? `\x1B[32m${s}\x1B[0m` : s,
|
|
198
|
+
yellow: (s) => USE_COLOR ? `\x1B[33m${s}\x1B[0m` : s,
|
|
199
|
+
red: (s) => USE_COLOR ? `\x1B[31m${s}\x1B[0m` : s,
|
|
200
|
+
cyan: (s) => USE_COLOR ? `\x1B[36m${s}\x1B[0m` : s
|
|
201
|
+
};
|
|
202
|
+
var AFFILIATE_PARAMS = /* @__PURE__ */ new Set([
|
|
203
|
+
"ref",
|
|
204
|
+
"ref_",
|
|
205
|
+
"affiliate",
|
|
206
|
+
"aff_id",
|
|
207
|
+
"aff_sub",
|
|
208
|
+
"partner",
|
|
209
|
+
"social_share",
|
|
210
|
+
"referral",
|
|
211
|
+
"tag",
|
|
212
|
+
"associate"
|
|
213
|
+
]);
|
|
214
|
+
var TRACKING_PARAMS = /* @__PURE__ */ new Set([
|
|
215
|
+
"fbclid",
|
|
216
|
+
"gclid",
|
|
217
|
+
"msclkid",
|
|
218
|
+
"twclid",
|
|
219
|
+
"ttclid",
|
|
220
|
+
"li_fat_id",
|
|
221
|
+
"clickid",
|
|
222
|
+
"click_id",
|
|
223
|
+
"_ga",
|
|
224
|
+
"mc_eid"
|
|
225
|
+
]);
|
|
226
|
+
function paramType(key) {
|
|
227
|
+
const k = key.toLowerCase();
|
|
228
|
+
if (k.startsWith("utm_")) return "TRACKING";
|
|
229
|
+
if (TRACKING_PARAMS.has(k)) return "TRACKING";
|
|
230
|
+
if (AFFILIATE_PARAMS.has(k)) return "AFFILIATE";
|
|
231
|
+
return "OTHER";
|
|
232
|
+
}
|
|
233
|
+
var STATUS_TEXT = {
|
|
234
|
+
200: "OK",
|
|
235
|
+
201: "Created",
|
|
236
|
+
204: "No Content",
|
|
237
|
+
301: "Moved Permanently",
|
|
238
|
+
302: "Found",
|
|
239
|
+
303: "See Other",
|
|
240
|
+
307: "Temporary Redirect",
|
|
241
|
+
308: "Permanent Redirect",
|
|
242
|
+
400: "Bad Request",
|
|
243
|
+
401: "Unauthorized",
|
|
244
|
+
403: "Forbidden",
|
|
245
|
+
404: "Not Found",
|
|
246
|
+
429: "Too Many Requests",
|
|
247
|
+
500: "Internal Server Error",
|
|
248
|
+
502: "Bad Gateway",
|
|
249
|
+
503: "Service Unavailable"
|
|
250
|
+
};
|
|
251
|
+
function colorStatus(code) {
|
|
252
|
+
const text = `${code} ${STATUS_TEXT[code] ?? ""}`;
|
|
253
|
+
if (code >= 200 && code < 300) return c.green(text);
|
|
254
|
+
if (code >= 300 && code < 400) return c.yellow(text);
|
|
255
|
+
return c.red(text);
|
|
256
|
+
}
|
|
257
|
+
var MAX_URL_DISPLAY = 120;
|
|
258
|
+
function sanitizeForDisplay(url) {
|
|
259
|
+
return url.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b[^[]/g, "").replace(/[\u202A-\u202E\u2066-\u2069]/g, "").replace(/[\u200B-\u200F\u2028\u2029\uFEFF]/g, "").replace(/[\x00-\x1f\x7f]/g, "");
|
|
260
|
+
}
|
|
261
|
+
function truncateUrl(url) {
|
|
262
|
+
if (url.length <= MAX_URL_DISPLAY) return { display: url, truncated: false };
|
|
263
|
+
return { display: `${url.slice(0, 80)}...${url.slice(-37)}`, truncated: true };
|
|
264
|
+
}
|
|
265
|
+
function safeUrl(raw) {
|
|
266
|
+
return truncateUrl(sanitizeForDisplay(raw));
|
|
267
|
+
}
|
|
268
|
+
function printRich(result, showChain) {
|
|
269
|
+
if (result.error) {
|
|
270
|
+
console.error(` ${c.red("\u2717")} ${safeUrl(result.originalUrl).display}`);
|
|
271
|
+
console.error(` ${c.dim(result.error)}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const isHttps = result.expandedUrl.startsWith("https://");
|
|
275
|
+
const hopCount = result.redirectChain.length;
|
|
276
|
+
const lbl = (s) => c.dim(s.padEnd(10));
|
|
277
|
+
const expanded = safeUrl(result.expandedUrl);
|
|
278
|
+
console.log();
|
|
279
|
+
console.log(` ${lbl("Original")} ${safeUrl(result.originalUrl).display}`);
|
|
280
|
+
console.log(` ${lbl("Expanded")} ${c.cyan(expanded.display)}`);
|
|
281
|
+
if (expanded.truncated) {
|
|
282
|
+
console.log(` ${lbl("")} ${c.dim("full URL available via --json")}`);
|
|
283
|
+
}
|
|
284
|
+
console.log(` ${lbl("Status")} ${colorStatus(result.statusCode)}`);
|
|
285
|
+
console.log(` ${lbl("Security")} ${isHttps ? c.green("\u2713 HTTPS") : c.yellow("\u26A0 HTTP")}`);
|
|
286
|
+
if (hopCount > 0) {
|
|
287
|
+
console.log(` ${lbl("Hops")} ${hopCount} redirect${hopCount === 1 ? "" : "s"}`);
|
|
288
|
+
}
|
|
289
|
+
if (showChain && hopCount > 0) {
|
|
290
|
+
console.log();
|
|
291
|
+
console.log(` ${c.bold("Redirect Chain")}`);
|
|
292
|
+
result.redirectChain.forEach((url, i) => {
|
|
293
|
+
console.log(` ${c.dim(`${i + 1}.`)} ${safeUrl(url).display}`);
|
|
294
|
+
});
|
|
295
|
+
console.log(` ${c.dim("\u2192")} ${expanded.display}`);
|
|
296
|
+
}
|
|
297
|
+
let params;
|
|
298
|
+
try {
|
|
299
|
+
params = new URL(result.expandedUrl).searchParams;
|
|
300
|
+
} catch {
|
|
301
|
+
console.log();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const entries = [...params.entries()];
|
|
305
|
+
if (entries.length > 0) {
|
|
306
|
+
console.log();
|
|
307
|
+
console.log(` ${c.bold("Query Parameters")} ${c.dim(`(${entries.length})`)}`);
|
|
308
|
+
const keyWidth = Math.min(Math.max(...entries.map(([k]) => k.length), 8), 24);
|
|
309
|
+
for (const [key, value] of entries) {
|
|
310
|
+
const type = paramType(key);
|
|
311
|
+
const typeStr = type === "AFFILIATE" ? c.red(type) : type === "TRACKING" ? c.yellow(type) : c.dim(type);
|
|
312
|
+
const truncated = value.length > 40 ? `${value.slice(0, 37)}...` : value;
|
|
313
|
+
console.log(` ${c.dim(key.padEnd(keyWidth + 2))} ${truncated.padEnd(42)} ${typeStr}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
console.log();
|
|
317
|
+
}
|
|
318
|
+
var HELP = `
|
|
319
|
+
Usage: xpandurl <url> [url2 ...] [options]
|
|
320
|
+
|
|
321
|
+
Arguments:
|
|
322
|
+
url One or more URLs to expand
|
|
323
|
+
|
|
324
|
+
Options:
|
|
325
|
+
--chain Show the full redirect chain
|
|
326
|
+
--json Output results as JSON
|
|
327
|
+
--timeout Request timeout in milliseconds (default: 10000)
|
|
328
|
+
--help Show this help message
|
|
329
|
+
|
|
330
|
+
Examples:
|
|
331
|
+
xpandurl https://bit.ly/xyz
|
|
332
|
+
xpandurl https://bit.ly/xyz --chain
|
|
333
|
+
xpandurl https://bit.ly/a https://t.co/b --json
|
|
334
|
+
`.trim();
|
|
335
|
+
function parseArgs(argv) {
|
|
336
|
+
const urls = [];
|
|
337
|
+
const options = {};
|
|
338
|
+
let showChain = false;
|
|
339
|
+
let jsonOutput = false;
|
|
340
|
+
for (let i = 0; i < argv.length; i++) {
|
|
341
|
+
const arg = argv[i];
|
|
342
|
+
if (arg === "--help") {
|
|
343
|
+
console.log(HELP);
|
|
344
|
+
process.exit(0);
|
|
345
|
+
} else if (arg === "--chain") {
|
|
346
|
+
showChain = true;
|
|
347
|
+
} else if (arg === "--json") {
|
|
348
|
+
jsonOutput = true;
|
|
349
|
+
} else if (arg === "--timeout") {
|
|
350
|
+
const val = Number(argv[++i]);
|
|
351
|
+
if (!Number.isFinite(val) || val <= 0) {
|
|
352
|
+
console.error("Error: --timeout must be a positive number");
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
options.timeout = val;
|
|
356
|
+
} else if (arg.startsWith("--")) {
|
|
357
|
+
console.error(`Error: Unknown option "${arg}". Run with --help for usage.`);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
} else {
|
|
360
|
+
urls.push(arg);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { urls, options, showChain, jsonOutput };
|
|
364
|
+
}
|
|
365
|
+
async function main() {
|
|
366
|
+
const { urls, options, showChain, jsonOutput } = parseArgs(process.argv.slice(2));
|
|
367
|
+
if (urls.length === 0) {
|
|
368
|
+
console.error("Error: No URL provided.\n");
|
|
369
|
+
console.log(HELP);
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
if (urls.length === 1) {
|
|
374
|
+
const result = await expand(urls[0], options);
|
|
375
|
+
if (jsonOutput) {
|
|
376
|
+
console.log(JSON.stringify(result, null, 2));
|
|
377
|
+
} else {
|
|
378
|
+
printRich(result, showChain);
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
const results = await expandMany(urls, options);
|
|
382
|
+
if (jsonOutput) {
|
|
383
|
+
console.log(JSON.stringify(results, null, 2));
|
|
384
|
+
} else {
|
|
385
|
+
results.forEach((result) => printRich(result, showChain));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
main();
|
|
394
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/expand.ts","../src/errors.ts","../src/expand-many.ts","../src/cli.ts"],"sourcesContent":["import http from 'node:http'\nimport https from 'node:https'\nimport { ExpandError, InvalidUrlError, MaxRedirectsError, TimeoutError } from './errors.js'\nimport type { ExpandOptions, ExpandResult } from './types.js'\n\nconst DEFAULT_TIMEOUT = 10_000\nconst DEFAULT_MAX_REDIRECTS = 10\nconst DEFAULT_UA =\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'\n\n// ─── Private / local IP detection ─────────────────────────────────────────────\n\nconst BLOCKED_HOSTNAMES = new Set([\n 'localhost',\n 'metadata.google.internal', // GCP metadata\n])\n\nfunction isPrivateHost(hostname: string): boolean {\n const h = hostname.toLowerCase()\n\n if (BLOCKED_HOSTNAMES.has(h)) return true\n\n // IPv6 loopback, link-local, ULA (fc00::/7)\n if (h === '::1' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) {\n return true\n }\n\n // IPv4: must be exactly 4 dot-separated octets\n const parts = h.split('.')\n if (parts.length !== 4) return false\n const octets = parts.map(Number)\n if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false\n\n const [a, b] = octets as [number, number, number, number]\n return (\n a === 0 || // 0.0.0.0/8 unspecified\n a === 10 || // 10.0.0.0/8 RFC1918\n a === 127 || // 127.0.0.0/8 loopback\n (a === 169 && b === 254) || // 169.254.0.0/16 link-local + cloud metadata\n (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 RFC1918\n (a === 192 && b === 168) || // 192.168.0.0/16 RFC1918\n a === 255 // 255.x.x.x broadcast\n )\n}\n\n// ─── Request (headers only — body is never read) ───────────────────────────────\n\ninterface RequestResult {\n statusCode: number\n location: string | undefined\n}\n\nfunction makeRequest(url: string, options: ExpandOptions): Promise<RequestResult> {\n return new Promise((resolve, reject) => {\n const timeout = options.timeout ?? DEFAULT_TIMEOUT\n const lib = url.startsWith('https') ? https : http\n\n const req = lib.get(\n url,\n {\n headers: {\n 'User-Agent': options.userAgent ?? DEFAULT_UA,\n Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',\n ...options.headers,\n },\n },\n (res) => {\n // Capture headers then immediately close — never read the response body.\n // This prevents downloading large or malicious payloads (e.g. shell scripts).\n const result: RequestResult = {\n statusCode: res.statusCode ?? 0,\n location: Array.isArray(res.headers.location)\n ? res.headers.location[0]\n : res.headers.location,\n }\n resolve(result)\n res.destroy()\n },\n )\n\n req.setTimeout(timeout, () => {\n req.destroy(new TimeoutError(url, timeout))\n })\n\n req.on('error', (err) => {\n if (err instanceof TimeoutError) {\n reject(err)\n return\n }\n // Ignore socket errors that result from our own res.destroy() call\n // (promise is already resolved at that point, so reject is a no-op,\n // but we guard here to avoid unhandled-rejection noise in edge cases)\n reject(new ExpandError(err.message, url))\n })\n })\n}\n\n// ─── URL validation helpers ────────────────────────────────────────────────────\n\nfunction assertHttpProtocol(url: string): void {\n let protocol: string\n try {\n protocol = new URL(url).protocol\n } catch {\n throw new InvalidUrlError(url)\n }\n if (protocol !== 'http:' && protocol !== 'https:') {\n throw new InvalidUrlError(url)\n }\n}\n\nfunction assertHostPolicy(url: string, options: ExpandOptions): void {\n const { hostname } = new URL(url)\n\n if (options.blockPrivateIPs !== false && isPrivateHost(hostname)) {\n throw new ExpandError(\n `Requests to private/local hosts are blocked (\"${hostname}\"). Set blockPrivateIPs: false to allow.`,\n url,\n )\n }\n\n if (options.allowedHosts && options.allowedHosts.length > 0) {\n if (!options.allowedHosts.includes(hostname)) {\n throw new ExpandError(\n `\"${hostname}\" is not in the allowedHosts list`,\n url,\n )\n }\n }\n}\n\nfunction assertRedirectPolicy(from: string, to: string, options: ExpandOptions): void {\n if (options.allowHttpDowngrade === false) {\n const fromProto = new URL(from).protocol\n const toProto = new URL(to).protocol\n if (fromProto === 'https:' && toProto === 'http:') {\n throw new ExpandError(\n `HTTPS to HTTP downgrade blocked (set allowHttpDowngrade: true to permit)`,\n from,\n )\n }\n }\n\n assertHostPolicy(to, options)\n}\n\n// ─── Public API ────────────────────────────────────────────────────────────────\n\nexport async function expand(url: string, options: ExpandOptions = {}): Promise<ExpandResult> {\n assertHttpProtocol(url)\n assertHostPolicy(url, options)\n\n const max = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS\n const chain: string[] = []\n let current = url\n\n for (let i = 0; i <= max; i++) {\n const { statusCode, location } = await makeRequest(current, options)\n\n const isRedirect = statusCode >= 300 && statusCode < 400\n if (isRedirect && location) {\n chain.push(current)\n const next = new URL(location, current).href\n assertHttpProtocol(next)\n assertRedirectPolicy(current, next, options)\n current = next\n continue\n }\n\n return {\n originalUrl: url,\n expandedUrl: current,\n statusCode,\n redirectChain: chain,\n }\n }\n\n throw new MaxRedirectsError(url, max)\n}\n","export class ExpandError extends Error {\n constructor(\n message: string,\n public readonly url: string,\n ) {\n super(message)\n this.name = 'ExpandError'\n Object.setPrototypeOf(this, new.target.prototype)\n }\n}\n\nexport class TimeoutError extends ExpandError {\n constructor(url: string, timeoutMs: number) {\n super(`Request timed out after ${timeoutMs}ms`, url)\n this.name = 'TimeoutError'\n }\n}\n\nexport class MaxRedirectsError extends ExpandError {\n constructor(url: string, max: number) {\n super(`Exceeded maximum of ${max} redirects`, url)\n this.name = 'MaxRedirectsError'\n }\n}\n\nexport class InvalidUrlError extends ExpandError {\n constructor(url: string) {\n super(`Invalid or unsupported URL: ${url}`, url)\n this.name = 'InvalidUrlError'\n }\n}\n","import { expand } from './expand.js'\nimport type { ExpandManyOptions, ExpandResult } from './types.js'\n\nconst DEFAULT_CONCURRENCY = 5\n\nexport async function expandMany(\n urls: string[],\n options: ExpandManyOptions = {},\n): Promise<ExpandResult[]> {\n const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY\n const results: ExpandResult[] = []\n\n for (let i = 0; i < urls.length; i += concurrency) {\n const batch = urls.slice(i, i + concurrency)\n const settled = await Promise.allSettled(batch.map((url) => expand(url, options)))\n\n for (let j = 0; j < settled.length; j++) {\n const outcome = settled[j]!\n if (outcome.status === 'fulfilled') {\n results.push(outcome.value)\n } else {\n results.push({\n originalUrl: urls[i + j]!,\n expandedUrl: urls[i + j]!,\n statusCode: 0,\n redirectChain: [],\n error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason),\n })\n }\n }\n }\n\n return results\n}\n","#!/usr/bin/env node\nimport { expand, expandMany } from './index.js'\nimport type { ExpandOptions, ExpandResult } from './types.js'\n\n// ─── ANSI colours (disabled when piped or --json) ─────────────────────────────\n\nconst USE_COLOR = process.stdout.isTTY === true\n\nconst c = {\n bold: (s: string) => USE_COLOR ? `\\x1b[1m${s}\\x1b[0m` : s,\n dim: (s: string) => USE_COLOR ? `\\x1b[2m${s}\\x1b[0m` : s,\n green: (s: string) => USE_COLOR ? `\\x1b[32m${s}\\x1b[0m` : s,\n yellow: (s: string) => USE_COLOR ? `\\x1b[33m${s}\\x1b[0m` : s,\n red: (s: string) => USE_COLOR ? `\\x1b[31m${s}\\x1b[0m` : s,\n cyan: (s: string) => USE_COLOR ? `\\x1b[36m${s}\\x1b[0m` : s,\n}\n\n// ─── URL param classification ──────────────────────────────────────────────────\n\nconst AFFILIATE_PARAMS = new Set([\n 'ref', 'ref_', 'affiliate', 'aff_id', 'aff_sub', 'partner',\n 'social_share', 'referral', 'tag', 'associate',\n])\n\nconst TRACKING_PARAMS = new Set([\n 'fbclid', 'gclid', 'msclkid', 'twclid', 'ttclid', 'li_fat_id',\n 'clickid', 'click_id', '_ga', 'mc_eid',\n])\n\nfunction paramType(key: string): 'AFFILIATE' | 'TRACKING' | 'OTHER' {\n const k = key.toLowerCase()\n if (k.startsWith('utm_')) return 'TRACKING'\n if (TRACKING_PARAMS.has(k)) return 'TRACKING'\n if (AFFILIATE_PARAMS.has(k)) return 'AFFILIATE'\n return 'OTHER'\n}\n\n// ─── Status helpers ────────────────────────────────────────────────────────────\n\nconst STATUS_TEXT: Record<number, string> = {\n 200: 'OK', 201: 'Created', 204: 'No Content',\n 301: 'Moved Permanently', 302: 'Found', 303: 'See Other',\n 307: 'Temporary Redirect', 308: 'Permanent Redirect',\n 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden',\n 404: 'Not Found', 429: 'Too Many Requests',\n 500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable',\n}\n\nfunction colorStatus(code: number): string {\n const text = `${code} ${STATUS_TEXT[code] ?? ''}`\n if (code >= 200 && code < 300) return c.green(text)\n if (code >= 300 && code < 400) return c.yellow(text)\n return c.red(text)\n}\n\n// ─── URL safety: sanitize + truncate for terminal display ─────────────────────\n// --json output is always raw — sanitization is display-only.\n\nconst MAX_URL_DISPLAY = 120\n\n/**\n * Strip characters that can be weaponised in a terminal:\n * - ANSI escape sequences (clear screen, cursor moves, colour injections)\n * - Bidirectional overrides (RTL/LTR marks that flip how text renders)\n * - Zero-width / invisible characters\n * - C0/C1 control characters\n */\nfunction sanitizeForDisplay(url: string): string {\n return url\n .replace(/\\x1b\\[[0-9;]*[a-zA-Z]/g, '') // ANSI CSI sequences e.g. \\x1b[2J\n .replace(/\\x1b[^[]/g, '') // other ESC sequences e.g. \\x1bM\n .replace(/[\\u202A-\\u202E\\u2066-\\u2069]/g, '') // bidi override/isolate\n .replace(/[\\u200B-\\u200F\\u2028\\u2029\\uFEFF]/g, '') // zero-width + line/para sep\n .replace(/[\\x00-\\x1f\\x7f]/g, '') // remaining control chars\n}\n\n/**\n * Keep the first 80 chars (domain + path start) and last 37 chars\n * (end of query string) so neither the origin nor the destination is hidden.\n */\nfunction truncateUrl(url: string): { display: string; truncated: boolean } {\n if (url.length <= MAX_URL_DISPLAY) return { display: url, truncated: false }\n return { display: `${url.slice(0, 80)}...${url.slice(-37)}`, truncated: true }\n}\n\nfunction safeUrl(raw: string): { display: string; truncated: boolean } {\n return truncateUrl(sanitizeForDisplay(raw))\n}\n\n// ─── Rich human-readable output ────────────────────────────────────────────────\n\nfunction printRich(result: ExpandResult, showChain: boolean): void {\n if (result.error) {\n console.error(` ${c.red('✗')} ${safeUrl(result.originalUrl).display}`)\n console.error(` ${c.dim(result.error)}`)\n return\n }\n\n const isHttps = result.expandedUrl.startsWith('https://')\n const hopCount = result.redirectChain.length\n const lbl = (s: string) => c.dim(s.padEnd(10))\n\n const expanded = safeUrl(result.expandedUrl)\n\n console.log()\n console.log(` ${lbl('Original')} ${safeUrl(result.originalUrl).display}`)\n console.log(` ${lbl('Expanded')} ${c.cyan(expanded.display)}`)\n if (expanded.truncated) {\n console.log(` ${lbl('')} ${c.dim('full URL available via --json')}`)\n }\n console.log(` ${lbl('Status')} ${colorStatus(result.statusCode)}`)\n console.log(` ${lbl('Security')} ${isHttps ? c.green('✓ HTTPS') : c.yellow('⚠ HTTP')}`)\n if (hopCount > 0) {\n console.log(` ${lbl('Hops')} ${hopCount} redirect${hopCount === 1 ? '' : 's'}`)\n }\n\n if (showChain && hopCount > 0) {\n console.log()\n console.log(` ${c.bold('Redirect Chain')}`)\n result.redirectChain.forEach((url, i) => {\n console.log(` ${c.dim(`${i + 1}.`)} ${safeUrl(url).display}`)\n })\n console.log(` ${c.dim('→')} ${expanded.display}`)\n }\n\n // Query parameters of the final URL\n let params: URLSearchParams\n try {\n params = new URL(result.expandedUrl).searchParams\n } catch {\n console.log()\n return\n }\n\n const entries = [...params.entries()]\n if (entries.length > 0) {\n console.log()\n console.log(` ${c.bold('Query Parameters')} ${c.dim(`(${entries.length})`)}`)\n\n const keyWidth = Math.min(Math.max(...entries.map(([k]) => k.length), 8), 24)\n for (const [key, value] of entries) {\n const type = paramType(key)\n const typeStr = type === 'AFFILIATE' ? c.red(type) : type === 'TRACKING' ? c.yellow(type) : c.dim(type)\n const truncated = value.length > 40 ? `${value.slice(0, 37)}...` : value\n console.log(` ${c.dim(key.padEnd(keyWidth + 2))} ${truncated.padEnd(42)} ${typeStr}`)\n }\n }\n\n console.log()\n}\n\n// ─── Arg parsing ───────────────────────────────────────────────────────────────\n\nconst HELP = `\nUsage: xpandurl <url> [url2 ...] [options]\n\nArguments:\n url One or more URLs to expand\n\nOptions:\n --chain Show the full redirect chain\n --json Output results as JSON\n --timeout Request timeout in milliseconds (default: 10000)\n --help Show this help message\n\nExamples:\n xpandurl https://bit.ly/xyz\n xpandurl https://bit.ly/xyz --chain\n xpandurl https://bit.ly/a https://t.co/b --json\n`.trim()\n\nfunction parseArgs(argv: string[]): {\n urls: string[]\n options: ExpandOptions\n showChain: boolean\n jsonOutput: boolean\n} {\n const urls: string[] = []\n const options: ExpandOptions = {}\n let showChain = false\n let jsonOutput = false\n\n for (let i = 0; i < argv.length; i++) {\n const arg = argv[i]!\n if (arg === '--help') {\n console.log(HELP)\n process.exit(0)\n } else if (arg === '--chain') {\n showChain = true\n } else if (arg === '--json') {\n jsonOutput = true\n } else if (arg === '--timeout') {\n const val = Number(argv[++i])\n if (!Number.isFinite(val) || val <= 0) {\n console.error('Error: --timeout must be a positive number')\n process.exit(1)\n }\n options.timeout = val\n } else if (arg.startsWith('--')) {\n console.error(`Error: Unknown option \"${arg}\". Run with --help for usage.`)\n process.exit(1)\n } else {\n urls.push(arg)\n }\n }\n\n return { urls, options, showChain, jsonOutput }\n}\n\n// ─── Entry point ───────────────────────────────────────────────────────────────\n\nasync function main(): Promise<void> {\n const { urls, options, showChain, jsonOutput } = parseArgs(process.argv.slice(2))\n\n if (urls.length === 0) {\n console.error('Error: No URL provided.\\n')\n console.log(HELP)\n process.exit(1)\n }\n\n try {\n if (urls.length === 1) {\n const result = await expand(urls[0]!, options)\n if (jsonOutput) {\n console.log(JSON.stringify(result, null, 2))\n } else {\n printRich(result, showChain)\n }\n } else {\n const results = await expandMany(urls, options)\n if (jsonOutput) {\n console.log(JSON.stringify(results, null, 2))\n } else {\n results.forEach((result) => printRich(result, showChain))\n }\n }\n } catch (err) {\n console.error(`Error: ${err instanceof Error ? err.message : String(err)}`)\n process.exit(1)\n }\n}\n\nmain()\n"],"mappings":"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","names":[]}
|