@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.
- package/LICENSE +45 -0
- package/README.md +124 -0
- package/package.json +52 -0
- package/server.json +23 -0
- package/src/artifact-result.js +90 -0
- package/src/artifact-store.js +153 -0
- package/src/image-core/index.js +168 -0
- package/src/image-core/page-analysis.js +204 -0
- package/src/image-core/policy.js +49 -0
- package/src/image-core/safe-fix.js +91 -0
- package/src/secure-fetch.js +193 -0
- package/src/server.js +178 -0
- package/src/text-result.js +49 -0
- package/src/tools.js +81 -0
- package/src/url-ingestion.js +228 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
LayerPorter Proprietary Software License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LayerPorter. All rights reserved.
|
|
4
|
+
|
|
5
|
+
1. LIMITED LICENSE
|
|
6
|
+
|
|
7
|
+
Subject to your compliance with this License, LayerPorter grants you a limited, non-exclusive, non-transferable, non-sublicensable right to download, install, and use the unmodified official LayerPorter Website Image Optimizer MCP package solely for its intended functionality.
|
|
8
|
+
|
|
9
|
+
You may make only those copies that are technically necessary for installation, execution, backup, or package-manager caching, and only for those purposes.
|
|
10
|
+
|
|
11
|
+
2. RESTRICTIONS
|
|
12
|
+
|
|
13
|
+
Except where expressly permitted by this License or required by applicable law, you may not, and may not permit any third party to:
|
|
14
|
+
|
|
15
|
+
(a) copy or reproduce the Software except for the limited technical copies expressly permitted above;
|
|
16
|
+
(b) modify, adapt, translate, alter, or create derivative works from the Software;
|
|
17
|
+
(c) distribute, publish, republish, mirror, host for third-party download, sublicense, sell, rent, lease, assign, transfer, or otherwise make the Software available to third parties;
|
|
18
|
+
(d) repackage, bundle, embed, or include the Software or substantial portions of it in another product, package, service, repository, or distribution;
|
|
19
|
+
(e) reverse engineer, decompile, disassemble, or otherwise attempt to derive source code or underlying implementation details, except to the limited extent such restriction is prohibited by applicable law;
|
|
20
|
+
(f) remove, obscure, or alter copyright, ownership, trademark, or other proprietary notices;
|
|
21
|
+
(g) use the Software, its source code, or substantial portions of its implementation to create, train, reproduce, or distribute a competing or substantially similar software product.
|
|
22
|
+
|
|
23
|
+
3. OWNERSHIP
|
|
24
|
+
|
|
25
|
+
The Software is licensed, not sold. LayerPorter retains all right, title, and interest in and to the Software, including all intellectual property rights not expressly granted under this License. No rights to LayerPorter names, marks, logos, branding, or other identifiers are granted.
|
|
26
|
+
|
|
27
|
+
4. THIRD-PARTY COMPONENTS
|
|
28
|
+
|
|
29
|
+
Third-party software, libraries, or dependencies used with the Software remain subject to their respective licenses. This License does not replace or restrict rights granted by those third-party licenses.
|
|
30
|
+
|
|
31
|
+
5. TERMINATION
|
|
32
|
+
|
|
33
|
+
Your rights under this License terminate automatically if you materially breach its terms. Upon termination, you must stop using the Software and delete copies in your possession or control, except copies that applicable law requires you to retain.
|
|
34
|
+
|
|
35
|
+
6. DISCLAIMER
|
|
36
|
+
|
|
37
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW.
|
|
38
|
+
|
|
39
|
+
7. LIMITATION OF LIABILITY
|
|
40
|
+
|
|
41
|
+
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, LAYERPORTER WILL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOSS OF PROFITS, REVENUE, DATA, OR BUSINESS OPPORTUNITY ARISING FROM OR RELATED TO THE SOFTWARE.
|
|
42
|
+
|
|
43
|
+
8. NO IMPLIED RIGHTS
|
|
44
|
+
|
|
45
|
+
No permission is granted by implication, estoppel, or otherwise beyond the limited rights expressly stated in this License.
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# LayerPorter Website Image Optimizer MCP
|
|
2
|
+
|
|
3
|
+
Technical candidate for page-aware website image optimization through Model Context Protocol.
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
The package is release-prepared but is **not yet claimed as a public npm or Official MCP Registry release**. Publication status changes only after the exact npm version and Registry entry are externally verified.
|
|
8
|
+
|
|
9
|
+
Registry identity:
|
|
10
|
+
|
|
11
|
+
- npm package: `@layerporter/image-optimizer-mcp`
|
|
12
|
+
- MCP server: `com.layerporter/website-image-optimizer`
|
|
13
|
+
- transport: `stdio`
|
|
14
|
+
- Node.js: `>=22.12.0`
|
|
15
|
+
|
|
16
|
+
The npm package carries `mcpName: com.layerporter/website-image-optimizer`, matching `server.json`, so the Official MCP Registry can verify package ownership after the package exists publicly on npm.
|
|
17
|
+
|
|
18
|
+
## Tools
|
|
19
|
+
|
|
20
|
+
The current server registers seven bounded tools:
|
|
21
|
+
|
|
22
|
+
- `analyze_page_images`
|
|
23
|
+
- `optimize_image`
|
|
24
|
+
- `generate_responsive_variants`
|
|
25
|
+
- `compare_image_versions`
|
|
26
|
+
- `optimize_page_images`
|
|
27
|
+
- `analyze_url_images`
|
|
28
|
+
- `optimize_url_images`
|
|
29
|
+
|
|
30
|
+
The first five operate on caller-provided image data or normalized page facts. The two URL tools perform bounded read-only HTTP(S) fetching through a dedicated SSRF-protected network boundary. URL mode is intentionally a fast static-HTML mode: it does not claim browser-rendered size, `currentSrc`, LCP, CSS-background discovery, or JavaScript-driven lazy content.
|
|
31
|
+
|
|
32
|
+
Accepted optimized binaries are exposed as temporary MCP `resource_link` values and are read separately with `resources/read`; they are not embedded in the initial tool text response.
|
|
33
|
+
|
|
34
|
+
## Runtime
|
|
35
|
+
|
|
36
|
+
- Node.js `>=22.12.0`
|
|
37
|
+
- MCP v2 server package
|
|
38
|
+
- stdio transport
|
|
39
|
+
- image processing through the shared image core / Sharp / libvips
|
|
40
|
+
|
|
41
|
+
## Local source setup
|
|
42
|
+
|
|
43
|
+
A source checkout does not contain the generated `src/image-core` copy. Sync it explicitly before MCP tests or direct server startup:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd packages/image-core
|
|
47
|
+
npm install --no-audit --no-fund
|
|
48
|
+
npm test
|
|
49
|
+
|
|
50
|
+
cd ../image-optimizer-mcp
|
|
51
|
+
npm install --ignore-scripts --no-audit --no-fund
|
|
52
|
+
npm run release:preflight
|
|
53
|
+
npm run sync:core
|
|
54
|
+
npm test
|
|
55
|
+
node src/server.js
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The last command starts the stdio MCP server and waits for a client connection. `npm pack` also runs `sync:core` through `prepack`; source setup and packed-artifact setup are verified separately.
|
|
59
|
+
|
|
60
|
+
## Installation after public release
|
|
61
|
+
|
|
62
|
+
Do not treat this command as available until the npm package has actually been published and verified:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx -y @layerporter/image-optimizer-mcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
This package is proprietary LayerPorter software. The official unmodified package may be downloaded, installed, and used under the terms in `LICENSE`. Copying beyond technically necessary installation/runtime copies, modification, derivative works, repackaging, redistribution, sublicensing, resale, and unauthorized hosting are prohibited.
|
|
71
|
+
|
|
72
|
+
The software is licensed, not sold. Third-party dependencies remain governed by their own licenses.
|
|
73
|
+
|
|
74
|
+
## Release model
|
|
75
|
+
|
|
76
|
+
The first npm version is a bootstrap release: npm staged publishing and Trusted Publisher configuration require the package to already exist. The first public package therefore must be published by the package owner with npm account 2FA. After that bootstrap, GitHub Actions Trusted Publishing can be configured for `.github/workflows/image-mcp-release.yml`, and later versions can use the staged OIDC release path.
|
|
77
|
+
|
|
78
|
+
Official MCP Registry publication happens only **after** the matching npm version is publicly available. The Registry namespace uses ownership of `layerporter.com` and the server name `com.layerporter/website-image-optimizer`.
|
|
79
|
+
|
|
80
|
+
## Safety boundary
|
|
81
|
+
|
|
82
|
+
The current MCP package:
|
|
83
|
+
|
|
84
|
+
- performs bounded public HTTP(S) reads only through `analyze_url_images` and `optimize_url_images`;
|
|
85
|
+
- blocks unsafe URL schemes, URL credentials, localhost/private/link-local/reserved targets, unsafe DNS answers and redirects to prohibited addresses;
|
|
86
|
+
- caps page bytes, per-image bytes, accepted total image bytes, redirects, image count, timeouts and concurrency;
|
|
87
|
+
- writes accepted image artifacts only to its isolated temporary artifact store;
|
|
88
|
+
- does not perform arbitrary filesystem write/delete/rename operations;
|
|
89
|
+
- does not execute shell commands;
|
|
90
|
+
- does not write to a website or production environment.
|
|
91
|
+
|
|
92
|
+
Local transform tools consume caller-provided image buffers or normalized page facts. URL tools are read-only open-world operations. Hard safety comes from implementation controls, not only from MCP metadata.
|
|
93
|
+
|
|
94
|
+
Temporary artifacts are process-local and ephemeral. Their default TTL is 30 minutes, but cleanup occurs during store operations/disposal rather than as a guarantee of physical deletion at an exact wall-clock instant. `resources/read` returns the resource blob separately; whether a client places that blob into model context is client-specific.
|
|
95
|
+
|
|
96
|
+
## Verification
|
|
97
|
+
|
|
98
|
+
Release preparation verifies:
|
|
99
|
+
|
|
100
|
+
- package/server identity consistency;
|
|
101
|
+
- proprietary license metadata and packaged `LICENSE` presence;
|
|
102
|
+
- image-core and MCP tests;
|
|
103
|
+
- packed npm artifact metadata;
|
|
104
|
+
- clean installation of the packed tarball;
|
|
105
|
+
- real stdio startup and client handshake from the packed artifact;
|
|
106
|
+
- `tools/list`, transform calls, temporary `resource_link` and `resources/read`;
|
|
107
|
+
- Official MCP Registry `server.json` validation;
|
|
108
|
+
- full LayerPorter Astro build in the integrated repository tree.
|
|
109
|
+
|
|
110
|
+
## Public documentation
|
|
111
|
+
|
|
112
|
+
Canonical product pages after site release:
|
|
113
|
+
|
|
114
|
+
- `https://layerporter.com/mcp/website-image-optimizer/`
|
|
115
|
+
- `https://layerporter.com/docs/mcp/website-image-optimizer/`
|
|
116
|
+
|
|
117
|
+
## Not yet included
|
|
118
|
+
|
|
119
|
+
- confirmed public npm release;
|
|
120
|
+
- confirmed Official MCP Registry publication;
|
|
121
|
+
- hosted endpoint;
|
|
122
|
+
- full browser crawler/runtime metrics collection;
|
|
123
|
+
- production apply/write tool;
|
|
124
|
+
- universal performance or quality claims.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@layerporter/image-optimizer-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Page-aware MCP server for deterministic website image analysis and safe optimization.",
|
|
5
|
+
"mcpName": "com.layerporter/website-image-optimizer",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.12.0"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"layerporter-image-optimizer-mcp": "src/server.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src/**",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"server.json"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"sync:core": "node scripts/sync-image-core.mjs",
|
|
22
|
+
"prepack": "npm run sync:core",
|
|
23
|
+
"test": "node --test tests/*.test.js",
|
|
24
|
+
"release:preflight": "node scripts/release-preflight.mjs"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
28
|
+
"sharp": "0.35.4",
|
|
29
|
+
"zod": "^4.1.5"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/OlegStrateg/layerporter-site.git",
|
|
34
|
+
"directory": "packages/image-optimizer-mcp"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://layerporter.com/mcp/website-image-optimizer/",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/OlegStrateg/layerporter-site/issues"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"mcp",
|
|
42
|
+
"model-context-protocol",
|
|
43
|
+
"image-optimization",
|
|
44
|
+
"webp",
|
|
45
|
+
"avif",
|
|
46
|
+
"core-web-vitals",
|
|
47
|
+
"lcp"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "com.layerporter/website-image-optimizer",
|
|
4
|
+
"title": "LayerPorter Website Image Optimizer",
|
|
5
|
+
"description": "Page-aware website image analysis and safe optimization MCP server.",
|
|
6
|
+
"websiteUrl": "https://layerporter.com/mcp/website-image-optimizer/",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/OlegStrateg/layerporter-site",
|
|
9
|
+
"source": "github",
|
|
10
|
+
"subfolder": "packages/image-optimizer-mcp"
|
|
11
|
+
},
|
|
12
|
+
"version": "0.1.0",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "npm",
|
|
16
|
+
"identifier": "@layerporter/image-optimizer-mcp",
|
|
17
|
+
"version": "0.1.0",
|
|
18
|
+
"transport": {
|
|
19
|
+
"type": "stdio"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { sanitizeForText } from './text-result.js';
|
|
3
|
+
|
|
4
|
+
const MIME_BY_FORMAT = Object.freeze({
|
|
5
|
+
jpeg: 'image/jpeg',
|
|
6
|
+
jpg: 'image/jpeg',
|
|
7
|
+
png: 'image/png',
|
|
8
|
+
webp: 'image/webp',
|
|
9
|
+
avif: 'image/avif',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function safeStem(value) {
|
|
13
|
+
if (typeof value !== 'string' || !value.trim()) return 'optimized-image';
|
|
14
|
+
let raw = value.trim();
|
|
15
|
+
try {
|
|
16
|
+
raw = path.basename(new URL(raw).pathname) || raw;
|
|
17
|
+
} catch {
|
|
18
|
+
raw = path.basename(raw);
|
|
19
|
+
}
|
|
20
|
+
return (raw.replace(/\.[a-zA-Z0-9]+$/, '').replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 72) || 'optimized-image');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extensionFor(format) {
|
|
24
|
+
if (format === 'jpeg' || format === 'jpg') return 'jpg';
|
|
25
|
+
if (format === 'png' || format === 'webp' || format === 'avif') return format;
|
|
26
|
+
return 'bin';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function artifactName(context, object) {
|
|
30
|
+
const format = object.output?.format ?? object.outputFormat ?? context.format ?? null;
|
|
31
|
+
const width = object.width ?? object.output?.width ?? context.width ?? null;
|
|
32
|
+
const suffix = Number.isFinite(width) && width > 0 ? `${width}w` : 'optimized';
|
|
33
|
+
return `${safeStem(context.source)}-${suffix}.${extensionFor(format)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function walk(value, store, links, context = {}) {
|
|
37
|
+
if (Buffer.isBuffer(value)) return { type: 'binary', byteLength: value.byteLength };
|
|
38
|
+
if (Array.isArray(value)) return Promise.all(value.map((item) => walk(item, store, links, context)));
|
|
39
|
+
if (!value || typeof value !== 'object') return value;
|
|
40
|
+
|
|
41
|
+
const nextContext = {
|
|
42
|
+
source: value.sourceUrl ?? value.id ?? context.source,
|
|
43
|
+
width: value.width ?? value.output?.width ?? context.width,
|
|
44
|
+
format: value.output?.format ?? value.outputFormat ?? context.format,
|
|
45
|
+
};
|
|
46
|
+
const out = {};
|
|
47
|
+
|
|
48
|
+
for (const [key, item] of Object.entries(value)) {
|
|
49
|
+
if (key === 'buffer' && Buffer.isBuffer(item)) {
|
|
50
|
+
if (value.status === 'ACCEPT') {
|
|
51
|
+
const format = value.output?.format ?? value.outputFormat ?? nextContext.format;
|
|
52
|
+
const mimeType = MIME_BY_FORMAT[format] ?? 'application/octet-stream';
|
|
53
|
+
const artifact = await store.put(item, {
|
|
54
|
+
name: artifactName(nextContext, value),
|
|
55
|
+
mimeType,
|
|
56
|
+
});
|
|
57
|
+
out.artifact = artifact;
|
|
58
|
+
links.push({
|
|
59
|
+
type: 'resource_link',
|
|
60
|
+
uri: artifact.uri,
|
|
61
|
+
name: artifact.name,
|
|
62
|
+
title: 'Optimized image artifact',
|
|
63
|
+
description: `Verified temporary image output. SHA-256 ${artifact.sha256}.`,
|
|
64
|
+
mimeType: artifact.mimeType,
|
|
65
|
+
size: artifact.size,
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
out.buffer = { type: 'binary', byteLength: item.byteLength };
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
out[key] = await walk(item, store, links, nextContext);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function artifactToolResult(value, store) {
|
|
79
|
+
const links = [];
|
|
80
|
+
const safeValue = await walk(value, store, links);
|
|
81
|
+
return {
|
|
82
|
+
content: [
|
|
83
|
+
{
|
|
84
|
+
type: 'text',
|
|
85
|
+
text: JSON.stringify(sanitizeForText(safeValue)),
|
|
86
|
+
},
|
|
87
|
+
...links,
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const ARTIFACT_SCHEME = 'layerporter-artifact:';
|
|
7
|
+
const ARTIFACT_HOST = 'artifact';
|
|
8
|
+
const ID_RE = /^[a-f0-9]{32}$/;
|
|
9
|
+
|
|
10
|
+
const EXT_BY_MIME = Object.freeze({
|
|
11
|
+
'image/jpeg': '.jpg',
|
|
12
|
+
'image/png': '.png',
|
|
13
|
+
'image/webp': '.webp',
|
|
14
|
+
'image/avif': '.avif',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function artifactError(code, message) {
|
|
18
|
+
const error = new Error(message);
|
|
19
|
+
error.code = code;
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safeName(value, mimeType) {
|
|
24
|
+
const fallback = `optimized-image${EXT_BY_MIME[mimeType] ?? '.bin'}`;
|
|
25
|
+
const raw = typeof value === 'string' && value.trim() ? path.basename(value.trim()) : fallback;
|
|
26
|
+
const cleaned = raw.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 96) || fallback;
|
|
27
|
+
if (path.extname(cleaned)) return cleaned;
|
|
28
|
+
return `${cleaned}${EXT_BY_MIME[mimeType] ?? '.bin'}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseArtifactId(value) {
|
|
32
|
+
if (typeof value !== 'string' || !value.trim()) throw artifactError('INVALID_ARTIFACT_ID', 'artifact id is required');
|
|
33
|
+
let id = value.trim();
|
|
34
|
+
if (id.startsWith(`${ARTIFACT_SCHEME}//`)) {
|
|
35
|
+
const url = new URL(id);
|
|
36
|
+
if (url.protocol !== ARTIFACT_SCHEME || url.hostname !== ARTIFACT_HOST) {
|
|
37
|
+
throw artifactError('INVALID_ARTIFACT_URI', 'unsupported artifact URI');
|
|
38
|
+
}
|
|
39
|
+
id = url.pathname.replace(/^\/+/, '');
|
|
40
|
+
}
|
|
41
|
+
if (!ID_RE.test(id)) throw artifactError('INVALID_ARTIFACT_ID', 'invalid artifact id');
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ArtifactStore {
|
|
46
|
+
constructor(options = {}) {
|
|
47
|
+
this.rootDir = options.rootDir ?? path.join(os.tmpdir(), 'layerporter-image-optimizer-artifacts');
|
|
48
|
+
this.ttlMs = options.ttlMs ?? 30 * 60 * 1000;
|
|
49
|
+
this.maxArtifacts = options.maxArtifacts ?? 50;
|
|
50
|
+
this.maxTotalBytes = options.maxTotalBytes ?? 50 * 1024 * 1024;
|
|
51
|
+
this.maxArtifactBytes = options.maxArtifactBytes ?? 10 * 1024 * 1024;
|
|
52
|
+
this.records = new Map();
|
|
53
|
+
this.totalBytes = 0;
|
|
54
|
+
this.initialized = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async init() {
|
|
58
|
+
if (this.initialized) return;
|
|
59
|
+
await fs.mkdir(this.rootDir, { recursive: true, mode: 0o700 });
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
for (const entry of await fs.readdir(this.rootDir, { withFileTypes: true })) {
|
|
62
|
+
if (!entry.isFile()) continue;
|
|
63
|
+
const filePath = path.join(this.rootDir, entry.name);
|
|
64
|
+
try {
|
|
65
|
+
const stat = await fs.stat(filePath);
|
|
66
|
+
if (now - stat.mtimeMs > this.ttlMs) await fs.unlink(filePath);
|
|
67
|
+
} catch {
|
|
68
|
+
// Best-effort cleanup only. Unknown files are never exposed through the in-memory index.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.initialized = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async cleanupExpired(now = Date.now()) {
|
|
75
|
+
for (const [id, record] of this.records) {
|
|
76
|
+
if (record.expiresAtMs > now) continue;
|
|
77
|
+
this.records.delete(id);
|
|
78
|
+
this.totalBytes -= record.size;
|
|
79
|
+
await fs.unlink(record.filePath).catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async put(buffer, options = {}) {
|
|
84
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw artifactError('INVALID_ARTIFACT', 'artifact must be a non-empty Buffer');
|
|
85
|
+
if (buffer.length > this.maxArtifactBytes) throw artifactError('ARTIFACT_TOO_LARGE', 'artifact exceeds per-file byte limit');
|
|
86
|
+
|
|
87
|
+
await this.init();
|
|
88
|
+
await this.cleanupExpired();
|
|
89
|
+
if (this.records.size >= this.maxArtifacts) throw artifactError('ARTIFACT_STORE_FULL', 'artifact count limit reached');
|
|
90
|
+
if (this.totalBytes + buffer.length > this.maxTotalBytes) throw artifactError('ARTIFACT_STORE_FULL', 'artifact byte budget reached');
|
|
91
|
+
|
|
92
|
+
const id = crypto.randomUUID().replaceAll('-', '');
|
|
93
|
+
const mimeType = options.mimeType ?? 'application/octet-stream';
|
|
94
|
+
const name = safeName(options.name, mimeType);
|
|
95
|
+
const filePath = path.join(this.rootDir, `${id}-${name}`);
|
|
96
|
+
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
97
|
+
const createdAtMs = Date.now();
|
|
98
|
+
const expiresAtMs = createdAtMs + this.ttlMs;
|
|
99
|
+
|
|
100
|
+
await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 });
|
|
101
|
+
|
|
102
|
+
const record = {
|
|
103
|
+
id,
|
|
104
|
+
name,
|
|
105
|
+
mimeType,
|
|
106
|
+
size: buffer.length,
|
|
107
|
+
sha256,
|
|
108
|
+
filePath,
|
|
109
|
+
createdAtMs,
|
|
110
|
+
expiresAtMs,
|
|
111
|
+
};
|
|
112
|
+
this.records.set(id, record);
|
|
113
|
+
this.totalBytes += record.size;
|
|
114
|
+
return this.publicMetadata(record);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
publicMetadata(record) {
|
|
118
|
+
return {
|
|
119
|
+
id: record.id,
|
|
120
|
+
uri: `${ARTIFACT_SCHEME}//${ARTIFACT_HOST}/${record.id}`,
|
|
121
|
+
name: record.name,
|
|
122
|
+
mimeType: record.mimeType,
|
|
123
|
+
size: record.size,
|
|
124
|
+
sha256: record.sha256,
|
|
125
|
+
expiresAt: new Date(record.expiresAtMs).toISOString(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async read(idOrUri) {
|
|
130
|
+
await this.init();
|
|
131
|
+
await this.cleanupExpired();
|
|
132
|
+
const id = parseArtifactId(idOrUri);
|
|
133
|
+
const record = this.records.get(id);
|
|
134
|
+
if (!record) throw artifactError('ARTIFACT_NOT_FOUND', 'artifact is missing or expired');
|
|
135
|
+
if (record.expiresAtMs <= Date.now()) {
|
|
136
|
+
await this.cleanupExpired();
|
|
137
|
+
throw artifactError('ARTIFACT_EXPIRED', 'artifact expired');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const buffer = await fs.readFile(record.filePath);
|
|
141
|
+
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
142
|
+
if (buffer.length !== record.size || sha256 !== record.sha256) {
|
|
143
|
+
throw artifactError('ARTIFACT_INTEGRITY_FAILED', 'artifact integrity verification failed');
|
|
144
|
+
}
|
|
145
|
+
return { ...this.publicMetadata(record), buffer };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async dispose() {
|
|
149
|
+
for (const record of this.records.values()) await fs.unlink(record.filePath).catch(() => {});
|
|
150
|
+
this.records.clear();
|
|
151
|
+
this.totalBytes = 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { chooseOutputFormat, validatePolicy } from './policy.js';
|
|
3
|
+
|
|
4
|
+
function assertBuffer(input) {
|
|
5
|
+
if (!Buffer.isBuffer(input) || input.length === 0) {
|
|
6
|
+
throw new TypeError('input must be a non-empty Buffer');
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function assertTarget(target = {}) {
|
|
11
|
+
for (const key of ['width', 'height']) {
|
|
12
|
+
if (target[key] !== undefined && (!Number.isInteger(target[key]) || target[key] < 1)) {
|
|
13
|
+
throw new TypeError(`${key} must be a positive integer`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeFormat(format, compression) {
|
|
19
|
+
if (format === 'heif' && compression === 'av1') return 'avif';
|
|
20
|
+
return format ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function orientedBounds(image, applyOrientation) {
|
|
24
|
+
if (applyOrientation && [5, 6, 7, 8].includes(image.orientation)) {
|
|
25
|
+
return { width: image.height, height: image.width };
|
|
26
|
+
}
|
|
27
|
+
return { width: image.width, height: image.height };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function encoder(pipeline, format, policy) {
|
|
31
|
+
switch (format) {
|
|
32
|
+
case 'jpeg':
|
|
33
|
+
return pipeline.jpeg({ quality: policy.quality, mozjpeg: true });
|
|
34
|
+
case 'png':
|
|
35
|
+
return pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
|
|
36
|
+
case 'webp':
|
|
37
|
+
return pipeline.webp({ quality: policy.quality, effort: Math.min(policy.effort, 6) });
|
|
38
|
+
case 'avif':
|
|
39
|
+
return pipeline.avif({ quality: policy.avifQuality, effort: policy.effort });
|
|
40
|
+
default:
|
|
41
|
+
throw new TypeError(`Unsupported encoder: ${format}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function inspectImage(input, options = {}) {
|
|
46
|
+
assertBuffer(input);
|
|
47
|
+
const policy = validatePolicy(options.policy);
|
|
48
|
+
const metadata = await sharp(input, { limitInputPixels: policy.maxPixels }).metadata();
|
|
49
|
+
|
|
50
|
+
if (!metadata.width || !metadata.height) {
|
|
51
|
+
throw new Error('Unable to determine image dimensions');
|
|
52
|
+
}
|
|
53
|
+
if (metadata.width > policy.maxWidth || metadata.height > policy.maxHeight) {
|
|
54
|
+
throw new RangeError(`Image dimensions exceed ${policy.maxWidth}x${policy.maxHeight}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
bytes: input.length,
|
|
59
|
+
format: normalizeFormat(metadata.format, metadata.compression),
|
|
60
|
+
mediaType: metadata.mediaType ?? null,
|
|
61
|
+
compression: metadata.compression ?? null,
|
|
62
|
+
width: metadata.width,
|
|
63
|
+
height: metadata.height,
|
|
64
|
+
orientation: metadata.orientation ?? null,
|
|
65
|
+
hasAlpha: Boolean(metadata.hasAlpha),
|
|
66
|
+
space: metadata.space ?? null,
|
|
67
|
+
channels: metadata.channels ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function optimizeImage(input, options = {}) {
|
|
72
|
+
assertBuffer(input);
|
|
73
|
+
const target = options.target ?? {};
|
|
74
|
+
assertTarget(target);
|
|
75
|
+
const policy = validatePolicy(options.policy);
|
|
76
|
+
const original = await inspectImage(input, { policy });
|
|
77
|
+
const outputFormat = chooseOutputFormat(original, policy);
|
|
78
|
+
|
|
79
|
+
if (policy.preserveAlpha && original.hasAlpha && outputFormat === 'jpeg') {
|
|
80
|
+
return {
|
|
81
|
+
status: 'REJECT',
|
|
82
|
+
reason: 'alpha_would_be_lost',
|
|
83
|
+
original,
|
|
84
|
+
output: original,
|
|
85
|
+
outputFormat: original.format,
|
|
86
|
+
savingsBytes: 0,
|
|
87
|
+
savingsPercent: 0,
|
|
88
|
+
buffer: input,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let pipeline = sharp(input, { limitInputPixels: policy.maxPixels });
|
|
93
|
+
|
|
94
|
+
if (policy.preserveOrientation) {
|
|
95
|
+
pipeline = pipeline.autoOrient();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pipeline = pipeline.keepIccProfile();
|
|
99
|
+
|
|
100
|
+
if (target.width || target.height) {
|
|
101
|
+
pipeline = pipeline.resize({
|
|
102
|
+
width: target.width,
|
|
103
|
+
height: target.height,
|
|
104
|
+
fit: 'inside',
|
|
105
|
+
withoutEnlargement: policy.withoutEnlargement,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const encoded = encoder(pipeline, outputFormat, policy);
|
|
110
|
+
const { data, info } = await encoded.toBuffer({ resolveWithObject: true });
|
|
111
|
+
const originalBounds = orientedBounds(original, policy.preserveOrientation);
|
|
112
|
+
|
|
113
|
+
if (policy.withoutEnlargement && (info.width > originalBounds.width || info.height > originalBounds.height)) {
|
|
114
|
+
throw new Error('no-upscale guard failed');
|
|
115
|
+
}
|
|
116
|
+
if (policy.preserveAlpha && original.hasAlpha && !info.channels) {
|
|
117
|
+
throw new Error('alpha verification unavailable');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const savingsBytes = original.bytes - data.length;
|
|
121
|
+
const savingsPercent = Number(((savingsBytes / original.bytes) * 100).toFixed(2));
|
|
122
|
+
|
|
123
|
+
if (policy.neverIncreaseBytes && data.length >= original.bytes) {
|
|
124
|
+
return {
|
|
125
|
+
status: 'REJECT',
|
|
126
|
+
reason: 'no_byte_saving',
|
|
127
|
+
original,
|
|
128
|
+
output: {
|
|
129
|
+
bytes: data.length,
|
|
130
|
+
format: outputFormat,
|
|
131
|
+
width: info.width,
|
|
132
|
+
height: info.height,
|
|
133
|
+
channels: info.channels,
|
|
134
|
+
},
|
|
135
|
+
outputFormat,
|
|
136
|
+
savingsBytes,
|
|
137
|
+
savingsPercent,
|
|
138
|
+
buffer: input,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const outputMetadata = await sharp(data, { limitInputPixels: policy.maxPixels }).metadata();
|
|
143
|
+
if (policy.preserveAlpha && original.hasAlpha && !outputMetadata.hasAlpha) {
|
|
144
|
+
throw new Error('alpha preservation guard failed');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
status: 'ACCEPT',
|
|
149
|
+
reason: 'smaller_without_guard_regression',
|
|
150
|
+
original,
|
|
151
|
+
output: {
|
|
152
|
+
bytes: data.length,
|
|
153
|
+
format: normalizeFormat(outputMetadata.format, outputMetadata.compression),
|
|
154
|
+
width: info.width,
|
|
155
|
+
height: info.height,
|
|
156
|
+
hasAlpha: Boolean(outputMetadata.hasAlpha),
|
|
157
|
+
orientation: outputMetadata.orientation ?? null,
|
|
158
|
+
space: outputMetadata.space ?? null,
|
|
159
|
+
},
|
|
160
|
+
outputFormat,
|
|
161
|
+
savingsBytes,
|
|
162
|
+
savingsPercent,
|
|
163
|
+
buffer: data,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { DEFAULT_POLICY, validatePolicy, chooseOutputFormat } from './policy.js';
|
|
168
|
+
export { analyzeImageUsage, analyzePageImages } from './page-analysis.js';
|