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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aman Harsh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# xpandurl
|
|
2
|
+
|
|
3
|
+
Expand shortened URLs to their final destination. Follows the full redirect chain using Node.js built-in HTTP — zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx xpandurl https://bit.ly/xyz
|
|
7
|
+
# https://example.com/the-real-page
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install xpandurl
|
|
14
|
+
# or
|
|
15
|
+
bun add xpandurl
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Requires Node.js 18+**
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Library
|
|
23
|
+
|
|
24
|
+
### `expand(url, options?)`
|
|
25
|
+
|
|
26
|
+
Expands a single URL and returns the final destination.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { expand } from 'xpandurl'
|
|
30
|
+
|
|
31
|
+
const result = await expand('https://bit.ly/xyz')
|
|
32
|
+
|
|
33
|
+
console.log(result.expandedUrl) // 'https://example.com/the-real-page'
|
|
34
|
+
console.log(result.statusCode) // 200
|
|
35
|
+
console.log(result.redirectChain) // ['https://bit.ly/xyz', ...]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `expandMany(urls, options?)`
|
|
39
|
+
|
|
40
|
+
Expands multiple URLs concurrently. Always returns one result per input URL — failures are captured in `result.error` rather than throwing.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { expandMany } from 'xpandurl'
|
|
44
|
+
|
|
45
|
+
const results = await expandMany([
|
|
46
|
+
'https://bit.ly/a',
|
|
47
|
+
'https://t.co/b',
|
|
48
|
+
'https://tinyurl.com/c',
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
for (const result of results) {
|
|
52
|
+
if (result.error) {
|
|
53
|
+
console.error(`${result.originalUrl} failed: ${result.error}`)
|
|
54
|
+
} else {
|
|
55
|
+
console.log(`${result.originalUrl} → ${result.expandedUrl}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## API Reference
|
|
63
|
+
|
|
64
|
+
### ExpandOptions
|
|
65
|
+
|
|
66
|
+
| Option | Type | Default | Description |
|
|
67
|
+
|---|---|---|---|
|
|
68
|
+
| `timeout` | `number` | `10000` | Request timeout in milliseconds |
|
|
69
|
+
| `maxRedirects` | `number` | `10` | Maximum redirects to follow |
|
|
70
|
+
| `userAgent` | `string` | Chrome UA | User-Agent header |
|
|
71
|
+
| `headers` | `Record<string, string>` | — | Additional request headers |
|
|
72
|
+
| `allowHttpDowngrade` | `boolean` | `true` | Allow redirects from `https:` to `http:`. Set to `false` to reject downgrades |
|
|
73
|
+
| `allowedHosts` | `string[]` | — | If set, only follow redirects to these hostnames. Requests to any other host throw |
|
|
74
|
+
| `blockPrivateIPs` | `boolean` | `true` | Block requests to loopback, RFC1918, link-local, and cloud metadata addresses |
|
|
75
|
+
| `concurrency` | `number` | `5` | Max concurrent expansions (`expandMany` only) |
|
|
76
|
+
|
|
77
|
+
### ExpandResult
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
interface ExpandResult {
|
|
81
|
+
originalUrl: string // The URL you passed in
|
|
82
|
+
expandedUrl: string // Final destination after all redirects
|
|
83
|
+
statusCode: number // HTTP status of the final response
|
|
84
|
+
redirectChain: string[] // Each intermediate URL (not including final)
|
|
85
|
+
error?: string // Set on failure (expandMany only)
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Error types
|
|
90
|
+
|
|
91
|
+
All errors extend `ExpandError` which exposes a `url` property pointing to the URL that caused the failure.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { ExpandError, TimeoutError, MaxRedirectsError, InvalidUrlError } from 'xpandurl'
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await expand('https://bit.ly/xyz', { timeout: 3000, maxRedirects: 5 })
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err instanceof TimeoutError) console.error('Timed out')
|
|
100
|
+
if (err instanceof MaxRedirectsError) console.error('Too many redirects')
|
|
101
|
+
if (err instanceof InvalidUrlError) console.error('Bad URL or protocol')
|
|
102
|
+
if (err instanceof ExpandError) console.error('Expansion failed:', err.url)
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## CLI
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npx xpandurl <url> [url2 ...] [options]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
| Flag | Description |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `--chain` | Print each redirect hop |
|
|
117
|
+
| `--json` | Output as JSON |
|
|
118
|
+
| `--timeout <ms>` | Request timeout in ms (default: 10000) |
|
|
119
|
+
| `--help` | Show usage |
|
|
120
|
+
|
|
121
|
+
### Rich output
|
|
122
|
+
|
|
123
|
+
By default the CLI prints a human-readable summary for each URL:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
Original https://bit.ly/xyz
|
|
127
|
+
Expanded https://example.com/the-real-page
|
|
128
|
+
Status 200 OK
|
|
129
|
+
Security ✓ HTTPS
|
|
130
|
+
Hops 2 redirects
|
|
131
|
+
|
|
132
|
+
Query Parameters (3)
|
|
133
|
+
utm_source newsletter TRACKING
|
|
134
|
+
ref homepage AFFILIATE
|
|
135
|
+
page about OTHER
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **Status** is color-coded: green for 2xx, yellow for 3xx, red for 4xx/5xx.
|
|
139
|
+
- **Security** flags `⚠ HTTP` if the final URL is not HTTPS.
|
|
140
|
+
- **Hops** is only shown when the URL required at least one redirect.
|
|
141
|
+
- **Query Parameters** classifies each param as `TRACKING` (UTM, fbclid, gclid, …), `AFFILIATE` (ref, tag, partner, …), or `OTHER`.
|
|
142
|
+
- Very long URLs are truncated in display mode. Use `--json` to get the full untruncated URL.
|
|
143
|
+
|
|
144
|
+
Column widths in the summary block adjust based on label length; the example above is representative.
|
|
145
|
+
|
|
146
|
+
Colors are automatically disabled when output is piped.
|
|
147
|
+
|
|
148
|
+
### Examples
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
# Expand a single URL
|
|
152
|
+
npx xpandurl https://bit.ly/xyz
|
|
153
|
+
|
|
154
|
+
# Show the full redirect chain
|
|
155
|
+
npx xpandurl https://bit.ly/xyz --chain
|
|
156
|
+
|
|
157
|
+
# Expand multiple URLs at once
|
|
158
|
+
npx xpandurl https://bit.ly/a https://t.co/b
|
|
159
|
+
|
|
160
|
+
# Machine-readable JSON output (full URLs, no truncation)
|
|
161
|
+
npx xpandurl https://bit.ly/xyz --json
|
|
162
|
+
|
|
163
|
+
# Custom timeout
|
|
164
|
+
npx xpandurl https://bit.ly/xyz --timeout 5000
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Security
|
|
170
|
+
|
|
171
|
+
This library makes outbound HTTP/S requests to arbitrary URLs and follows redirects. **Treat it as an SSRF primitive** — do not pass raw user input to `expand()` or `expandMany()` in a server-side context without caller-side controls.
|
|
172
|
+
|
|
173
|
+
### Server-side usage
|
|
174
|
+
|
|
175
|
+
When expanding user-supplied URLs on a server, apply restrictions:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const result = await expand(userSuppliedUrl, {
|
|
179
|
+
allowHttpDowngrade: false, // reject https → http downgrades
|
|
180
|
+
allowedHosts: ['bit.ly', 't.co', 'tinyurl.com'], // only known shorteners
|
|
181
|
+
timeout: 5000,
|
|
182
|
+
maxRedirects: 5,
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### What this library does not protect against
|
|
187
|
+
|
|
188
|
+
- **DNS rebinding** — `blockPrivateIPs` and `allowedHosts` check the URL hostname, not the resolved IP. A public hostname that resolves to a private IP at connection time is not blocked. Combine with network-level egress filtering if full SSRF protection is required.
|
|
189
|
+
- **Response body content** — the library never reads or saves response bodies (it closes the connection after headers), so shell scripts and other payloads are not downloaded.
|
|
190
|
+
|
|
191
|
+
### HTTPS → HTTP downgrade
|
|
192
|
+
|
|
193
|
+
By default, redirects from `https:` to `http:` are permitted (common in the wild). Set `allowHttpDowngrade: false` to reject them.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT — [Aman Harsh](https://github.com/amanharshx)
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/expand.ts
|
|
27
|
+
var import_node_http = __toESM(require("http"), 1);
|
|
28
|
+
var import_node_https = __toESM(require("https"), 1);
|
|
29
|
+
|
|
30
|
+
// src/errors.ts
|
|
31
|
+
var ExpandError = class extends Error {
|
|
32
|
+
constructor(message, url) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.url = url;
|
|
35
|
+
this.name = "ExpandError";
|
|
36
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
37
|
+
}
|
|
38
|
+
url;
|
|
39
|
+
};
|
|
40
|
+
var TimeoutError = class extends ExpandError {
|
|
41
|
+
constructor(url, timeoutMs) {
|
|
42
|
+
super(`Request timed out after ${timeoutMs}ms`, url);
|
|
43
|
+
this.name = "TimeoutError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var MaxRedirectsError = class extends ExpandError {
|
|
47
|
+
constructor(url, max) {
|
|
48
|
+
super(`Exceeded maximum of ${max} redirects`, url);
|
|
49
|
+
this.name = "MaxRedirectsError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
var InvalidUrlError = class extends ExpandError {
|
|
53
|
+
constructor(url) {
|
|
54
|
+
super(`Invalid or unsupported URL: ${url}`, url);
|
|
55
|
+
this.name = "InvalidUrlError";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/expand.ts
|
|
60
|
+
var DEFAULT_TIMEOUT = 1e4;
|
|
61
|
+
var DEFAULT_MAX_REDIRECTS = 10;
|
|
62
|
+
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";
|
|
63
|
+
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
64
|
+
"localhost",
|
|
65
|
+
"metadata.google.internal"
|
|
66
|
+
// GCP metadata
|
|
67
|
+
]);
|
|
68
|
+
function isPrivateHost(hostname) {
|
|
69
|
+
const h = hostname.toLowerCase();
|
|
70
|
+
if (BLOCKED_HOSTNAMES.has(h)) return true;
|
|
71
|
+
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const parts = h.split(".");
|
|
75
|
+
if (parts.length !== 4) return false;
|
|
76
|
+
const octets = parts.map(Number);
|
|
77
|
+
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false;
|
|
78
|
+
const [a, b] = octets;
|
|
79
|
+
return a === 0 || // 0.0.0.0/8 unspecified
|
|
80
|
+
a === 10 || // 10.0.0.0/8 RFC1918
|
|
81
|
+
a === 127 || // 127.0.0.0/8 loopback
|
|
82
|
+
a === 169 && b === 254 || // 169.254.0.0/16 link-local + cloud metadata
|
|
83
|
+
a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 RFC1918
|
|
84
|
+
a === 192 && b === 168 || // 192.168.0.0/16 RFC1918
|
|
85
|
+
a === 255;
|
|
86
|
+
}
|
|
87
|
+
function makeRequest(url, options) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
90
|
+
const lib = url.startsWith("https") ? import_node_https.default : import_node_http.default;
|
|
91
|
+
const req = lib.get(
|
|
92
|
+
url,
|
|
93
|
+
{
|
|
94
|
+
headers: {
|
|
95
|
+
"User-Agent": options.userAgent ?? DEFAULT_UA,
|
|
96
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
97
|
+
...options.headers
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
(res) => {
|
|
101
|
+
const result = {
|
|
102
|
+
statusCode: res.statusCode ?? 0,
|
|
103
|
+
location: Array.isArray(res.headers.location) ? res.headers.location[0] : res.headers.location
|
|
104
|
+
};
|
|
105
|
+
resolve(result);
|
|
106
|
+
res.destroy();
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
req.setTimeout(timeout, () => {
|
|
110
|
+
req.destroy(new TimeoutError(url, timeout));
|
|
111
|
+
});
|
|
112
|
+
req.on("error", (err) => {
|
|
113
|
+
if (err instanceof TimeoutError) {
|
|
114
|
+
reject(err);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
reject(new ExpandError(err.message, url));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function assertHttpProtocol(url) {
|
|
122
|
+
let protocol;
|
|
123
|
+
try {
|
|
124
|
+
protocol = new URL(url).protocol;
|
|
125
|
+
} catch {
|
|
126
|
+
throw new InvalidUrlError(url);
|
|
127
|
+
}
|
|
128
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
129
|
+
throw new InvalidUrlError(url);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function assertHostPolicy(url, options) {
|
|
133
|
+
const { hostname } = new URL(url);
|
|
134
|
+
if (options.blockPrivateIPs !== false && isPrivateHost(hostname)) {
|
|
135
|
+
throw new ExpandError(
|
|
136
|
+
`Requests to private/local hosts are blocked ("${hostname}"). Set blockPrivateIPs: false to allow.`,
|
|
137
|
+
url
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (options.allowedHosts && options.allowedHosts.length > 0) {
|
|
141
|
+
if (!options.allowedHosts.includes(hostname)) {
|
|
142
|
+
throw new ExpandError(
|
|
143
|
+
`"${hostname}" is not in the allowedHosts list`,
|
|
144
|
+
url
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function assertRedirectPolicy(from, to, options) {
|
|
150
|
+
if (options.allowHttpDowngrade === false) {
|
|
151
|
+
const fromProto = new URL(from).protocol;
|
|
152
|
+
const toProto = new URL(to).protocol;
|
|
153
|
+
if (fromProto === "https:" && toProto === "http:") {
|
|
154
|
+
throw new ExpandError(
|
|
155
|
+
`HTTPS to HTTP downgrade blocked (set allowHttpDowngrade: true to permit)`,
|
|
156
|
+
from
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
assertHostPolicy(to, options);
|
|
161
|
+
}
|
|
162
|
+
async function expand(url, options = {}) {
|
|
163
|
+
assertHttpProtocol(url);
|
|
164
|
+
assertHostPolicy(url, options);
|
|
165
|
+
const max = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
166
|
+
const chain = [];
|
|
167
|
+
let current = url;
|
|
168
|
+
for (let i = 0; i <= max; i++) {
|
|
169
|
+
const { statusCode, location } = await makeRequest(current, options);
|
|
170
|
+
const isRedirect = statusCode >= 300 && statusCode < 400;
|
|
171
|
+
if (isRedirect && location) {
|
|
172
|
+
chain.push(current);
|
|
173
|
+
const next = new URL(location, current).href;
|
|
174
|
+
assertHttpProtocol(next);
|
|
175
|
+
assertRedirectPolicy(current, next, options);
|
|
176
|
+
current = next;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
originalUrl: url,
|
|
181
|
+
expandedUrl: current,
|
|
182
|
+
statusCode,
|
|
183
|
+
redirectChain: chain
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
throw new MaxRedirectsError(url, max);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/expand-many.ts
|
|
190
|
+
var DEFAULT_CONCURRENCY = 5;
|
|
191
|
+
async function expandMany(urls, options = {}) {
|
|
192
|
+
const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
|
193
|
+
const results = [];
|
|
194
|
+
for (let i = 0; i < urls.length; i += concurrency) {
|
|
195
|
+
const batch = urls.slice(i, i + concurrency);
|
|
196
|
+
const settled = await Promise.allSettled(batch.map((url) => expand(url, options)));
|
|
197
|
+
for (let j = 0; j < settled.length; j++) {
|
|
198
|
+
const outcome = settled[j];
|
|
199
|
+
if (outcome.status === "fulfilled") {
|
|
200
|
+
results.push(outcome.value);
|
|
201
|
+
} else {
|
|
202
|
+
results.push({
|
|
203
|
+
originalUrl: urls[i + j],
|
|
204
|
+
expandedUrl: urls[i + j],
|
|
205
|
+
statusCode: 0,
|
|
206
|
+
redirectChain: [],
|
|
207
|
+
error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/cli.ts
|
|
216
|
+
var USE_COLOR = process.stdout.isTTY === true;
|
|
217
|
+
var c = {
|
|
218
|
+
bold: (s) => USE_COLOR ? `\x1B[1m${s}\x1B[0m` : s,
|
|
219
|
+
dim: (s) => USE_COLOR ? `\x1B[2m${s}\x1B[0m` : s,
|
|
220
|
+
green: (s) => USE_COLOR ? `\x1B[32m${s}\x1B[0m` : s,
|
|
221
|
+
yellow: (s) => USE_COLOR ? `\x1B[33m${s}\x1B[0m` : s,
|
|
222
|
+
red: (s) => USE_COLOR ? `\x1B[31m${s}\x1B[0m` : s,
|
|
223
|
+
cyan: (s) => USE_COLOR ? `\x1B[36m${s}\x1B[0m` : s
|
|
224
|
+
};
|
|
225
|
+
var AFFILIATE_PARAMS = /* @__PURE__ */ new Set([
|
|
226
|
+
"ref",
|
|
227
|
+
"ref_",
|
|
228
|
+
"affiliate",
|
|
229
|
+
"aff_id",
|
|
230
|
+
"aff_sub",
|
|
231
|
+
"partner",
|
|
232
|
+
"social_share",
|
|
233
|
+
"referral",
|
|
234
|
+
"tag",
|
|
235
|
+
"associate"
|
|
236
|
+
]);
|
|
237
|
+
var TRACKING_PARAMS = /* @__PURE__ */ new Set([
|
|
238
|
+
"fbclid",
|
|
239
|
+
"gclid",
|
|
240
|
+
"msclkid",
|
|
241
|
+
"twclid",
|
|
242
|
+
"ttclid",
|
|
243
|
+
"li_fat_id",
|
|
244
|
+
"clickid",
|
|
245
|
+
"click_id",
|
|
246
|
+
"_ga",
|
|
247
|
+
"mc_eid"
|
|
248
|
+
]);
|
|
249
|
+
function paramType(key) {
|
|
250
|
+
const k = key.toLowerCase();
|
|
251
|
+
if (k.startsWith("utm_")) return "TRACKING";
|
|
252
|
+
if (TRACKING_PARAMS.has(k)) return "TRACKING";
|
|
253
|
+
if (AFFILIATE_PARAMS.has(k)) return "AFFILIATE";
|
|
254
|
+
return "OTHER";
|
|
255
|
+
}
|
|
256
|
+
var STATUS_TEXT = {
|
|
257
|
+
200: "OK",
|
|
258
|
+
201: "Created",
|
|
259
|
+
204: "No Content",
|
|
260
|
+
301: "Moved Permanently",
|
|
261
|
+
302: "Found",
|
|
262
|
+
303: "See Other",
|
|
263
|
+
307: "Temporary Redirect",
|
|
264
|
+
308: "Permanent Redirect",
|
|
265
|
+
400: "Bad Request",
|
|
266
|
+
401: "Unauthorized",
|
|
267
|
+
403: "Forbidden",
|
|
268
|
+
404: "Not Found",
|
|
269
|
+
429: "Too Many Requests",
|
|
270
|
+
500: "Internal Server Error",
|
|
271
|
+
502: "Bad Gateway",
|
|
272
|
+
503: "Service Unavailable"
|
|
273
|
+
};
|
|
274
|
+
function colorStatus(code) {
|
|
275
|
+
const text = `${code} ${STATUS_TEXT[code] ?? ""}`;
|
|
276
|
+
if (code >= 200 && code < 300) return c.green(text);
|
|
277
|
+
if (code >= 300 && code < 400) return c.yellow(text);
|
|
278
|
+
return c.red(text);
|
|
279
|
+
}
|
|
280
|
+
var MAX_URL_DISPLAY = 120;
|
|
281
|
+
function sanitizeForDisplay(url) {
|
|
282
|
+
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, "");
|
|
283
|
+
}
|
|
284
|
+
function truncateUrl(url) {
|
|
285
|
+
if (url.length <= MAX_URL_DISPLAY) return { display: url, truncated: false };
|
|
286
|
+
return { display: `${url.slice(0, 80)}...${url.slice(-37)}`, truncated: true };
|
|
287
|
+
}
|
|
288
|
+
function safeUrl(raw) {
|
|
289
|
+
return truncateUrl(sanitizeForDisplay(raw));
|
|
290
|
+
}
|
|
291
|
+
function printRich(result, showChain) {
|
|
292
|
+
if (result.error) {
|
|
293
|
+
console.error(` ${c.red("\u2717")} ${safeUrl(result.originalUrl).display}`);
|
|
294
|
+
console.error(` ${c.dim(result.error)}`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const isHttps = result.expandedUrl.startsWith("https://");
|
|
298
|
+
const hopCount = result.redirectChain.length;
|
|
299
|
+
const lbl = (s) => c.dim(s.padEnd(10));
|
|
300
|
+
const expanded = safeUrl(result.expandedUrl);
|
|
301
|
+
console.log();
|
|
302
|
+
console.log(` ${lbl("Original")} ${safeUrl(result.originalUrl).display}`);
|
|
303
|
+
console.log(` ${lbl("Expanded")} ${c.cyan(expanded.display)}`);
|
|
304
|
+
if (expanded.truncated) {
|
|
305
|
+
console.log(` ${lbl("")} ${c.dim("full URL available via --json")}`);
|
|
306
|
+
}
|
|
307
|
+
console.log(` ${lbl("Status")} ${colorStatus(result.statusCode)}`);
|
|
308
|
+
console.log(` ${lbl("Security")} ${isHttps ? c.green("\u2713 HTTPS") : c.yellow("\u26A0 HTTP")}`);
|
|
309
|
+
if (hopCount > 0) {
|
|
310
|
+
console.log(` ${lbl("Hops")} ${hopCount} redirect${hopCount === 1 ? "" : "s"}`);
|
|
311
|
+
}
|
|
312
|
+
if (showChain && hopCount > 0) {
|
|
313
|
+
console.log();
|
|
314
|
+
console.log(` ${c.bold("Redirect Chain")}`);
|
|
315
|
+
result.redirectChain.forEach((url, i) => {
|
|
316
|
+
console.log(` ${c.dim(`${i + 1}.`)} ${safeUrl(url).display}`);
|
|
317
|
+
});
|
|
318
|
+
console.log(` ${c.dim("\u2192")} ${expanded.display}`);
|
|
319
|
+
}
|
|
320
|
+
let params;
|
|
321
|
+
try {
|
|
322
|
+
params = new URL(result.expandedUrl).searchParams;
|
|
323
|
+
} catch {
|
|
324
|
+
console.log();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const entries = [...params.entries()];
|
|
328
|
+
if (entries.length > 0) {
|
|
329
|
+
console.log();
|
|
330
|
+
console.log(` ${c.bold("Query Parameters")} ${c.dim(`(${entries.length})`)}`);
|
|
331
|
+
const keyWidth = Math.min(Math.max(...entries.map(([k]) => k.length), 8), 24);
|
|
332
|
+
for (const [key, value] of entries) {
|
|
333
|
+
const type = paramType(key);
|
|
334
|
+
const typeStr = type === "AFFILIATE" ? c.red(type) : type === "TRACKING" ? c.yellow(type) : c.dim(type);
|
|
335
|
+
const truncated = value.length > 40 ? `${value.slice(0, 37)}...` : value;
|
|
336
|
+
console.log(` ${c.dim(key.padEnd(keyWidth + 2))} ${truncated.padEnd(42)} ${typeStr}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
console.log();
|
|
340
|
+
}
|
|
341
|
+
var HELP = `
|
|
342
|
+
Usage: xpandurl <url> [url2 ...] [options]
|
|
343
|
+
|
|
344
|
+
Arguments:
|
|
345
|
+
url One or more URLs to expand
|
|
346
|
+
|
|
347
|
+
Options:
|
|
348
|
+
--chain Show the full redirect chain
|
|
349
|
+
--json Output results as JSON
|
|
350
|
+
--timeout Request timeout in milliseconds (default: 10000)
|
|
351
|
+
--help Show this help message
|
|
352
|
+
|
|
353
|
+
Examples:
|
|
354
|
+
xpandurl https://bit.ly/xyz
|
|
355
|
+
xpandurl https://bit.ly/xyz --chain
|
|
356
|
+
xpandurl https://bit.ly/a https://t.co/b --json
|
|
357
|
+
`.trim();
|
|
358
|
+
function parseArgs(argv) {
|
|
359
|
+
const urls = [];
|
|
360
|
+
const options = {};
|
|
361
|
+
let showChain = false;
|
|
362
|
+
let jsonOutput = false;
|
|
363
|
+
for (let i = 0; i < argv.length; i++) {
|
|
364
|
+
const arg = argv[i];
|
|
365
|
+
if (arg === "--help") {
|
|
366
|
+
console.log(HELP);
|
|
367
|
+
process.exit(0);
|
|
368
|
+
} else if (arg === "--chain") {
|
|
369
|
+
showChain = true;
|
|
370
|
+
} else if (arg === "--json") {
|
|
371
|
+
jsonOutput = true;
|
|
372
|
+
} else if (arg === "--timeout") {
|
|
373
|
+
const val = Number(argv[++i]);
|
|
374
|
+
if (!Number.isFinite(val) || val <= 0) {
|
|
375
|
+
console.error("Error: --timeout must be a positive number");
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
options.timeout = val;
|
|
379
|
+
} else if (arg.startsWith("--")) {
|
|
380
|
+
console.error(`Error: Unknown option "${arg}". Run with --help for usage.`);
|
|
381
|
+
process.exit(1);
|
|
382
|
+
} else {
|
|
383
|
+
urls.push(arg);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { urls, options, showChain, jsonOutput };
|
|
387
|
+
}
|
|
388
|
+
async function main() {
|
|
389
|
+
const { urls, options, showChain, jsonOutput } = parseArgs(process.argv.slice(2));
|
|
390
|
+
if (urls.length === 0) {
|
|
391
|
+
console.error("Error: No URL provided.\n");
|
|
392
|
+
console.log(HELP);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
if (urls.length === 1) {
|
|
397
|
+
const result = await expand(urls[0], options);
|
|
398
|
+
if (jsonOutput) {
|
|
399
|
+
console.log(JSON.stringify(result, null, 2));
|
|
400
|
+
} else {
|
|
401
|
+
printRich(result, showChain);
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
const results = await expandMany(urls, options);
|
|
405
|
+
if (jsonOutput) {
|
|
406
|
+
console.log(JSON.stringify(results, null, 2));
|
|
407
|
+
} else {
|
|
408
|
+
results.forEach((result) => printRich(result, showChain));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} catch (err) {
|
|
412
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
main();
|
|
417
|
+
//# sourceMappingURL=cli.cjs.map
|