@layerporter/image-optimizer-mcp 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.
@@ -0,0 +1,204 @@
1
+ const DEFAULTS = Object.freeze({
2
+ oversizeRatioWarn: 1.5,
3
+ oversizeRatioHigh: 2,
4
+ minRenderedPixels: 16,
5
+ });
6
+
7
+ function finitePositive(value) {
8
+ return Number.isFinite(value) && value > 0;
9
+ }
10
+
11
+ function clamp(value, min, max) {
12
+ return Math.min(max, Math.max(min, value));
13
+ }
14
+
15
+ function normalizeUrl(value) {
16
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
17
+ }
18
+
19
+ function confidenceFromEvidence(evidenceCount, max = 6) {
20
+ return Number(clamp(evidenceCount / max, 0.25, 1).toFixed(2));
21
+ }
22
+
23
+ export function analyzeImageUsage(image, options = {}) {
24
+ if (!image || typeof image !== 'object') throw new TypeError('image facts are required');
25
+
26
+ const cfg = { ...DEFAULTS, ...options };
27
+ const intrinsicWidth = Number(image.intrinsicWidth ?? 0);
28
+ const intrinsicHeight = Number(image.intrinsicHeight ?? 0);
29
+ const renderedWidth = Number(image.renderedWidth ?? 0);
30
+ const renderedHeight = Number(image.renderedHeight ?? 0);
31
+ const dpr = finitePositive(Number(image.devicePixelRatio)) ? Number(image.devicePixelRatio) : 1;
32
+
33
+ const hasDimensions = [intrinsicWidth, intrinsicHeight, renderedWidth, renderedHeight].every(finitePositive);
34
+ const requiredWidth = hasDimensions ? renderedWidth * dpr : null;
35
+ const requiredHeight = hasDimensions ? renderedHeight * dpr : null;
36
+ const widthRatio = hasDimensions ? intrinsicWidth / requiredWidth : null;
37
+ const heightRatio = hasDimensions ? intrinsicHeight / requiredHeight : null;
38
+ const oversizeRatio = hasDimensions ? Number(Math.max(widthRatio, heightRatio).toFixed(2)) : null;
39
+
40
+ const src = normalizeUrl(image.currentSrc ?? image.src);
41
+ const format = typeof image.format === 'string' ? image.format.toLowerCase() : null;
42
+ const bytes = Number.isFinite(image.bytes) && image.bytes >= 0 ? image.bytes : null;
43
+ const hasSrcset = typeof image.srcset === 'string' && image.srcset.trim().length > 0;
44
+ const hasSizes = typeof image.sizes === 'string' && image.sizes.trim().length > 0;
45
+ const loading = typeof image.loading === 'string' ? image.loading.toLowerCase() : null;
46
+ const fetchPriority = typeof image.fetchPriority === 'string' ? image.fetchPriority.toLowerCase() : null;
47
+ const widthAttr = Number(image.widthAttr ?? 0);
48
+ const heightAttr = Number(image.heightAttr ?? 0);
49
+ const hasDimensionAttrs = finitePositive(widthAttr) && finitePositive(heightAttr);
50
+
51
+ const evidence = [];
52
+ if (src) evidence.push({ type: 'url', value: src });
53
+ if (hasDimensions) evidence.push({ type: 'dimensions', intrinsic: [intrinsicWidth, intrinsicHeight], rendered: [renderedWidth, renderedHeight], dpr });
54
+ if (bytes !== null) evidence.push({ type: 'bytes', value: bytes });
55
+ if (format) evidence.push({ type: 'format', value: format });
56
+ if (image.lcp === true) evidence.push({ type: 'lcp', value: 'confirmed' });
57
+ else if (image.hero === true) evidence.push({ type: 'hero', value: 'page-marked' });
58
+
59
+ let role = 'content';
60
+ let roleConfidence = 0.5;
61
+ if (image.lcp === true) {
62
+ role = 'lcp';
63
+ roleConfidence = 1;
64
+ } else if (image.hero === true) {
65
+ role = 'hero_candidate';
66
+ roleConfidence = 0.75;
67
+ } else if (image.decorative === true) {
68
+ role = 'decorative';
69
+ roleConfidence = 0.9;
70
+ }
71
+
72
+ const findings = [];
73
+ const findingBase = {
74
+ category: 'image',
75
+ imageUrl: src,
76
+ evidence,
77
+ confidence: confidenceFromEvidence(evidence.length),
78
+ };
79
+
80
+ if (oversizeRatio !== null && oversizeRatio >= cfg.oversizeRatioWarn) {
81
+ findings.push({
82
+ ...findingBase,
83
+ id: 'oversized_image',
84
+ severity: oversizeRatio >= cfg.oversizeRatioHigh ? 'high' : 'medium',
85
+ impact: oversizeRatio >= cfg.oversizeRatioHigh ? 'high' : 'medium',
86
+ currentState: { intrinsicWidth, intrinsicHeight, renderedWidth, renderedHeight, dpr, oversizeRatio },
87
+ expectedState: { maxWidth: Math.ceil(requiredWidth), maxHeight: Math.ceil(requiredHeight) },
88
+ risk: 'low',
89
+ autoFixability: 'SAFE',
90
+ suggestedFix: 'resize_or_generate_responsive_variant',
91
+ verificationMethod: 'dimensions_and_bytes_before_after',
92
+ });
93
+ }
94
+
95
+ if (!hasSrcset && hasDimensions && renderedWidth >= cfg.minRenderedPixels) {
96
+ findings.push({
97
+ ...findingBase,
98
+ id: 'missing_srcset',
99
+ severity: oversizeRatio !== null && oversizeRatio >= cfg.oversizeRatioWarn ? 'medium' : 'low',
100
+ impact: 'medium',
101
+ currentState: { srcset: null, sizes: hasSizes ? image.sizes : null },
102
+ expectedState: { responsiveVariants: true },
103
+ risk: 'low',
104
+ autoFixability: 'SAFE',
105
+ suggestedFix: 'generate_responsive_variants',
106
+ verificationMethod: 'responsive_markup_and_network_bytes',
107
+ });
108
+ }
109
+
110
+ if (hasSrcset && !hasSizes) {
111
+ findings.push({
112
+ ...findingBase,
113
+ id: 'missing_sizes',
114
+ severity: 'low',
115
+ impact: 'medium',
116
+ currentState: { srcset: image.srcset, sizes: null },
117
+ expectedState: { sizes: 'context_derived' },
118
+ risk: 'review',
119
+ autoFixability: 'REVIEW',
120
+ suggestedFix: 'derive_sizes_from_layout_context',
121
+ verificationMethod: 'viewport_candidate_selection',
122
+ });
123
+ }
124
+
125
+ if (!hasDimensionAttrs && hasDimensions) {
126
+ findings.push({
127
+ ...findingBase,
128
+ id: 'missing_dimension_attributes',
129
+ severity: 'low',
130
+ impact: 'medium',
131
+ currentState: { widthAttr: widthAttr || null, heightAttr: heightAttr || null },
132
+ expectedState: { width: intrinsicWidth, height: intrinsicHeight },
133
+ risk: 'low',
134
+ autoFixability: 'SAFE',
135
+ suggestedFix: 'add_width_height_attributes',
136
+ verificationMethod: 'dom_attributes_and_cls_regression',
137
+ });
138
+ }
139
+
140
+ if (role === 'lcp' && loading === 'lazy') {
141
+ findings.push({
142
+ ...findingBase,
143
+ id: 'lcp_lazy_loaded',
144
+ severity: 'high',
145
+ impact: 'high',
146
+ currentState: { loading, fetchPriority },
147
+ expectedState: { loading: 'eager_or_default', fetchPriority: 'high_when_confirmed' },
148
+ risk: 'low',
149
+ autoFixability: 'SAFE',
150
+ suggestedFix: 'remove_lazy_from_confirmed_lcp',
151
+ verificationMethod: 'lcp_resource_timing_before_after',
152
+ });
153
+ }
154
+
155
+ if (role === 'lcp' && fetchPriority !== 'high') {
156
+ findings.push({
157
+ ...findingBase,
158
+ id: 'lcp_missing_fetchpriority',
159
+ severity: 'medium',
160
+ impact: 'medium',
161
+ currentState: { fetchPriority: fetchPriority || null },
162
+ expectedState: { fetchPriority: 'high' },
163
+ risk: 'low',
164
+ autoFixability: 'SAFE',
165
+ suggestedFix: 'set_fetchpriority_high',
166
+ verificationMethod: 'resource_priority_and_lcp_before_after',
167
+ });
168
+ }
169
+
170
+ return {
171
+ src,
172
+ format,
173
+ bytes,
174
+ intrinsic: hasDimensions ? { width: intrinsicWidth, height: intrinsicHeight } : null,
175
+ rendered: hasDimensions ? { width: renderedWidth, height: renderedHeight, dpr } : null,
176
+ required: hasDimensions ? { width: Math.ceil(requiredWidth), height: Math.ceil(requiredHeight) } : null,
177
+ oversizeRatio,
178
+ responsive: { hasSrcset, hasSizes, srcset: hasSrcset ? image.srcset : null, sizes: hasSizes ? image.sizes : null },
179
+ loading: { loading, fetchPriority },
180
+ attributes: { width: widthAttr || null, height: heightAttr || null, complete: hasDimensionAttrs },
181
+ role: { value: role, confidence: roleConfidence },
182
+ findings,
183
+ };
184
+ }
185
+
186
+ export function analyzePageImages(snapshot, options = {}) {
187
+ if (!snapshot || typeof snapshot !== 'object') throw new TypeError('page snapshot is required');
188
+ if (!Array.isArray(snapshot.images)) throw new TypeError('page snapshot.images must be an array');
189
+
190
+ const pageUrl = normalizeUrl(snapshot.pageUrl);
191
+ const images = snapshot.images.map((image, index) => ({ index, ...analyzeImageUsage(image, options) }));
192
+ const findings = images.flatMap((item) => item.findings.map((finding) => ({ ...finding, pageUrl, imageIndex: item.index })));
193
+
194
+ const priority = { high: 3, medium: 2, low: 1 };
195
+ findings.sort((a, b) => (priority[b.severity] ?? 0) - (priority[a.severity] ?? 0));
196
+
197
+ return {
198
+ pageUrl,
199
+ imageCount: images.length,
200
+ findingsCount: findings.length,
201
+ images,
202
+ findings,
203
+ };
204
+ }
@@ -0,0 +1,49 @@
1
+ export const ALLOWED_FORMATS = new Set(['jpeg', 'png', 'webp', 'avif']);
2
+
3
+ export const DEFAULT_POLICY = Object.freeze({
4
+ format: 'auto',
5
+ quality: 82,
6
+ avifQuality: 50,
7
+ effort: 4,
8
+ withoutEnlargement: true,
9
+ preserveAlpha: true,
10
+ preserveOrientation: true,
11
+ neverIncreaseBytes: true,
12
+ maxWidth: 8192,
13
+ maxHeight: 8192,
14
+ maxPixels: 40_000_000,
15
+ });
16
+
17
+ export function validatePolicy(policy = {}) {
18
+ const merged = { ...DEFAULT_POLICY, ...policy };
19
+
20
+ if (!['auto', ...ALLOWED_FORMATS].includes(merged.format)) {
21
+ throw new TypeError(`Unsupported output format: ${merged.format}`);
22
+ }
23
+
24
+ if (!Number.isInteger(merged.quality) || merged.quality < 1 || merged.quality > 100) {
25
+ throw new TypeError('quality must be an integer between 1 and 100');
26
+ }
27
+
28
+ if (!Number.isInteger(merged.avifQuality) || merged.avifQuality < 1 || merged.avifQuality > 100) {
29
+ throw new TypeError('avifQuality must be an integer between 1 and 100');
30
+ }
31
+
32
+ if (!Number.isInteger(merged.effort) || merged.effort < 0 || merged.effort > 9) {
33
+ throw new TypeError('effort must be an integer between 0 and 9');
34
+ }
35
+
36
+ return merged;
37
+ }
38
+
39
+ export function chooseOutputFormat(metadata, policy) {
40
+ if (policy.format !== 'auto') return policy.format;
41
+
42
+ if (metadata.hasAlpha) {
43
+ return metadata.format === 'png' ? 'webp' : metadata.format || 'webp';
44
+ }
45
+
46
+ if (metadata.format === 'jpeg' || metadata.format === 'png') return 'webp';
47
+ if (metadata.format === 'webp' || metadata.format === 'avif') return metadata.format;
48
+ return 'webp';
49
+ }
@@ -0,0 +1,91 @@
1
+ import { inspectImage, optimizeImage } from './index.js';
2
+
3
+ const SAFE_IMAGE_RULES = new Set(['oversized_image', 'missing_image_dimensions', 'missing_srcset']);
4
+ const BYTE_TRANSFORM_RULES = new Set(['oversized_image']);
5
+
6
+ function cloneBuffer(buffer) {
7
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw new TypeError('buffer must be a non-empty Buffer');
8
+ return Buffer.from(buffer);
9
+ }
10
+
11
+ export function buildSafeImageFixPlan({ finding, image }) {
12
+ if (!finding || !SAFE_IMAGE_RULES.has(finding.ruleId)) {
13
+ return { status: 'REVIEW_REQUIRED', reason: 'finding is outside the safe image allowlist' };
14
+ }
15
+ if (!BYTE_TRANSFORM_RULES.has(finding.ruleId)) {
16
+ return {
17
+ status: 'REVIEW_REQUIRED',
18
+ reason: 'this finding requires a markup patch; byte optimization alone cannot resolve it',
19
+ };
20
+ }
21
+ if (finding.confidence !== 'high') {
22
+ return { status: 'REVIEW_REQUIRED', reason: 'high confidence is required for safe image fix' };
23
+ }
24
+ const renderedWidth = Number(image?.renderedWidth || 0);
25
+ const renderedHeight = Number(image?.renderedHeight || 0);
26
+ const intrinsicWidth = Number(image?.intrinsicWidth || 0);
27
+ const intrinsicHeight = Number(image?.intrinsicHeight || 0);
28
+ if (!renderedWidth || !renderedHeight || !intrinsicWidth || !intrinsicHeight) {
29
+ return { status: 'REVIEW_REQUIRED', reason: 'complete rendered and intrinsic dimensions are required' };
30
+ }
31
+ if (intrinsicWidth <= renderedWidth * 2 && intrinsicHeight <= renderedHeight * 2) {
32
+ return { status: 'REVIEW_REQUIRED', reason: 'oversize threshold is not independently confirmed' };
33
+ }
34
+ return {
35
+ status: 'READY',
36
+ ruleId: finding.ruleId,
37
+ target: {
38
+ width: Math.min(intrinsicWidth, Math.ceil(renderedWidth)),
39
+ height: Math.min(intrinsicHeight, Math.ceil(renderedHeight)),
40
+ },
41
+ policy: {
42
+ withoutEnlargement: true,
43
+ preserveAlpha: true,
44
+ neverIncreaseBytes: true,
45
+ },
46
+ };
47
+ }
48
+
49
+ export async function prepareSafeImageFix({ buffer, plan, policy = {} }) {
50
+ if (!plan || plan.status !== 'READY') throw new TypeError('READY safe image fix plan required');
51
+ const originalBuffer = cloneBuffer(buffer);
52
+ const before = await inspectImage(originalBuffer, { policy: { ...plan.policy, ...policy } });
53
+ const optimized = await optimizeImage(originalBuffer, {
54
+ target: plan.target,
55
+ policy: { ...plan.policy, ...policy },
56
+ });
57
+
58
+ if (optimized.status !== 'ACCEPT' || !Buffer.isBuffer(optimized.buffer)) {
59
+ return {
60
+ status: 'REJECT',
61
+ reason: optimized.reason || 'candidate failed optimization guards',
62
+ before,
63
+ after: optimized.output || null,
64
+ savingsBytes: optimized.savingsBytes ?? 0,
65
+ candidateBuffer: null,
66
+ };
67
+ }
68
+
69
+ const candidateBuffer = Buffer.from(optimized.buffer);
70
+ const after = await inspectImage(candidateBuffer, { policy: { ...plan.policy, ...policy } });
71
+ const savingsBytes = before.bytes - after.bytes;
72
+ const noUpscale = after.width <= before.width && after.height <= before.height;
73
+ const alphaPreserved = !before.hasAlpha || after.hasAlpha;
74
+ const smaller = savingsBytes > 0;
75
+ const targetSatisfied = after.width <= plan.target.width && after.height <= plan.target.height;
76
+ const originalUnchanged = buffer.equals(originalBuffer);
77
+ const guardsPass = noUpscale && alphaPreserved && smaller && targetSatisfied && originalUnchanged;
78
+
79
+ return {
80
+ status: guardsPass ? 'PREVIEW_READY' : 'REJECT',
81
+ before,
82
+ after,
83
+ savingsBytes,
84
+ savingsPercent: Number(((savingsBytes / before.bytes) * 100).toFixed(2)),
85
+ checks: { noUpscale, alphaPreserved, smaller, targetSatisfied, originalUnchanged },
86
+ candidateBuffer: guardsPass ? candidateBuffer : null,
87
+ };
88
+ }
89
+
90
+ export const SAFE_IMAGE_FIX_RULES = Object.freeze([...SAFE_IMAGE_RULES]);
91
+ export const BYTE_TRANSFORM_SAFE_IMAGE_RULES = Object.freeze([...BYTE_TRANSFORM_RULES]);
@@ -0,0 +1,193 @@
1
+ import dns from 'node:dns';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import net from 'node:net';
5
+
6
+ const BLOCKED_V4 = new net.BlockList();
7
+ for (const [network, prefix] of [
8
+ ['0.0.0.0', 8],
9
+ ['10.0.0.0', 8],
10
+ ['100.64.0.0', 10],
11
+ ['127.0.0.0', 8],
12
+ ['169.254.0.0', 16],
13
+ ['172.16.0.0', 12],
14
+ ['192.0.0.0', 24],
15
+ ['192.0.2.0', 24],
16
+ ['192.168.0.0', 16],
17
+ ['198.18.0.0', 15],
18
+ ['198.51.100.0', 24],
19
+ ['203.0.113.0', 24],
20
+ ['224.0.0.0', 4],
21
+ ['240.0.0.0', 4],
22
+ ]) BLOCKED_V4.addSubnet(network, prefix, 'ipv4');
23
+
24
+ const BLOCKED_V6 = new net.BlockList();
25
+ for (const [network, prefix] of [
26
+ ['::', 128],
27
+ ['::1', 128],
28
+ ['100::', 64],
29
+ ['2001:db8::', 32],
30
+ ['fc00::', 7],
31
+ ['fe80::', 10],
32
+ ['ff00::', 8],
33
+ ]) BLOCKED_V6.addSubnet(network, prefix, 'ipv6');
34
+
35
+ function securityError(code, message) {
36
+ const error = new Error(message);
37
+ error.code = code;
38
+ return error;
39
+ }
40
+
41
+ function bareHostname(value) {
42
+ return value.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
43
+ }
44
+
45
+ export function isBlockedAddress(address) {
46
+ const family = net.isIP(address);
47
+ if (!family) return true;
48
+ if (family === 4) return BLOCKED_V4.check(address, 'ipv4');
49
+
50
+ const lower = address.toLowerCase();
51
+ if (lower.startsWith('::ffff:')) {
52
+ const mapped = lower.slice('::ffff:'.length);
53
+ if (net.isIP(mapped) === 4) return BLOCKED_V4.check(mapped, 'ipv4');
54
+ }
55
+ return BLOCKED_V6.check(address, 'ipv6');
56
+ }
57
+
58
+ export function validateRemoteUrl(input) {
59
+ let url;
60
+ try {
61
+ url = input instanceof URL ? new URL(input.href) : new URL(String(input));
62
+ } catch {
63
+ throw securityError('INVALID_URL', 'invalid URL');
64
+ }
65
+
66
+ if (!['http:', 'https:'].includes(url.protocol)) throw securityError('UNSAFE_SCHEME', 'only http and https URLs are allowed');
67
+ if (url.username || url.password) throw securityError('URL_CREDENTIALS_BLOCKED', 'credentials in URLs are not allowed');
68
+
69
+ const hostname = bareHostname(url.hostname);
70
+ if (!hostname) throw securityError('INVALID_HOST', 'URL hostname is required');
71
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) throw securityError('PRIVATE_HOST_BLOCKED', 'localhost is not allowed');
72
+ if (net.isIP(hostname) && isBlockedAddress(hostname)) throw securityError('PRIVATE_IP_BLOCKED', 'private or non-routable IP is not allowed');
73
+
74
+ return url;
75
+ }
76
+
77
+ export function createSafeLookup(lookupFn = dns.lookup) {
78
+ return function safeLookup(hostname, options, callback) {
79
+ lookupFn(hostname, { all: true, verbatim: true }, (error, addresses) => {
80
+ if (error) return callback(error);
81
+ if (!Array.isArray(addresses) || addresses.length === 0) return callback(securityError('DNS_EMPTY', 'hostname did not resolve'));
82
+ if (addresses.some((entry) => isBlockedAddress(entry.address))) {
83
+ return callback(securityError('PRIVATE_DNS_BLOCKED', 'hostname resolved to a private or non-routable IP'));
84
+ }
85
+
86
+ const requestedFamily = typeof options === 'number' ? options : options?.family;
87
+ const candidates = requestedFamily === 4 || requestedFamily === 6
88
+ ? addresses.filter((entry) => entry.family === requestedFamily)
89
+ : addresses;
90
+ if (candidates.length === 0) return callback(securityError('DNS_FAMILY_MISMATCH', 'hostname has no address for requested family'));
91
+
92
+ if (typeof options === 'object' && options?.all) return callback(null, candidates);
93
+ return callback(null, candidates[0].address, candidates[0].family);
94
+ });
95
+ };
96
+ }
97
+
98
+ export async function requestOnce(url, options = {}) {
99
+ const safeUrl = validateRemoteUrl(url);
100
+ const maxBytes = options.maxBytes ?? 2 * 1024 * 1024;
101
+ const timeoutMs = options.timeoutMs ?? 8000;
102
+ const lookup = options.lookup ?? createSafeLookup();
103
+ const hostname = bareHostname(safeUrl.hostname);
104
+ const transport = safeUrl.protocol === 'https:' ? https : http;
105
+
106
+ return new Promise((resolve, reject) => {
107
+ let settled = false;
108
+ const fail = (error) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ reject(error);
112
+ };
113
+
114
+ const request = transport.request({
115
+ protocol: safeUrl.protocol,
116
+ hostname,
117
+ port: safeUrl.port || undefined,
118
+ path: `${safeUrl.pathname}${safeUrl.search}`,
119
+ method: 'GET',
120
+ lookup,
121
+ agent: false,
122
+ servername: net.isIP(hostname) ? undefined : hostname,
123
+ headers: {
124
+ 'user-agent': 'LayerPorter-Image-Optimizer/0.1 (+https://layerporter.com/)',
125
+ accept: options.accept ?? '*/*',
126
+ 'accept-encoding': 'identity',
127
+ connection: 'close',
128
+ },
129
+ }, (response) => {
130
+ const status = response.statusCode ?? 0;
131
+ const headers = response.headers;
132
+ const location = headers.location;
133
+
134
+ if ([301, 302, 303, 307, 308].includes(status) && location) {
135
+ response.resume();
136
+ if (!settled) {
137
+ settled = true;
138
+ resolve({ status, headers, body: Buffer.alloc(0), location });
139
+ }
140
+ return;
141
+ }
142
+
143
+ const contentLength = Number(headers['content-length'] ?? 0);
144
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
145
+ response.destroy();
146
+ return fail(securityError('RESPONSE_TOO_LARGE', 'response exceeds byte limit'));
147
+ }
148
+
149
+ const chunks = [];
150
+ let bytes = 0;
151
+ response.on('data', (chunk) => {
152
+ bytes += chunk.length;
153
+ if (bytes > maxBytes) {
154
+ response.destroy();
155
+ fail(securityError('RESPONSE_TOO_LARGE', 'response exceeds byte limit'));
156
+ return;
157
+ }
158
+ chunks.push(chunk);
159
+ });
160
+ response.on('error', fail);
161
+ response.on('end', () => {
162
+ if (settled) return;
163
+ settled = true;
164
+ resolve({ status, headers, body: Buffer.concat(chunks), location: null });
165
+ });
166
+ });
167
+
168
+ request.setTimeout(timeoutMs, () => request.destroy(securityError('REQUEST_TIMEOUT', 'request timed out')));
169
+ request.on('error', fail);
170
+ request.end();
171
+ });
172
+ }
173
+
174
+ export async function secureFetch(input, options = {}) {
175
+ const maxRedirects = Math.max(0, Math.min(options.maxRedirects ?? 3, 5));
176
+ const requestOnceImpl = options.requestOnceImpl ?? requestOnce;
177
+ let current = validateRemoteUrl(input);
178
+ const redirects = [];
179
+
180
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
181
+ const response = await requestOnceImpl(current, options);
182
+ if ([301, 302, 303, 307, 308].includes(response.status) && response.location) {
183
+ if (hop >= maxRedirects) throw securityError('TOO_MANY_REDIRECTS', 'redirect limit reached');
184
+ const next = validateRemoteUrl(new URL(response.location, current));
185
+ redirects.push({ from: current.href, to: next.href, status: response.status });
186
+ current = next;
187
+ continue;
188
+ }
189
+ return { ...response, url: current.href, redirects };
190
+ }
191
+
192
+ throw securityError('TOO_MANY_REDIRECTS', 'redirect limit reached');
193
+ }