ipx 0.9.0 → 0.9.1

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,527 @@
1
+ import defu from 'defu';
2
+ import { imageMeta } from 'image-meta';
3
+ import { parseURL, withLeadingSlash, hasProtocol, joinURL, decode } from 'ufo';
4
+ import { promises } from 'fs';
5
+ import { resolve, join } from 'pathe';
6
+ import isValidPath from 'is-valid-path';
7
+ import http from 'http';
8
+ import https from 'https';
9
+ import { fetch } from 'ohmyfetch';
10
+ import destr from 'destr';
11
+ import getEtag from 'etag';
12
+ import xss from 'xss';
13
+
14
+ const Handlers = {
15
+ __proto__: null,
16
+ get quality () { return quality; },
17
+ get fit () { return fit; },
18
+ get background () { return background; },
19
+ get enlarge () { return enlarge; },
20
+ get width () { return width; },
21
+ get height () { return height; },
22
+ get resize () { return resize; },
23
+ get trim () { return trim; },
24
+ get extend () { return extend; },
25
+ get extract () { return extract; },
26
+ get rotate () { return rotate; },
27
+ get flip () { return flip; },
28
+ get flop () { return flop; },
29
+ get sharpen () { return sharpen; },
30
+ get median () { return median; },
31
+ get blur () { return blur; },
32
+ get flatten () { return flatten; },
33
+ get gamma () { return gamma; },
34
+ get negate () { return negate; },
35
+ get normalize () { return normalize; },
36
+ get threshold () { return threshold; },
37
+ get modulate () { return modulate; },
38
+ get tint () { return tint; },
39
+ get grayscale () { return grayscale; },
40
+ get crop () { return crop; },
41
+ get q () { return q; },
42
+ get b () { return b; },
43
+ get w () { return w; },
44
+ get h () { return h; },
45
+ get s () { return s; }
46
+ };
47
+
48
+ function getEnv(name, defaultValue) {
49
+ return destr(process.env[name]) ?? defaultValue;
50
+ }
51
+ function cachedPromise(fn) {
52
+ let p;
53
+ return (...args) => {
54
+ if (p) {
55
+ return p;
56
+ }
57
+ p = Promise.resolve(fn(...args));
58
+ return p;
59
+ };
60
+ }
61
+ class IPXError extends Error {
62
+ }
63
+ function createError(message, statusCode) {
64
+ const err = new IPXError(message);
65
+ err.statusMessage = "IPX: " + message;
66
+ err.statusCode = statusCode;
67
+ return err;
68
+ }
69
+
70
+ const createFilesystemSource = (options) => {
71
+ const rootDir = resolve(options.dir);
72
+ return async (id) => {
73
+ const fsPath = resolve(join(rootDir, id));
74
+ if (!isValidPath(id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
75
+ throw createError("Forbidden path:" + id, 403);
76
+ }
77
+ let stats;
78
+ try {
79
+ stats = await promises.stat(fsPath);
80
+ } catch (err) {
81
+ if (err.code === "ENOENT") {
82
+ throw createError("File not found: " + fsPath, 404);
83
+ } else {
84
+ throw createError("File access error for " + fsPath + ":" + err.code, 403);
85
+ }
86
+ }
87
+ if (!stats.isFile()) {
88
+ throw createError("Path should be a file: " + fsPath, 400);
89
+ }
90
+ return {
91
+ mtime: stats.mtime,
92
+ maxAge: options.maxAge || 300,
93
+ getData: cachedPromise(() => promises.readFile(fsPath))
94
+ };
95
+ };
96
+ };
97
+
98
+ const createHTTPSource = (options) => {
99
+ const httpsAgent = new https.Agent({ keepAlive: true });
100
+ const httpAgent = new http.Agent({ keepAlive: true });
101
+ let domains = options.domains || [];
102
+ if (typeof domains === "string") {
103
+ domains = domains.split(",").map((s) => s.trim());
104
+ }
105
+ const hosts = domains.map((domain) => parseURL(domain, "https://").host);
106
+ return async (id, reqOptions) => {
107
+ const parsedUrl = parseURL(id, "https://");
108
+ if (!parsedUrl.host) {
109
+ throw createError("Hostname is missing: " + id, 403);
110
+ }
111
+ if (!reqOptions?.bypassDomain && !hosts.find((host) => parsedUrl.host === host)) {
112
+ throw createError("Forbidden host: " + parsedUrl.host, 403);
113
+ }
114
+ const response = await fetch(id, {
115
+ agent: id.startsWith("https") ? httpsAgent : httpAgent
116
+ });
117
+ if (!response.ok) {
118
+ throw createError(response.statusText || "fetch error", response.status || 500);
119
+ }
120
+ let maxAge = options.maxAge || 300;
121
+ const _cacheControl = response.headers.get("cache-control");
122
+ if (_cacheControl) {
123
+ const m = _cacheControl.match(/max-age=(\d+)/);
124
+ if (m && m[1]) {
125
+ maxAge = parseInt(m[1]);
126
+ }
127
+ }
128
+ let mtime;
129
+ const _lastModified = response.headers.get("last-modified");
130
+ if (_lastModified) {
131
+ mtime = new Date(_lastModified);
132
+ }
133
+ return {
134
+ mtime,
135
+ maxAge,
136
+ getData: cachedPromise(() => response.buffer())
137
+ };
138
+ };
139
+ };
140
+
141
+ function VArg(arg) {
142
+ return destr(arg);
143
+ }
144
+ function parseArgs(args, mappers) {
145
+ const vargs = args.split("_");
146
+ return mappers.map((v, i) => v(vargs[i]));
147
+ }
148
+ function getHandler(key) {
149
+ return Handlers[key];
150
+ }
151
+ function applyHandler(ctx, pipe, handler, argsStr) {
152
+ const args = handler.args ? parseArgs(argsStr, handler.args) : [];
153
+ return handler.apply(ctx, pipe, ...args);
154
+ }
155
+ function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions) {
156
+ const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height;
157
+ let { width, height } = desiredDimensions;
158
+ if (width > sourceDimensions.width) {
159
+ width = sourceDimensions.width;
160
+ height = Math.round(sourceDimensions.width / desiredAspectRatio);
161
+ }
162
+ if (height > sourceDimensions.height) {
163
+ height = sourceDimensions.height;
164
+ width = Math.round(sourceDimensions.height * desiredAspectRatio);
165
+ }
166
+ return { width, height };
167
+ }
168
+
169
+ const quality = {
170
+ args: [VArg],
171
+ order: -1,
172
+ apply: (context, _pipe, quality2) => {
173
+ context.quality = quality2;
174
+ }
175
+ };
176
+ const fit = {
177
+ args: [VArg],
178
+ order: -1,
179
+ apply: (context, _pipe, fit2) => {
180
+ context.fit = fit2;
181
+ }
182
+ };
183
+ const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
184
+ const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
185
+ const background = {
186
+ args: [VArg],
187
+ order: -1,
188
+ apply: (context, _pipe, background2) => {
189
+ background2 = String(background2);
190
+ if (!background2.startsWith("#") && (HEX_RE.test(background2) || SHORTHEX_RE.test(background2))) {
191
+ background2 = "#" + background2;
192
+ }
193
+ context.background = background2;
194
+ }
195
+ };
196
+ const enlarge = {
197
+ args: [],
198
+ apply: (context) => {
199
+ context.enlarge = true;
200
+ }
201
+ };
202
+ const width = {
203
+ args: [VArg],
204
+ apply: (context, pipe, width2) => {
205
+ return pipe.resize(width2, null, { withoutEnlargement: !context.enlarge });
206
+ }
207
+ };
208
+ const height = {
209
+ args: [VArg],
210
+ apply: (context, pipe, height2) => {
211
+ return pipe.resize(null, height2, { withoutEnlargement: !context.enlarge });
212
+ }
213
+ };
214
+ const resize = {
215
+ args: [VArg, VArg, VArg],
216
+ apply: (context, pipe, size) => {
217
+ let [width2, height2] = String(size).split("x").map((v) => Number(v));
218
+ if (!context.enlarge) {
219
+ const clamped = clampDimensionsPreservingAspectRatio(context.meta, { width: width2, height: height2 });
220
+ width2 = clamped.width;
221
+ height2 = clamped.height;
222
+ }
223
+ return pipe.resize(width2, height2, {
224
+ fit: context.fit,
225
+ background: context.background
226
+ });
227
+ }
228
+ };
229
+ const trim = {
230
+ args: [VArg],
231
+ apply: (_context, pipe, threshold2) => {
232
+ return pipe.trim(threshold2);
233
+ }
234
+ };
235
+ const extend = {
236
+ args: [VArg, VArg, VArg, VArg],
237
+ apply: (context, pipe, top, right, bottom, left) => {
238
+ return pipe.extend({
239
+ top,
240
+ left,
241
+ bottom,
242
+ right,
243
+ background: context.background
244
+ });
245
+ }
246
+ };
247
+ const extract = {
248
+ args: [VArg, VArg, VArg, VArg],
249
+ apply: (context, pipe, top, right, bottom, left) => {
250
+ return pipe.extend({
251
+ top,
252
+ left,
253
+ bottom,
254
+ right,
255
+ background: context.background
256
+ });
257
+ }
258
+ };
259
+ const rotate = {
260
+ args: [VArg],
261
+ apply: (context, pipe, angel) => {
262
+ return pipe.rotate(angel, {
263
+ background: context.background
264
+ });
265
+ }
266
+ };
267
+ const flip = {
268
+ args: [],
269
+ apply: (_context, pipe) => {
270
+ return pipe.flip();
271
+ }
272
+ };
273
+ const flop = {
274
+ args: [],
275
+ apply: (_context, pipe) => {
276
+ return pipe.flop();
277
+ }
278
+ };
279
+ const sharpen = {
280
+ args: [VArg, VArg, VArg],
281
+ apply: (_context, pipe, sigma, flat, jagged) => {
282
+ return pipe.sharpen(sigma, flat, jagged);
283
+ }
284
+ };
285
+ const median = {
286
+ args: [VArg, VArg, VArg],
287
+ apply: (_context, pipe, size) => {
288
+ return pipe.median(size);
289
+ }
290
+ };
291
+ const blur = {
292
+ args: [VArg, VArg, VArg],
293
+ apply: (_context, pipe) => {
294
+ return pipe.blur();
295
+ }
296
+ };
297
+ const flatten = {
298
+ args: [VArg, VArg, VArg],
299
+ apply: (context, pipe) => {
300
+ return pipe.flatten({
301
+ background: context.background
302
+ });
303
+ }
304
+ };
305
+ const gamma = {
306
+ args: [VArg, VArg, VArg],
307
+ apply: (_context, pipe, gamma2, gammaOut) => {
308
+ return pipe.gamma(gamma2, gammaOut);
309
+ }
310
+ };
311
+ const negate = {
312
+ args: [VArg, VArg, VArg],
313
+ apply: (_context, pipe) => {
314
+ return pipe.negate();
315
+ }
316
+ };
317
+ const normalize = {
318
+ args: [VArg, VArg, VArg],
319
+ apply: (_context, pipe) => {
320
+ return pipe.normalize();
321
+ }
322
+ };
323
+ const threshold = {
324
+ args: [VArg],
325
+ apply: (_context, pipe, threshold2) => {
326
+ return pipe.threshold(threshold2);
327
+ }
328
+ };
329
+ const modulate = {
330
+ args: [VArg],
331
+ apply: (_context, pipe, brightness, saturation, hue) => {
332
+ return pipe.modulate({
333
+ brightness,
334
+ saturation,
335
+ hue
336
+ });
337
+ }
338
+ };
339
+ const tint = {
340
+ args: [VArg],
341
+ apply: (_context, pipe, rgb) => {
342
+ return pipe.tint(rgb);
343
+ }
344
+ };
345
+ const grayscale = {
346
+ args: [VArg],
347
+ apply: (_context, pipe) => {
348
+ return pipe.grayscale();
349
+ }
350
+ };
351
+ const crop = extract;
352
+ const q = quality;
353
+ const b = background;
354
+ const w = width;
355
+ const h = height;
356
+ const s = resize;
357
+
358
+ const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
359
+ function createIPX(userOptions) {
360
+ const defaults = {
361
+ dir: getEnv("IPX_DIR", "."),
362
+ domains: getEnv("IPX_DOMAINS", []),
363
+ alias: getEnv("IPX_ALIAS", {}),
364
+ sharp: {}
365
+ };
366
+ const options = defu(userOptions, defaults);
367
+ options.alias = Object.fromEntries(Object.entries(options.alias).map((e) => [withLeadingSlash(e[0]), e[1]]));
368
+ const ctx = {
369
+ sources: {}
370
+ };
371
+ if (options.dir) {
372
+ ctx.sources.filesystem = createFilesystemSource({
373
+ dir: options.dir
374
+ });
375
+ }
376
+ if (options.domains) {
377
+ ctx.sources.http = createHTTPSource({
378
+ domains: options.domains
379
+ });
380
+ }
381
+ return function ipx(id, modifiers = {}, reqOptions = {}) {
382
+ if (!id) {
383
+ throw createError("resource id is missing", 400);
384
+ }
385
+ id = hasProtocol(id) ? id : withLeadingSlash(id);
386
+ for (const base in options.alias) {
387
+ if (id.startsWith(base)) {
388
+ id = joinURL(options.alias[base], id.substr(base.length));
389
+ }
390
+ }
391
+ const getSrc = cachedPromise(() => {
392
+ const source = hasProtocol(id) ? "http" : "filesystem";
393
+ if (!ctx.sources[source]) {
394
+ throw createError("Unknown source: " + source, 400);
395
+ }
396
+ return ctx.sources[source](id, reqOptions);
397
+ });
398
+ const getData = cachedPromise(async () => {
399
+ const src = await getSrc();
400
+ const data = await src.getData();
401
+ const meta = imageMeta(data);
402
+ const mFormat = modifiers.f || modifiers.format;
403
+ let format = mFormat || meta.type;
404
+ if (format === "jpg") {
405
+ format = "jpeg";
406
+ }
407
+ if (meta.type === "svg" && !mFormat) {
408
+ return {
409
+ data,
410
+ format: "svg+xml",
411
+ meta
412
+ };
413
+ }
414
+ const animated = modifiers.animated !== void 0 || modifiers.a !== void 0;
415
+ if (animated) {
416
+ format = "webp";
417
+ }
418
+ const Sharp = await import('sharp').then((r) => r.default || r);
419
+ let sharp = Sharp(data, { animated });
420
+ Object.assign(sharp.options, options.sharp);
421
+ const handlers = Object.entries(modifiers).map(([name, args]) => ({ handler: getHandler(name), name, args })).filter((h) => h.handler).sort((a, b) => {
422
+ const aKey = (a.handler.order || a.name || "").toString();
423
+ const bKey = (b.handler.order || b.name || "").toString();
424
+ return aKey.localeCompare(bKey);
425
+ });
426
+ const handlerCtx = { meta };
427
+ for (const h of handlers) {
428
+ sharp = applyHandler(handlerCtx, sharp, h.handler, h.args) || sharp;
429
+ }
430
+ if (SUPPORTED_FORMATS.includes(format)) {
431
+ sharp = sharp.toFormat(format, {
432
+ quality: handlerCtx.quality,
433
+ progressive: format === "jpeg"
434
+ });
435
+ }
436
+ const newData = await sharp.toBuffer();
437
+ return {
438
+ data: newData,
439
+ format,
440
+ meta
441
+ };
442
+ });
443
+ return {
444
+ src: getSrc,
445
+ data: getData
446
+ };
447
+ };
448
+ }
449
+
450
+ async function _handleRequest(req, ipx) {
451
+ const res = {
452
+ statusCode: 200,
453
+ statusMessage: "",
454
+ headers: {},
455
+ body: ""
456
+ };
457
+ const [modifiersStr = "", ...idSegments] = req.url.substr(1).split("/");
458
+ const id = decode(idSegments.join("/"));
459
+ if (!modifiersStr) {
460
+ throw createError("Modifiers is missing in path: " + req.url, 400);
461
+ }
462
+ if (!id || id === "/") {
463
+ throw createError("Resource id is missing: " + req.url, 400);
464
+ }
465
+ const modifiers = Object.create(null);
466
+ if (modifiersStr !== "_") {
467
+ for (const p of modifiersStr.split(",")) {
468
+ const [key, value = ""] = p.split("_");
469
+ modifiers[key] = decode(value);
470
+ }
471
+ }
472
+ const img = ipx(id, modifiers, req.options);
473
+ const src = await img.src();
474
+ if (src.mtime) {
475
+ if (req.headers["if-modified-since"]) {
476
+ if (new Date(req.headers["if-modified-since"]) >= src.mtime) {
477
+ res.statusCode = 304;
478
+ return res;
479
+ }
480
+ }
481
+ res.headers["Last-Modified"] = +src.mtime + "";
482
+ }
483
+ if (src.maxAge !== void 0) {
484
+ res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
485
+ }
486
+ const { data, format } = await img.data();
487
+ const etag = getEtag(data);
488
+ res.headers.ETag = etag;
489
+ if (etag && req.headers["if-none-match"] === etag) {
490
+ res.statusCode = 304;
491
+ return res;
492
+ }
493
+ if (format) {
494
+ res.headers["Content-Type"] = `image/${format}`;
495
+ }
496
+ res.body = data;
497
+ return res;
498
+ }
499
+ function handleRequest(req, ipx) {
500
+ return _handleRequest(req, ipx).catch((err) => {
501
+ const statusCode = parseInt(err.statusCode) || 500;
502
+ const statusMessage = err.statusMessage ? xss(err.statusMessage) : `IPX Error (${statusCode})`;
503
+ if (process.env.NODE_ENV !== "production" && statusCode === 500) {
504
+ console.error(err);
505
+ }
506
+ return {
507
+ statusCode,
508
+ statusMessage,
509
+ body: statusMessage,
510
+ headers: {}
511
+ };
512
+ });
513
+ }
514
+ function createIPXMiddleware(ipx) {
515
+ return function IPXMiddleware(req, res) {
516
+ handleRequest({ url: req.url, headers: req.headers }, ipx).then((_res) => {
517
+ res.statusCode = _res.statusCode;
518
+ res.statusMessage = _res.statusMessage;
519
+ for (const name in _res.headers) {
520
+ res.setHeader(name, _res.headers[name]);
521
+ }
522
+ res.end(_res.body);
523
+ });
524
+ };
525
+ }
526
+
527
+ export { createIPXMiddleware as a, createIPX as c, handleRequest as h };
package/dist/cli.cjs ADDED
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const consola = require('consola');
4
+ const listhen = require('listhen');
5
+ const middleware = require('./chunks/middleware.cjs');
6
+ require('defu');
7
+ require('image-meta');
8
+ require('ufo');
9
+ require('fs');
10
+ require('pathe');
11
+ require('is-valid-path');
12
+ require('http');
13
+ require('https');
14
+ require('ohmyfetch');
15
+ require('destr');
16
+ require('etag');
17
+ require('xss');
18
+
19
+ function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e["default"] : e; }
20
+
21
+ const consola__default = /*#__PURE__*/_interopDefaultLegacy(consola);
22
+
23
+ async function main() {
24
+ const ipx = middleware.createIPX({});
25
+ const middleware$1 = middleware.createIPXMiddleware(ipx);
26
+ await listhen.listen(middleware$1, {
27
+ clipboard: false
28
+ });
29
+ }
30
+ main().catch((err) => {
31
+ consola__default.error(err);
32
+ process.exit(1);
33
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+
package/dist/cli.mjs ADDED
@@ -0,0 +1,27 @@
1
+ import consola from 'consola';
2
+ import { listen } from 'listhen';
3
+ import { c as createIPX, a as createIPXMiddleware } from './chunks/middleware.mjs';
4
+ import 'defu';
5
+ import 'image-meta';
6
+ import 'ufo';
7
+ import 'fs';
8
+ import 'pathe';
9
+ import 'is-valid-path';
10
+ import 'http';
11
+ import 'https';
12
+ import 'ohmyfetch';
13
+ import 'destr';
14
+ import 'etag';
15
+ import 'xss';
16
+
17
+ async function main() {
18
+ const ipx = createIPX({});
19
+ const middleware = createIPXMiddleware(ipx);
20
+ await listen(middleware, {
21
+ clipboard: false
22
+ });
23
+ }
24
+ main().catch((err) => {
25
+ consola.error(err);
26
+ process.exit(1);
27
+ });