@mr-aftab-ahmad-khan/imago 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/CHANGELOG.md +10 -0
- package/LICENSE +15 -0
- package/README.md +300 -0
- package/dist/index.cjs +436 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +141 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +385 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] — 2026-05-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- URL-driven `handle({ source, cache, maxAge }, key, query, headers)` with `w`, `h`, `fm`, `q`, `blur`, `gravity`, `fit` parameters.
|
|
8
|
+
- Sharp-powered transforms with LRU and disk caching, ETag, Last-Modified, and Cache-Control headers.
|
|
9
|
+
- Express, Hono, and Fastify middleware adapters.
|
|
10
|
+
- Strip-EXIF by default, max-dimension guard, and Accept-aware format negotiation.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 imago contributors
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# imago
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@mr-aftab-ahmad-khan/imago)
|
|
4
|
+
[](https://bundlephobia.com/package/imago)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
**Cloudinary-style image optimization, self-hosted, in any Node.js server.** Cloudinary starts at $89/month. Next.js Image Optimization only works in Next.js. `imago` is a zero-config middleware powered by `sharp` that delivers on-the-fly resizing, AVIF/WebP conversion, smart cropping, blur placeholders, and EXIF stripping via URL parameters — `?w=800&h=600&format=webp&fit=cover&q=80`.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @mr-aftab-ahmad-khan/imago sharp
|
|
16
|
+
pnpm add @mr-aftab-ahmad-khan/imago sharp
|
|
17
|
+
yarn add @mr-aftab-ahmad-khan/imago sharp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import express from "express";
|
|
26
|
+
import { imago } from "@mr-aftab-ahmad-khan/imago";
|
|
27
|
+
|
|
28
|
+
const app = express();
|
|
29
|
+
app.use("/images", imago({ source: "./uploads", cache: "./cache", maxAge: 86400 }));
|
|
30
|
+
app.listen(3000);
|
|
31
|
+
// GET /images/photo.jpg?w=800&format=webp&q=80
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Core Usage Examples
|
|
37
|
+
|
|
38
|
+
### 1. Serve resized WebP from a local folder
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import express from "express";
|
|
42
|
+
import { imago } from "@mr-aftab-ahmad-khan/imago";
|
|
43
|
+
|
|
44
|
+
const app = express();
|
|
45
|
+
app.use("/img", imago({ source: "./public/img", cache: "./.imago-cache" }));
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 2. AVIF on the fly
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// GET /img/photo.jpg?format=avif&q=60
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 3. Blur placeholder
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// GET /img/photo.jpg?placeholder=blur
|
|
58
|
+
// → 8x8 WebP, perfect for `data:` URI in a LQIP setup
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 4. Smart entropy-based crop
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// GET /img/photo.jpg?w=300&h=300&fit=cover&gravity=smart
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 5. Strip EXIF from user uploads
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// GET /img/user-upload.jpg?strip=true
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 6. format=auto with Accept negotiation
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// GET /img/photo.jpg?w=800&format=auto
|
|
77
|
+
// Chrome with AVIF support → image/avif
|
|
78
|
+
// Older browsers → image/jpeg
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Framework Integration Examples
|
|
84
|
+
|
|
85
|
+
### Express
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import express from "express";
|
|
89
|
+
import { imago } from "@mr-aftab-ahmad-khan/imago";
|
|
90
|
+
|
|
91
|
+
const app = express();
|
|
92
|
+
app.use("/images", imago({
|
|
93
|
+
source: "./uploads",
|
|
94
|
+
cache: "./cache",
|
|
95
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
96
|
+
}));
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Hono (Cloudflare R2 source adapter)
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { Hono } from "hono";
|
|
103
|
+
import { imagoHono, type SourceAdapter } from "@mr-aftab-ahmad-khan/imago";
|
|
104
|
+
|
|
105
|
+
class R2Source implements SourceAdapter {
|
|
106
|
+
async fetch(key: string) {
|
|
107
|
+
const obj = await env.R2.get(key);
|
|
108
|
+
if (!obj) return undefined;
|
|
109
|
+
const buffer = Buffer.from(await obj.arrayBuffer());
|
|
110
|
+
return { buffer, mimeType: obj.httpMetadata?.contentType ?? "application/octet-stream" };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const app = new Hono();
|
|
115
|
+
app.get("/images/*", imagoHono({ source: new R2Source(), maxAge: 3600 }));
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Fastify
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import Fastify from "fastify";
|
|
122
|
+
import { imagoFastify } from "@mr-aftab-ahmad-khan/imago";
|
|
123
|
+
|
|
124
|
+
const fastify = Fastify();
|
|
125
|
+
fastify.get("/img/*", imagoFastify({ source: "./public/img", cache: "./cache" }));
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Standalone pipeline (build step)
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
132
|
+
import { transform } from "@mr-aftab-ahmad-khan/imago";
|
|
133
|
+
|
|
134
|
+
const buf = await readFile("./photo.jpg");
|
|
135
|
+
const out = await transform({ source: buf, options: { w: 800, format: "webp", q: 80 } });
|
|
136
|
+
await writeFile("./photo-800.webp", out.buffer);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Transform Reference
|
|
142
|
+
|
|
143
|
+
| Parameter | Type / Values | Default | Description |
|
|
144
|
+
| -------------- | ------------------------------------------------------------ | --------- | -------------------------------------------- |
|
|
145
|
+
| `w` | 1-4000 | — | Output width |
|
|
146
|
+
| `h` | 1-4000 | — | Output height |
|
|
147
|
+
| `fit` | `cover` `contain` `fill` `inside` `outside` | `cover` | Resize strategy |
|
|
148
|
+
| `format` | `jpeg` `png` `webp` `avif` `auto` | `auto` | Output format |
|
|
149
|
+
| `q` | 1-100 | format-default | Quality |
|
|
150
|
+
| `blur` | 0.3-1000 | — | Gaussian sigma |
|
|
151
|
+
| `gravity` | `center` `north` `south` `east` `west` `smart` | `center` | Crop anchor; `smart` uses sharp's `attention` |
|
|
152
|
+
| `strip` | bool | `false` | Strip EXIF |
|
|
153
|
+
| `sharpen` | bool | `false` | Apply default sharpen |
|
|
154
|
+
| `placeholder` | `blur` | — | Return 8×8 WebP blur LQIP |
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Error Handling
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { ImagoError, DimensionLimitError, UnsupportedFormatError, SourceNotFoundError } from "@mr-aftab-ahmad-khan/imago";
|
|
162
|
+
|
|
163
|
+
app.use((err: any, _req: any, res: any, _next: any) => {
|
|
164
|
+
if (err instanceof DimensionLimitError) return res.status(413).json({ error: err.message });
|
|
165
|
+
if (err instanceof UnsupportedFormatError) return res.status(400).json({ error: err.message });
|
|
166
|
+
if (err instanceof SourceNotFoundError) return res.status(404).json({ error: err.message });
|
|
167
|
+
if (err instanceof ImagoError) return res.status(err.status).json({ error: err.message });
|
|
168
|
+
res.status(500).end();
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## TypeScript Types
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import type {
|
|
178
|
+
ImagoOptions,
|
|
179
|
+
TransformOptions,
|
|
180
|
+
CacheOptions,
|
|
181
|
+
SourceAdapter,
|
|
182
|
+
Format,
|
|
183
|
+
Fit,
|
|
184
|
+
Gravity,
|
|
185
|
+
} from "@mr-aftab-ahmad-khan/imago";
|
|
186
|
+
|
|
187
|
+
class S3Source implements SourceAdapter {
|
|
188
|
+
async fetch(key: string) {
|
|
189
|
+
/* call S3 GetObject, return { buffer, mimeType } */
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Performance
|
|
198
|
+
|
|
199
|
+
Approx. benchmarks on an M2 laptop using `sharp` 0.33 and a 2 MB JPEG:
|
|
200
|
+
|
|
201
|
+
| Transform | Latency (cold) | Cache hit | Output size |
|
|
202
|
+
| -------------------------------- | -------------: | --------: | ----------: |
|
|
203
|
+
| WebP 800px (q=80) | ~38 ms | ~1 ms | 72 KB|
|
|
204
|
+
| AVIF 800px (q=65) | ~120 ms | ~1 ms | 49 KB|
|
|
205
|
+
| WebP 400px thumbnail | ~22 ms | ~1 ms | 24 KB|
|
|
206
|
+
| 8x8 blur placeholder | ~7 ms | ~1 ms | 300 B|
|
|
207
|
+
|
|
208
|
+
`sharp` is built on `libvips`; everything is done in native code with zero-copy buffer handling. The biggest performance win is the cache — a CDN in front of imago caches the output indefinitely (`Cache-Control: public, max-age=…, immutable`).
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Real-World Recipe — User Avatar Service
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import express from "express";
|
|
216
|
+
import { imago } from "@mr-aftab-ahmad-khan/imago";
|
|
217
|
+
import { upflow, DiskStorage } from "upflow";
|
|
218
|
+
|
|
219
|
+
const upload = upflow({
|
|
220
|
+
storage: new DiskStorage({ root: "./avatars" }),
|
|
221
|
+
limits: { allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"] },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const app = express();
|
|
225
|
+
|
|
226
|
+
app.post("/avatars", upload.single("file"), async (req: any, res) => {
|
|
227
|
+
res.json({ key: req.file.storageKey });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
app.use(
|
|
231
|
+
"/avatars",
|
|
232
|
+
imago({
|
|
233
|
+
source: "./avatars",
|
|
234
|
+
cache: "./avatar-cache",
|
|
235
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
236
|
+
maxDimension: 1024,
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
// /avatars/2026-01-12/abc.jpg?w=40&format=webp&strip=true ← thumbnail
|
|
240
|
+
// /avatars/2026-01-12/abc.jpg?w=160&format=webp&strip=true ← profile
|
|
241
|
+
// /avatars/2026-01-12/abc.jpg?w=400&format=webp&strip=true ← full
|
|
242
|
+
// /avatars/2026-01-12/abc.jpg?placeholder=blur ← LQIP
|
|
243
|
+
|
|
244
|
+
app.listen(3000);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Deployment Guide
|
|
250
|
+
|
|
251
|
+
### Behind nginx
|
|
252
|
+
|
|
253
|
+
```nginx
|
|
254
|
+
location /images/ {
|
|
255
|
+
proxy_pass http://app:3000;
|
|
256
|
+
proxy_cache imago_cache;
|
|
257
|
+
proxy_cache_valid 200 7d;
|
|
258
|
+
proxy_cache_key "$request_uri";
|
|
259
|
+
add_header X-Cache-Status $upstream_cache_status;
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### CDN
|
|
264
|
+
|
|
265
|
+
`imago` already sets `Cache-Control: public, max-age=…, immutable` plus `ETag` and `Last-Modified` headers. Cloudflare, Fastly, or Vercel Edge will cache transforms forever — invalidating only when source mtime changes (because `imago` includes mtime in the cache key, the URL changes).
|
|
266
|
+
|
|
267
|
+
### Docker
|
|
268
|
+
|
|
269
|
+
```dockerfile
|
|
270
|
+
FROM node:20-bookworm-slim
|
|
271
|
+
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
|
|
272
|
+
WORKDIR /app
|
|
273
|
+
COPY package*.json ./
|
|
274
|
+
RUN npm ci
|
|
275
|
+
COPY . .
|
|
276
|
+
CMD ["node", "dist/index.js"]
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The `sharp` native binary is platform-specific; install with `--include=optional` to make sure `libvips` is fetched.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Comparison Table
|
|
284
|
+
|
|
285
|
+
| Feature | Cloudinary | Next.js Image | sharp (raw) | **imago** |
|
|
286
|
+
| -------------------------- | :--------: | :-----------: | :---------: | :-------: |
|
|
287
|
+
| Framework agnostic | ✅ | ❌ | ✅ | ✅ |
|
|
288
|
+
| On-the-fly transforms | ✅ | ✅ | DIY | ✅ |
|
|
289
|
+
| URL-driven API | ✅ | ⚠️ | ❌ | ✅ |
|
|
290
|
+
| Self-hosted | ❌ | ✅ | ✅ | ✅ |
|
|
291
|
+
| AVIF support | ✅ | ✅ | ✅ | ✅ |
|
|
292
|
+
| Smart crop (entropy) | ✅ | ❌ | ✅ | ✅ |
|
|
293
|
+
| Blur placeholder | ✅ | ✅ | DIY | ✅ |
|
|
294
|
+
| Free | ❌ | ⚠️ | ✅ | ✅ |
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT
|