@platformatic/image-optimizer 0.0.1 → 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/README.md CHANGED
@@ -1,10 +1,70 @@
1
- # @platformatic/skeleton
1
+ # @platformatic/image-optimizer
2
+
3
+ Detect, fetch, and optimize images with [`sharp`](https://sharp.pixelplumbing.com/), with optional queue-backed processing via [`@platformatic/job-queue`](https://github.com/platformatic/job-queue).
2
4
 
3
5
  ## Features
4
6
 
7
+ - Detects image type from file signatures (magic bytes)
8
+ - Optimizes raster images (`jpeg`, `png`, `webp`, `avif`)
9
+ - Prevents animated image optimization
10
+ - Supports optional SVG passthrough
11
+ - Provides `fetchAndOptimize()` for URL-based workflows (via `undici.request()`)
12
+ - Provides queue APIs (`Queue`, `createQueue`) powered by [`@platformatic/job-queue`](https://www.npmjs.com/package/@platformatic/job-queue) for distributed work
13
+ - Throws structured `ImageError` objects
14
+
5
15
  ## Installation
6
16
 
7
- ## Getting Started
17
+ ```bash
18
+ npm i @platformatic/image-optimizer
19
+ ```
20
+
21
+ ## API
22
+
23
+ ### `optimize(buffer, width, quality, allowSVG = false)`
24
+
25
+ Optimizes an input image buffer.
26
+
27
+ - `width`: target max width (`withoutEnlargement` is enabled)
28
+ - `quality`: output quality used by format-specific encoders
29
+ - `allowSVG`: when `false` (default), SVG images are not optimized and it throws an error
30
+
31
+ ### `fetchAndOptimize(url, width, quality, allowSVG = false)`
32
+
33
+ Fetches an image and then runs `optimize()`.
34
+ Returns:
35
+
36
+ - `buffer`: optimized image buffer
37
+ - `contentType`: upstream `content-type` response header (or `null`)
38
+ - `cacheControl`: upstream `cache-control` response header (or `null`)
39
+
40
+ ### `detectImageType(buffer)`
41
+
42
+ Returns the detected image type (for example `jpeg`, `png`, `webp`) or `null`.
43
+
44
+ ### `Queue`
45
+
46
+ Queue-backed optimizer powered by [`@platformatic/job-queue`](https://www.npmjs.com/package/@platformatic/job-queue).
47
+
48
+ Methods:
49
+
50
+ - `start()`
51
+ - `stop()`
52
+ - `optimize(buffer, width, quality, allowSVG?)` (auto-starts on first use)
53
+ - `fetchAndOptimize(url, width, quality, allowSVG?)` (auto-starts on first use)
54
+
55
+ ### `createQueue(options?)`
56
+
57
+ Creates and starts a `Queue` instance.
58
+
59
+ ## Example
60
+
61
+ ```ts
62
+ import { createQueue, fetchAndOptimize } from '@platformatic/image-optimizer'
63
+
64
+ const queue = await createQueue({ concurrency: 2 })
65
+ const { buffer } = await fetchAndOptimize('https://example.com/image.jpg', 800, 75)
66
+ await queue.stop()
67
+ ```
8
68
 
9
69
  ## License
10
70
 
@@ -1,4 +1,5 @@
1
1
  export declare const imageTypes: [string, number[]][];
2
+ export declare const imageMimeTypes: Record<string, string>;
2
3
  export declare const animatableTypes: string[];
3
4
  export declare const bypassTypes: string[];
4
5
  export declare const maxImageSize = 268402689;
@@ -16,6 +16,18 @@ export const imageTypes = [
16
16
  ['svg_2', [0x3c, 0x73, 0x76, 0x67]],
17
17
  ['jxl_1', [0xff, 0x0a]]
18
18
  ];
19
+ export const imageMimeTypes = {
20
+ avif: 'image/avif',
21
+ jpeg: 'image/jpeg',
22
+ png: 'image/png',
23
+ webp: 'image/webp',
24
+ svg: 'image/svg+xml',
25
+ ico: 'image/x-icon',
26
+ icns: 'image/icns',
27
+ bmp: 'image/bmp',
28
+ heic: 'image/heic',
29
+ jxl: 'image/jxl'
30
+ };
19
31
  export const animatableTypes = ['webp', 'png', 'gif'];
20
32
  export const bypassTypes = ['svg', 'ico', 'icns', 'bmp', 'jxl', 'heic'];
21
33
  export const maxImageSize = 268_402_689;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- export declare function detectImageType(buffer: Buffer): string | null;
2
- export declare function optimize(buffer: Buffer, width: number, quality: number, allowSVG?: boolean): Promise<Buffer>;
3
- export declare function fetchAndOptimize(url: string, width: number, quality: number, allowSVG?: boolean): Promise<Buffer>;
1
+ export * from './definitions.ts';
2
+ export * from './operations.ts';
3
+ export * from './queue.ts';
4
+ export * from './types.ts';
package/dist/index.js CHANGED
@@ -1,66 +1,4 @@
1
- import isAnimated from 'is-animated';
2
- import sharp from 'sharp';
3
- import { animatableTypes, bypassTypes, imageTypes, maxImageSize } from "./definitions.js";
4
- import { ImageError } from "./errors.js";
5
- export function detectImageType(buffer) {
6
- for (let e = 0; e < imageTypes.length; e++) {
7
- const [key, bytes] = imageTypes[e];
8
- if (buffer.length < bytes.length) {
9
- continue;
10
- }
11
- let match = true;
12
- for (let i = 0; i < bytes.length; i++) {
13
- const s = bytes[i];
14
- if (s !== -1 && buffer[i] !== s) {
15
- match = false;
16
- break;
17
- }
18
- }
19
- if (!match) {
20
- continue;
21
- }
22
- const index = key.indexOf('_');
23
- return index === -1 ? key : key.slice(0, index);
24
- }
25
- return null;
26
- }
27
- export async function optimize(buffer, width, quality, allowSVG = false) {
28
- const upstreamType = detectImageType(buffer);
29
- if (!upstreamType) {
30
- throw new ImageError(400, 'Invalid input image');
31
- }
32
- if (animatableTypes.includes(upstreamType) && isAnimated(buffer)) {
33
- throw new ImageError(400, 'Unable to optimize and animated image');
34
- }
35
- if (upstreamType === 'svg' && !allowSVG) {
36
- throw new ImageError(400, 'Optimization of SVG images is not allowed');
37
- }
38
- if (bypassTypes.includes(upstreamType)) {
39
- return buffer;
40
- }
41
- const transformer = sharp(buffer, { limitInputPixels: maxImageSize }).rotate();
42
- transformer.resize({ width, withoutEnlargement: true });
43
- if (upstreamType === 'avif') {
44
- transformer.avif({ quality: Math.max(quality - 20, 1), effort: 3 });
45
- }
46
- else if (upstreamType === 'webp') {
47
- transformer.webp({ quality });
48
- }
49
- else if (upstreamType === 'png') {
50
- transformer.png({ quality });
51
- }
52
- else if (upstreamType === 'jpeg') {
53
- transformer.jpeg({ quality, mozjpeg: true });
54
- }
55
- return transformer.toBuffer();
56
- }
57
- export async function fetchAndOptimize(url, width, quality, allowSVG = false) {
58
- const response = await fetch(url);
59
- if (!response.ok) {
60
- throw new ImageError(response.statusText, `Unable to fetch the image. [HTTP ${response.statusText}]`, {
61
- response: await response.text()
62
- });
63
- }
64
- const buffer = Buffer.from(await response.arrayBuffer());
65
- return optimize(buffer, width, quality, allowSVG);
66
- }
1
+ export * from "./definitions.js";
2
+ export * from "./operations.js";
3
+ export * from "./queue.js";
4
+ export * from "./types.js";
@@ -0,0 +1,4 @@
1
+ import type { Image } from './types.ts';
2
+ export declare function detectImageType(buffer: Buffer): string | null;
3
+ export declare function optimize(buffer: Buffer, width: number, quality: number, allowSVG?: boolean): Promise<Buffer>;
4
+ export declare function fetchAndOptimize(url: string, width: number, quality: number, allowSVG?: boolean): Promise<Image<Buffer>>;
@@ -0,0 +1,72 @@
1
+ import { BadGatewayError, BadRequestError } from 'http-errors-enhanced';
2
+ import isAnimated from 'is-animated';
3
+ import sharp from 'sharp';
4
+ import { request } from 'undici';
5
+ import { animatableTypes, bypassTypes, imageTypes, maxImageSize } from "./definitions.js";
6
+ export function detectImageType(buffer) {
7
+ for (let e = 0; e < imageTypes.length; e++) {
8
+ const [key, bytes] = imageTypes[e];
9
+ if (buffer.length < bytes.length) {
10
+ continue;
11
+ }
12
+ let match = true;
13
+ for (let i = 0; i < bytes.length; i++) {
14
+ const s = bytes[i];
15
+ if (s !== -1 && buffer[i] !== s) {
16
+ match = false;
17
+ break;
18
+ }
19
+ }
20
+ if (!match) {
21
+ continue;
22
+ }
23
+ const index = key.indexOf('_');
24
+ return index === -1 ? key : key.slice(0, index);
25
+ }
26
+ return null;
27
+ }
28
+ export async function optimize(buffer, width, quality, allowSVG = false) {
29
+ const upstreamType = detectImageType(buffer);
30
+ if (!upstreamType) {
31
+ throw new BadRequestError('Invalid input image');
32
+ }
33
+ if (animatableTypes.includes(upstreamType) && isAnimated(buffer)) {
34
+ throw new BadRequestError('Unable to optimize and animated image');
35
+ }
36
+ if (upstreamType === 'svg' && !allowSVG) {
37
+ throw new BadRequestError('SVG images are not allowed');
38
+ }
39
+ if (bypassTypes.includes(upstreamType)) {
40
+ return buffer;
41
+ }
42
+ const transformer = sharp(buffer, { limitInputPixels: maxImageSize }).rotate();
43
+ transformer.resize({ width, withoutEnlargement: true });
44
+ if (upstreamType === 'avif') {
45
+ transformer.avif({ quality: Math.max(quality - 20, 1), effort: 3 });
46
+ }
47
+ else if (upstreamType === 'webp') {
48
+ transformer.webp({ quality });
49
+ }
50
+ else if (upstreamType === 'png') {
51
+ transformer.png({ quality });
52
+ }
53
+ else if (upstreamType === 'jpeg') {
54
+ transformer.jpeg({ quality, mozjpeg: true });
55
+ }
56
+ return transformer.toBuffer();
57
+ }
58
+ export async function fetchAndOptimize(url, width, quality, allowSVG = false) {
59
+ const response = await request(url);
60
+ if (response.statusCode < 200 || response.statusCode >= 300) {
61
+ throw new BadGatewayError(`Unable to fetch the image. [HTTP ${response.statusCode}]`, {
62
+ response: await response.body.text()
63
+ });
64
+ }
65
+ const contentType = response.headers['content-type'];
66
+ const cacheControl = response.headers['cache-control'];
67
+ return {
68
+ buffer: await optimize(Buffer.from(await response.body.arrayBuffer()), width, quality, allowSVG),
69
+ contentType: typeof contentType === 'string' ? contentType : null,
70
+ cacheControl: typeof cacheControl === 'string' ? cacheControl : null
71
+ };
72
+ }
@@ -0,0 +1,18 @@
1
+ import type { Queue as JobQueue, MemoryStorage } from '@platformatic/job-queue';
2
+ import type { Image, QueueOptions } from './types.ts';
3
+ interface JobQueueModule {
4
+ Queue: typeof JobQueue;
5
+ MemoryStorage: typeof MemoryStorage;
6
+ }
7
+ export declare class Queue {
8
+ #private;
9
+ protected static jobQueueModulePromise: Promise<JobQueueModule> | null;
10
+ constructor(options?: QueueOptions);
11
+ protected static loadJobQueueModule(): Promise<JobQueueModule>;
12
+ start(): Promise<void>;
13
+ stop(): Promise<void>;
14
+ optimize(buffer: Buffer, width: number, quality: number, allowSVG?: boolean): Promise<Buffer>;
15
+ fetchAndOptimize(url: string, width: number, quality: number, allowSVG?: boolean): Promise<Image<Buffer>>;
16
+ }
17
+ export declare function createQueue(options?: QueueOptions): Promise<Queue>;
18
+ export {};
package/dist/queue.js ADDED
@@ -0,0 +1,111 @@
1
+ import { InternalServerError } from 'http-errors-enhanced';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { fetchAndOptimize, optimize } from "./operations.js";
4
+ export class Queue {
5
+ static jobQueueModulePromise = null;
6
+ #queue = null;
7
+ #started = false;
8
+ #options;
9
+ constructor(options = {}) {
10
+ this.#options = options;
11
+ }
12
+ static async loadJobQueueModule() {
13
+ if (this.jobQueueModulePromise) {
14
+ return this.jobQueueModulePromise;
15
+ }
16
+ this.jobQueueModulePromise = import('@platformatic/job-queue');
17
+ return this.jobQueueModulePromise;
18
+ }
19
+ async start() {
20
+ if (this.#started) {
21
+ return;
22
+ }
23
+ const queueClass = this.constructor;
24
+ let jobQueueModule;
25
+ try {
26
+ jobQueueModule = await queueClass.loadJobQueueModule();
27
+ }
28
+ catch (error) {
29
+ if (error.code === 'ERR_MODULE_NOT_FOUND' &&
30
+ error.message.includes("'@platformatic/job-queue'")) {
31
+ throw new InternalServerError('The Queue requires @platformatic/job-queue to be installed', {
32
+ cause: error
33
+ });
34
+ }
35
+ throw error;
36
+ }
37
+ const { Queue: JobQueue, MemoryStorage } = jobQueueModule;
38
+ const storage = (this.#options.storage ?? new MemoryStorage());
39
+ const queue = new JobQueue({
40
+ storage,
41
+ workerId: this.#options.workerId,
42
+ concurrency: this.#options.concurrency,
43
+ blockTimeout: this.#options.blockTimeout,
44
+ maxRetries: this.#options.maxRetries,
45
+ visibilityTimeout: this.#options.visibilityTimeout,
46
+ processingQueueTTL: this.#options.processingQueueTTL,
47
+ resultTTL: this.#options.resultTTL
48
+ });
49
+ queue.execute(this.#execute.bind(this));
50
+ await queue.start();
51
+ this.#queue = queue;
52
+ this.#started = true;
53
+ }
54
+ async stop() {
55
+ if (!this.#queue || !this.#started) {
56
+ return;
57
+ }
58
+ await this.#queue.stop();
59
+ this.#queue = null;
60
+ this.#started = false;
61
+ }
62
+ async optimize(buffer, width, quality, allowSVG = false) {
63
+ const result = await this.#enqueueAndWait({
64
+ type: 'optimize',
65
+ buffer: buffer.toString('base64'),
66
+ width,
67
+ quality,
68
+ allowSVG
69
+ });
70
+ return Buffer.from(result.buffer, 'base64');
71
+ }
72
+ async fetchAndOptimize(url, width, quality, allowSVG = false) {
73
+ const result = await this.#enqueueAndWait({
74
+ type: 'fetchAndOptimize',
75
+ url,
76
+ width,
77
+ quality,
78
+ allowSVG
79
+ });
80
+ return {
81
+ buffer: Buffer.from(result.buffer, 'base64'),
82
+ contentType: result.contentType ?? null,
83
+ cacheControl: result.cacheControl ?? null
84
+ };
85
+ }
86
+ async #enqueueAndWait(payload) {
87
+ if (!this.#queue || !this.#started) {
88
+ await this.start();
89
+ }
90
+ return this.#queue.enqueueAndWait(randomUUID(), payload);
91
+ }
92
+ async #execute({ payload }) {
93
+ if (payload.type === 'optimize') {
94
+ const optimizedBuffer = await optimize(Buffer.from(payload.buffer, 'base64'), payload.width, payload.quality, payload.allowSVG);
95
+ return {
96
+ buffer: optimizedBuffer.toString('base64')
97
+ };
98
+ }
99
+ const optimizedImage = await fetchAndOptimize(payload.url, payload.width, payload.quality, payload.allowSVG);
100
+ return {
101
+ buffer: optimizedImage.buffer.toString('base64'),
102
+ contentType: optimizedImage.contentType,
103
+ cacheControl: optimizedImage.cacheControl
104
+ };
105
+ }
106
+ }
107
+ export async function createQueue(options = {}) {
108
+ const queue = new Queue(options);
109
+ await queue.start();
110
+ return queue;
111
+ }
@@ -0,0 +1,36 @@
1
+ export type ErrorProperties = {
2
+ cause?: Error;
3
+ } & Record<string, any>;
4
+ export interface OptimizeJobPayload {
5
+ type: 'optimize';
6
+ buffer: string;
7
+ width: number;
8
+ quality: number;
9
+ allowSVG: boolean;
10
+ }
11
+ export interface FetchAndOptimizeJobPayload {
12
+ type: 'fetchAndOptimize';
13
+ url: string;
14
+ width: number;
15
+ quality: number;
16
+ allowSVG: boolean;
17
+ }
18
+ export type QueuePayload = OptimizeJobPayload | FetchAndOptimizeJobPayload;
19
+ export interface QueueOptions {
20
+ storage?: unknown;
21
+ workerId?: string;
22
+ concurrency?: number;
23
+ blockTimeout?: number;
24
+ maxRetries?: number;
25
+ visibilityTimeout?: number;
26
+ processingQueueTTL?: number;
27
+ resultTTL?: number;
28
+ }
29
+ export interface Job {
30
+ payload: QueuePayload;
31
+ }
32
+ export interface Image<T = Buffer | string> {
33
+ buffer: T;
34
+ contentType?: string | null;
35
+ cacheControl?: string | null;
36
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const name = "image-optimizer";
2
- export const version = "0.0.1";
1
+ export const name = "@platformatic/image-optimizer";
2
+ export const version = "0.1.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/image-optimizer",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Detect, fetch, and optimize images with sharp, with optional queue-backed processing via @platformatic/job-queue.",
5
5
  "homepage": "https://github.com/platformatic/image-optimizer",
6
6
  "author": "Platformatic Inc. <oss@platformatic.dev> (https://platformatic.dev)",
@@ -22,7 +22,42 @@
22
22
  "type": "module",
23
23
  "exports": "./dist/index.js",
24
24
  "types": "./dist/index.d.ts",
25
+ "dependencies": {
26
+ "http-errors-enhanced": "^4.0.2",
27
+ "is-animated": "^2.0.2",
28
+ "sharp": "^0.34.5",
29
+ "undici": "^7.22.0"
30
+ },
31
+ "optionalDependencies": {
32
+ "@platformatic/job-queue": "^0.3.0",
33
+ "iovalkey": "^0.3.3"
34
+ },
35
+ "devDependencies": {
36
+ "@platformatic/tsconfig": "^0.2.0",
37
+ "@types/node": "^25.3.0",
38
+ "@types/semver": "^7.7.1",
39
+ "c8": "^10.1.3",
40
+ "cleaner-spec-reporter": "^1.0.3",
41
+ "eslint": "^9.39.0",
42
+ "eslint-plugin-prettier": "^5.5.5",
43
+ "neostandard": "^0.12.2",
44
+ "prettier": "^3.8.1",
45
+ "prettier-plugin-space-before-function-paren": "^0.0.10",
46
+ "semver": "^7.7.4",
47
+ "typescript": "^5.9.3"
48
+ },
25
49
  "engines": {
26
50
  "node": ">= 22.19.0"
51
+ },
52
+ "scripts": {
53
+ "build": "rm -rf dist && tsc -p tsconfig.base.json",
54
+ "postbuild": "node scripts/postbuild.ts",
55
+ "lint": "eslint --cache",
56
+ "typecheck": "tsc -p . --noEmit",
57
+ "format": "prettier -w src test scripts",
58
+ "fixtures": "node scripts/generate-fixtures.ts",
59
+ "test": "c8 -c test/config/c8-local.json node --test --test-reporter=cleaner-spec-reporter --test-timeout=60000 'test/**/*.test.ts'",
60
+ "test:ci": "c8 -c test/config/c8-ci.json node --test --test-reporter=cleaner-spec-reporter --test-timeout=60000 'test/**/*.test.ts'",
61
+ "ci": "npm run build && npm run lint && npm run test:ci"
27
62
  }
28
63
  }
package/dist/errors.d.ts DELETED
@@ -1,7 +0,0 @@
1
- export type ErrorProperties = {
2
- cause?: Error;
3
- } & Record<string, any>;
4
- export declare class ImageError extends Error {
5
- code: string | number;
6
- constructor(code: string | number, message: string, { cause, ...rest }?: ErrorProperties);
7
- }
package/dist/errors.js DELETED
@@ -1,17 +0,0 @@
1
- export class ImageError extends Error {
2
- code;
3
- constructor(code, message, { cause, ...rest } = {}) {
4
- super(message, cause ? { cause } : {});
5
- this.name = 'ImageError';
6
- this.code = code;
7
- Reflect.defineProperty(this, 'message', { enumerable: true });
8
- Reflect.defineProperty(this, 'code', { enumerable: true });
9
- if ('stack' in this) {
10
- Reflect.defineProperty(this, 'stack', { enumerable: true });
11
- }
12
- for (const [key, value] of Object.entries(rest)) {
13
- Reflect.defineProperty(this, key, { value, enumerable: true });
14
- }
15
- Error.captureStackTrace(this, this.constructor);
16
- }
17
- }