├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── eslint.config.js ├── images ├── animated.gif ├── animated.webp ├── image.png ├── non-animated.gif └── non-animated.webp ├── package.json ├── pnpm-lock.yaml ├── src ├── app.d.ts ├── app.html ├── demo.spec.ts ├── hooks.client.ts ├── hooks.server.ts ├── lib │ ├── cache-adapters │ │ ├── disck.ts │ │ ├── index.ts │ │ ├── memory.ts │ │ └── s3.ts │ ├── client.ts │ ├── components │ │ ├── Image.svelte │ │ ├── Picture.svelte │ │ ├── index.ts │ │ └── utils.ts │ ├── data.ts │ ├── handlers │ │ ├── helpers.ts │ │ ├── image.utils.test.ts │ │ ├── imageHandler.ts │ │ └── images.utils.ts │ └── index.ts └── routes │ ├── +page.server.ts │ ├── +page.svelte │ └── page.svelte.test.ts ├── static ├── favicon.png └── orange50.jpg ├── svelte.config.js ├── tsconfig.json ├── vite.config.ts └── vitest-setup-client.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | .netlify 7 | .wrangler 8 | /.svelte-kit 9 | /build 10 | /dist 11 | 12 | # OS 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # Env 17 | .env 18 | .env.* 19 | !.env.example 20 | !.env.test 21 | 22 | # Vite 23 | vite.config.js.timestamp-* 24 | vite.config.ts.timestamp-* 25 | 26 | 27 | # default 28 | .image-cache 29 | *.tgz 30 | .vscode -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | bun.lock 6 | bun.lockb 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Rachid Boudjelida 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, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![SvelteKit Image Optimizer](https://i.imgur.com/ZlMZBGk.png) 2 | 3 | # SvelteKit Image Optimizer 4 | 5 | > 💡Not to be confused with `img:enhance` that handles image optimizations at build time, this is for the external images, or images from CMSs and other sources. this package is more like cloudflare's image optimizer. a proxy right in your sveltekit app that auto optimizes images. 6 | 7 | This is a simple utility that helps you create an endpoint of your svelte app that optimizes your images, it uses `sharp` to optimize images. 8 | 9 | I have been using something similar for my small to medium projects, and decided maybe others can benefit from this as well. 10 | 11 | This library is similar to what `next/image` image optimizer does, basically a proxy that optimizes/resizes images on the fly, with support for [caching](#caching). 12 | 13 | ## Table of Contents 14 | 15 | - [SvelteKit Image Optimizer](#sveltekit-image-optimizer) 16 | - [Table of Contents](#table-of-contents) 17 | - [Why would you use this?](#why-would-you-use-this) 18 | - [Installation](#installation) 19 | - [Setup](#setup) 20 | - [Using Components](#using-components) 21 | - [Image Component Props](#image-component-props) 22 | - [`OptimizeOptions` Object](#optimizeoptions-object) 23 | - [Picture Component Props](#picture-component-props) 24 | - [ Advanced Hook Configuration](#advanced-hook-configuration) 25 | - [Client side Configuration](#client-side-configuration) 26 | - [Component Optimize options](#component-optimize-options) 27 | - [Caching](#caching) 28 | - [Cache Adapters](#cache-adapters) 29 | - [Memory Cache](#memory-cache) 30 | - [File System Cache](#file-system-cache) 31 | - [S3 Cache Adapter](#s3-cache-adapter) 32 | - [Usage](#usage) 33 | - [Make Your own Adapter](#make-your-own-adapter) 34 | - [Animated Images](#animated-images) 35 | - [Future plans](#future-plans) 36 | - [Contribution](#contribution) 37 | - [License](#license) 38 | 39 | ## Why would you use this? 40 | 41 | Svelte Kit Image Optimize provides a seamless solution for image optimization in your Svelte Kit project. You should consider this library when 42 | 43 | - You need to serve optimized, properly sized images to reduce page load times and improve Core Web Vitals 44 | - You want automatic format conversion to modern formats like WebP and AVIF based on browser support 45 | - You require responsive images that automatically adapt to different screen sizes and device capabilities 46 | - You need flexible caching strategies (memory, filesystem, or S3) to improve performance and reduce server load 47 | - You want a simple, declarative API with Svelte components that handle the complexity of responsive images 48 | - You need to optimize images from external domains with security controls 49 | - You want to serve external images from your own domain, improving SEO. 50 | 51 | > 💡 In many cased you do not have control over images, probably the content is created by writers or other non tech people, and you have no control over that, this can help you server optimized images without interfering with those content creators 52 | 53 | ## Installation 54 | 55 | To be able to use this package you need to install both `sveltekit-image-optimize` and `sharp` 56 | 57 | > 💡 **Note:** If you already have `sharp` installed you can skip this. 58 | 59 | ```bash 60 | npm i sveltekit-image-optimize sharp 61 | ``` 62 | 63 | ## Setup 64 | 65 | To begin using the optimizer all you have to do is add the image optimizer handler to your `hooks.server.ts` 66 | 67 | `hooks.server.ts:` 68 | 69 | ```typescript filename="hooks.server.(ts/js)" 70 | import { createImageOptimizer } from 'sveltekit-image-optimize'; 71 | 72 | const imageHandler = createImageOptimizer({ 73 | // optional params here 74 | }); 75 | 76 | // add it to your hook handler (if you have multiple handlers use sequence) 77 | export const handle: Handle = imageHandler; 78 | ``` 79 | 80 | > 💡 **Note:** while this simple example will get you going, I highly suggest you checkout the caching and the other options to optimize you setup 81 | 82 | ## Using Components 83 | 84 | Then Any where in your project you can use the provided `Image`, `Picture` components or `toOptimizedURL` to generate an optimized image `URL` (useful for styles/background images and such) 85 | 86 | ```html 87 | 91 | 95 | 103 | 104 | 108 | 130 | 131 | 132 | 133 | 134 | ``` 135 | 136 | ### Image Component Props 137 | 138 | | Prop | Type | Description | 139 | | ----------------- | -------- | ----------------------------------------------------------- | 140 | | `src` | `string` | The source URL of the image (required) | 141 | | `alt` | `string` | Alternative text for the image (required for accessibility) | 142 | | `optimizeOptions` | `object` | Image optimization options (see below) | 143 | | `...rest` | `any` | Any other HTML `img` attributes are passed through | 144 | 145 | ### `OptimizeOptions` Object 146 | 147 | All these options are optional 148 | 149 | | Option | Type | Description | 150 | | ------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 151 | | `width` | `number` | Target width of the image, if the image already have width/height you do not have to provide these here | 152 | | `height` | `number` | Target height of the image, if the image already have width/height you do not have to provide these here | 153 | | `quality` | `number` | Image quality (1-100) | 154 | | `format` | `string` | Output format (`avif`, `webp`, `jpeg`, `png`, etc.), recommended is to omit this option because the optimization endpoint will select the best format based on the `accept` header | 155 | | `fit` | `string` | Resize behavior (cover, contain, fill, inside, outside) | 156 | | `position` | `string` | Position for cropping (center, top, left, etc.) | 157 | | `background` | `string` | Background color when using fit modes that need it | 158 | | `preload` | `'preload' \| 'prefetch' or undefined` | if provided a `` will be added to `` | 159 | 160 | ### Picture Component Props 161 | 162 | For more details you can check the [component source](/src/lib/components/Picture.svelte) 163 | 164 | | Prop | Type | Description | 165 | | ------------------- | --------- | -------------------------------------------------------------- | 166 | | `src` | `string` | The source URL of the image (required) | 167 | | `alt` | `string` | Alternative text for the image (required for accessibility) | 168 | | `defaultOptions` | `object` | Default optimization options applied to all sources | 169 | | `sources` | `array` | Custom responsive image sources (see below) | 170 | | `useDefaultSources` | `boolean` | Whether to use built-in responsive breakpoints (default: true) | 171 | | `aspectRatio` | `number` | Optional aspect ratio to maintain (e.g., 16/9) | 172 | | `...rest` | `any` | Any other HTML `img` attributes are passed through | 173 | 174 | Each source in the `sources` array should have: 175 | 176 | | Property | Type | Description | 177 | | ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 178 | | `media` | `string` | Media query (e.g., `'(min-width: 768px)'`) | 179 | | `optimizeOptions` | `object` | Image optimization options for this breakpoint, similar to image optimize options omitting the preload because it does not make sense here (we can't know which picture source the client is going to use hence we can't ask to prefetch it) | 180 | 181 | When `useDefaultSources` is true, the Picture component includes sensible default responsive breakpoints from 540px to 5120px. 182 | 183 | ##  Advanced Hook Configuration 184 | 185 | `createImageOptimizer` takes an object of `Options` 186 | 187 | | Option | Type | Default | Description | 188 | | ------------------ | ------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | 189 | | `route` | `string` | `/api/image-op` | The endpoint path where the image optimizer will handle requests | 190 | | `formatPriority` | `Array` | `['avif', 'webp', 'jpeg', 'jpg', 'png', 'gif']` | Priority order for image formats | 191 | | `fallbackFormat` | `string` | `jpeg` | Default format to use when no format is specified | 192 | | `quality` | `number` | `75` | Default image quality (1-100) | 193 | | `cache` | `ImageCacheHandler` | `undefined` | Cache adapter for storing optimized images check [Caching](#caching) | 194 | | `cacheTTLStrategy` | `number \| 'source' \| 'immutable' \| 'no-cache'` | `'source'` | Cache TTL strategy, by default what ever cache policy is received from the source image is used. | 195 | | `allowedDomains` | `Array \| Function` | `undefined` | Domains that are allowed to be optimized (security feature) | 196 | | `minSizeThreshold` | `number \| undefined` | 5kb | The minimum size of the image to be optimized. Defaults to 5kb. | 197 | | skipFormats | `Array` | `avif, webp` | The formats that will not be optimized, this only applies to images without a resize query. Defaults to avif, webp | 198 | 199 | ## Client side Configuration 200 | 201 | If you've customized the default `route`, you need to configure the client side as well in `hooks.client.ts` 202 | 203 | `hooks.client.ts`: 204 | 205 | ```typescript 206 | import { initImageOptimizerClient } from 'sveltekit-image-optimize/client'; 207 | import type { ClientInit } from '@sveltejs/kit'; 208 | 209 | export const init: ClientInit = () => { 210 | // this is only needed if you change the default route /api/image-op 211 | // iF you do not change that , you don't have to initialize it on client side 212 | initImageOptimizerClient({ 213 | route: '/api/image-op-2' 214 | }); 215 | }; 216 | ``` 217 | 218 | ## Component Optimize options 219 | 220 | ## Caching 221 | 222 | This library provides several cache adapters for storing optimized images: 223 | 224 | ## Cache Adapters 225 | 226 | This library provides several cache adapters for storing optimized images: 227 | 228 | ### Memory Cache 229 | 230 | Memory cache is suitable for development environments. Not recommended for production as there is no cleanup other than `ttl`, so it could if you have many many images consume a lot of ram, and this is basically useless in serverless environments. 231 | 232 | ```typescript 233 | import { createMemoryCache } from 'sveltekit-image-optimize/cache-adapters'; 234 | 235 | const cache = createMemoryCache(); 236 | ``` 237 | 238 | ### File System Cache 239 | 240 | File system cache stores images on disk. Suitable for single-server deployments, For self hosting (single VPS) this is probably the best way. 241 | 242 | ```typescript 243 | import { createFileSystemCache } from 'sveltekit-image-optimize/cache-adapters'; 244 | 245 | const cache = createFileSystemCache('/path/to/cache/directory'); 246 | ``` 247 | 248 | ### S3 Cache Adapter 249 | 250 | The S3 cache adapter allows storing optimized images in Amazon S3 or S3-compatible storage. This is recommended for multi-server deployments or serverless environments. 251 | 252 | To use the S3 cache adapter, you need to install the AWS SDK packages: These packages are omitted from dependencies on purpose as the usage of the adapter is optional 253 | 254 | ```bash 255 | npm install @aws-sdk/client-s3 256 | ``` 257 | 258 | #### Usage 259 | 260 | ```typescript 261 | import { createS3Cache } from 'sveltekit-image-optimize/cache-adapters'; 262 | 263 | const cache = createS3Cache({ 264 | region: 'us-east-1', 265 | bucket: 'my-image-cache', 266 | prefix: 'images', // optional, adds a prefix to all keys 267 | // Auth credentials (optional, can use AWS environment variables) 268 | accessKeyId: 'YOUR_ACCESS_KEY', 269 | secretAccessKey: 'YOUR_SECRET_KEY', 270 | // For S3-compatible services like MinIO, DigitalOcean Spaces, etc. 271 | endpoint: 'https://custom-endpoint.com', // optional 272 | forcePathStyle: true, // for minio or other providers, this only affect the genration of public links bucket.enpoint vs endpoint/bucket 273 | /* 274 | if true the cached images won't be proxied and piped through sveltekit, instead a redirect response will be sent to the client, useful to save bandwidth o sveltekit backend or to use s3 cdn etc... 275 | if false a stream will be opened from s3 and piped straight through sveltekit response 276 | when redirecting the server will redirect with a found status and give the same cache policy as if it proxied the request 277 | */ 278 | useRedirects: false 279 | }); 280 | ``` 281 | 282 | Then use the cache with your image optimizer: 283 | 284 | ```typescript 285 | import { imageOptimizer } from 'sveltekit-image-optimize'; 286 | 287 | export const config = imageOptimizer({ 288 | cache: cache 289 | // other options... 290 | }); 291 | ``` 292 | 293 | #### Make Your own Adapter 294 | 295 | Cache adapters have the same interface, all you have to do is implement your own logic. 296 | 297 | ```typescript 298 | export interface ImageCacheHandler { 299 | // if true and getPublicUrl returns a url a redirect response will be sent instead of sending the image content 300 | useRedirects: boolean; 301 | 302 | // images are hashed based on url and the optimization parameter (with,height,format etc...) 303 | getKey(hash: string): Promise<{ 304 | value: NodeJS.ReadableStream | Buffer | null; 305 | ttl?: number; 306 | }>; 307 | 308 | // save this image to cache 309 | setKey( 310 | hash: string, 311 | value: NodeJS.ReadableStream | Buffer | ReadableStream, 312 | ttl?: number, 313 | contentType?: string 314 | ): Promise; 315 | 316 | // delete an image from cache 317 | delKey(hash: string): Promise; 318 | 319 | // if your adapter supports public urls you can handle that here 320 | // useful with cdns and other caching platforms etc... 321 | getPublicUrl(hash: string): Promise<{ 322 | url: string | null; 323 | ttl?: number; 324 | }>; 325 | } 326 | 327 | class MyAdapter implements ImageCacheHandler { 328 | // implement thigs here 329 | } 330 | ``` 331 | 332 | Note that when implementing your own cache adapter, you are responsible for invalidating the cache based on the `ttl` provided to you on `setKey`, the image optimizer handler will not keep records of `ttl` and rely of your adapter response. 333 | 334 | ### Animated Images 335 | 336 | Animated images will not be transformed as `sharp` does not support them, they will be piped straight through. 337 | 338 | ### Future plans 339 | 340 | - Add a fall back to use other libraries than `sharp` to add support for platforms what do not support sharp. 341 | - Add some tests in place 342 | - based on feedback adjust the interface if needed 343 | - Add cropping / filter and other basic image manipulations? 344 | 345 | ### Contribution 346 | 347 | Feel free to open an Issue or a pull request 348 | 349 | ## License 350 | 351 | MIT © 2025 [humanshield85](https://github.com/humanshield89) 352 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier'; 2 | import js from '@eslint/js'; 3 | import { includeIgnoreFile } from '@eslint/compat'; 4 | import svelte from 'eslint-plugin-svelte'; 5 | import globals from 'globals'; 6 | import { fileURLToPath } from 'node:url'; 7 | import ts from 'typescript-eslint'; 8 | import svelteConfig from './svelte.config.js'; 9 | 10 | const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); 11 | 12 | export default ts.config( 13 | includeIgnoreFile(gitignorePath), 14 | js.configs.recommended, 15 | ...ts.configs.recommended, 16 | ...svelte.configs.recommended, 17 | prettier, 18 | ...svelte.configs.prettier, 19 | { 20 | languageOptions: { 21 | globals: { ...globals.browser, ...globals.node } 22 | }, 23 | rules: { 'no-undef': 'off' } 24 | }, 25 | { 26 | files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], 27 | languageOptions: { 28 | parserOptions: { 29 | projectService: true, 30 | extraFileExtensions: ['.svelte'], 31 | parser: ts.parser, 32 | svelteConfig 33 | } 34 | } 35 | } 36 | ); 37 | -------------------------------------------------------------------------------- /images/animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanshield-sidepack/sveltekit-image-optimize/1048200380017b88aa99190386124e4a147c53da/images/animated.gif -------------------------------------------------------------------------------- /images/animated.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanshield-sidepack/sveltekit-image-optimize/1048200380017b88aa99190386124e4a147c53da/images/animated.webp -------------------------------------------------------------------------------- /images/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanshield-sidepack/sveltekit-image-optimize/1048200380017b88aa99190386124e4a147c53da/images/image.png -------------------------------------------------------------------------------- /images/non-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanshield-sidepack/sveltekit-image-optimize/1048200380017b88aa99190386124e4a147c53da/images/non-animated.gif -------------------------------------------------------------------------------- /images/non-animated.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanshield-sidepack/sveltekit-image-optimize/1048200380017b88aa99190386124e4a147c53da/images/non-animated.webp -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sveltekit-image-optimize", 3 | "version": "0.0.10", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/humanshield-sidepack/sveltekit-image-optimize" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/humanshield-sidepack/sveltekit-image-optimize/issues" 11 | }, 12 | "homepage": "https://github.com/humanshield-sidepack/sveltekit-image-optimize#readme", 13 | "scripts": { 14 | "dev": "vite dev", 15 | "build": "vite build && npm run prepack", 16 | "preview": "vite preview", 17 | "prepare": "svelte-kit sync || echo ''", 18 | "prepack": "svelte-kit sync && svelte-package && publint", 19 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 20 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 21 | "format": "prettier --write .", 22 | "lint": "prettier --check . && eslint .", 23 | "test:unit": "vitest", 24 | "test": "npm run test:unit -- --run" 25 | }, 26 | "files": [ 27 | "dist", 28 | "!dist/**/*.test.*", 29 | "!dist/**/*.spec.*" 30 | ], 31 | "sideEffects": [ 32 | "**/*.css" 33 | ], 34 | "svelte": "./dist/index.js", 35 | "types": "./dist/index.d.ts", 36 | "type": "module", 37 | "exports": { 38 | ".": { 39 | "types": "./dist/index.d.ts", 40 | "svelte": "./dist/index.js" 41 | }, 42 | "./components": { 43 | "types": "./dist/components/index.d.ts", 44 | "svelte": "./dist/components/index.js" 45 | }, 46 | "./data": { 47 | "types": "./dist/data.d.ts", 48 | "svelte": "./dist/data.js" 49 | }, 50 | "./cache-adapters": { 51 | "types": "./dist/cache-adapters/index.d.ts", 52 | "svelte": "./dist/cache-adapters/index.js" 53 | }, 54 | "./client": { 55 | "types": "./dist/client.d.ts", 56 | "svelte": "./dist/client.js" 57 | } 58 | }, 59 | "peerDependencies": { 60 | "sharp": "^0.33.0 || ^0.34.0", 61 | "svelte": "^5.0.0", 62 | "@aws-sdk/client-s3": "^3.0.0" 63 | }, 64 | "devDependencies": { 65 | "@aws-sdk/client-s3": "^3.802.0", 66 | "@eslint/compat": "^1.2.9", 67 | "@eslint/js": "^9.26.0", 68 | "@sveltejs/adapter-auto": "^6.0.0", 69 | "@sveltejs/kit": "^2.20.8", 70 | "@sveltejs/package": "^2.3.11", 71 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 72 | "@testing-library/jest-dom": "^6.6.3", 73 | "@testing-library/svelte": "^5.2.7", 74 | "@types/node": "^22.15.3", 75 | "eslint": "^9.26.0", 76 | "eslint-config-prettier": "^10.1.2", 77 | "eslint-plugin-svelte": "^3.5.1", 78 | "globals": "^16.0.0", 79 | "jsdom": "^26.1.0", 80 | "prettier": "^3.5.3", 81 | "prettier-plugin-svelte": "^3.3.3", 82 | "publint": "^0.3.12", 83 | "sharp": "^0.34.1", 84 | "svelte": "^5.28.2", 85 | "svelte-check": "^4.1.7", 86 | "typescript": "^5.8.3", 87 | "typescript-eslint": "^8.31.1", 88 | "vite": "^6.3.4", 89 | "vitest": "^3.1.2" 90 | }, 91 | "keywords": [ 92 | "svelte" 93 | ], 94 | "pnpm": { 95 | "onlyBuiltDependencies": [ 96 | "esbuild" 97 | ] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/demo.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/hooks.client.ts: -------------------------------------------------------------------------------- 1 | import { initImageOptimizerClient } from '$lib/client.js'; 2 | import type { ClientInit } from '@sveltejs/kit'; 3 | 4 | export const init: ClientInit = () => { 5 | // this is only needed if you change the default route /api/image-op 6 | // IF you do not change that , you don;t have to initialize it on client side 7 | initImageOptimizerClient({ 8 | route: '/api/image-op-2' 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | import { 3 | S3_ACCESS_KEY_ID, 4 | S3_BUCKET, 5 | S3_ENDPOINT, 6 | S3_REGION, 7 | S3_SECRET_ACCESS_KEY 8 | } from '$env/static/private'; 9 | */ 10 | 11 | import { 12 | //createFileSystemCache, 13 | createMemoryCache 14 | //createS3Cache 15 | } from '$lib/cache-adapters/index.js'; 16 | import { createImageOptimizer } from '$lib/index.js'; 17 | // import path from 'path'; 18 | 19 | const imageOptimizerHandler = createImageOptimizer({ 20 | route: '/api/image-op-2', 21 | /* 22 | cache: createS3Cache({ 23 | region: S3_REGION, 24 | bucket: S3_BUCKET, 25 | forcePathStyle: false, 26 | accessKeyId: S3_ACCESS_KEY_ID, 27 | secretAccessKey: S3_SECRET_ACCESS_KEY, 28 | endpoint: S3_ENDPOINT, 29 | useRedirects: true 30 | }) 31 | */ 32 | /* 33 | cache: createFileSystemCache( 34 | path.join(process.cwd(), '.image-cache') 35 | ) 36 | */ 37 | cache: createMemoryCache() 38 | }); 39 | 40 | export const handle = imageOptimizerHandler; 41 | -------------------------------------------------------------------------------- /src/lib/cache-adapters/disck.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { ONE_YEAR_IN_SECONDS, type ImageCacheHandler } from '$lib/data.js'; 4 | 5 | function ensureDirectoryExists(dirPath: string): void { 6 | try { 7 | if (!fs.existsSync(dirPath)) { 8 | fs.mkdirSync(dirPath, { recursive: true }); 9 | } 10 | } catch (error) { 11 | console.error(`Error creating directory ${dirPath}:`, error); 12 | throw error; 13 | } 14 | } 15 | 16 | export class FileSystemCache implements ImageCacheHandler { 17 | useRedirects = false; 18 | private cachePath: string; 19 | 20 | constructor(cacheDir: string) { 21 | this.cachePath = path.resolve(cacheDir); 22 | 23 | ensureDirectoryExists(this.cachePath); 24 | } 25 | 26 | async getKey(hash: string): Promise<{ 27 | value: fs.ReadStream | null; 28 | ttl?: number; 29 | }> { 30 | const filePath = path.join(this.cachePath, hash); 31 | if (!fs.existsSync(filePath)) { 32 | return { 33 | value: null, 34 | ttl: ONE_YEAR_IN_SECONDS 35 | }; 36 | } 37 | try { 38 | const metadataPath = this.getMetadataPath(hash); 39 | 40 | let ttl = ONE_YEAR_IN_SECONDS; 41 | if (fs.existsSync(metadataPath)) { 42 | const metadata = JSON.parse(await fs.promises.readFile(metadataPath, 'utf8')); 43 | 44 | if (metadata.ttl && Date.now() > metadata.ttl) { 45 | await this.delKey(hash); 46 | 47 | return { 48 | value: null, 49 | ttl: ONE_YEAR_IN_SECONDS 50 | }; 51 | } 52 | ttl = Math.floor((metadata.ttl - Date.now()) / 1000); 53 | } 54 | return { 55 | value: fs.createReadStream(filePath), 56 | ttl 57 | }; 58 | } catch (error) { 59 | console.error(`Error creating read stream for ${filePath}:`, error); 60 | return { 61 | value: null, 62 | ttl: ONE_YEAR_IN_SECONDS 63 | }; 64 | } 65 | } 66 | 67 | async setKey( 68 | hash: string, 69 | value: NodeJS.ReadableStream | Buffer, 70 | ttl?: number, 71 | contentType?: string 72 | ): Promise { 73 | const filePath = path.join(this.cachePath, hash); 74 | 75 | if (Buffer.isBuffer(value)) { 76 | await fs.promises.writeFile(filePath, value); 77 | } else { 78 | const writeStream = fs.createWriteStream(filePath); 79 | await new Promise((resolve, reject) => { 80 | value.pipe(writeStream); 81 | value.on('error', reject); 82 | writeStream.on('finish', resolve); 83 | writeStream.on('error', reject); 84 | }); 85 | } 86 | 87 | if (ttl) { 88 | await fs.promises.writeFile( 89 | this.getMetadataPath(hash), 90 | JSON.stringify({ 91 | ttl: ttl * 1000 + Date.now(), 92 | contentType 93 | }) 94 | ); 95 | } 96 | } 97 | 98 | async delKey(hash: string): Promise { 99 | const filePath = path.join(this.cachePath, hash); 100 | try { 101 | await fs.promises.unlink(filePath); 102 | const metadataPath = this.getMetadataPath(hash); 103 | if (fs.existsSync(metadataPath)) { 104 | await fs.promises.unlink(metadataPath); 105 | } 106 | } catch { 107 | // Ignore if file doesn't exist 108 | } 109 | } 110 | 111 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 112 | async getPublicUrl(hash: string): Promise<{ 113 | url: string | null; 114 | ttl?: number; 115 | }> { 116 | // not supported and does not make sense for file system cache 117 | return { 118 | url: null, 119 | ttl: ONE_YEAR_IN_SECONDS 120 | }; 121 | } 122 | 123 | private getMetadataPath(hash: string): string { 124 | return path.join(this.cachePath, `${hash}.meta`); 125 | } 126 | } 127 | 128 | export default function createFileSystemCache(cacheDir: string): ImageCacheHandler { 129 | return new FileSystemCache(cacheDir); 130 | } 131 | -------------------------------------------------------------------------------- /src/lib/cache-adapters/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createFileSystemCache } from './disck.js'; 2 | export { FileSystemCache } from './disck.js'; 3 | 4 | export { default as createMemoryCache } from './memory.js'; 5 | export { MemoryCache } from './memory.js'; 6 | 7 | export { default as createS3Cache } from './s3.js'; 8 | export { S3Cache } from './s3.js'; 9 | 10 | export type { ImageCacheHandler } from '$lib/data.js'; 11 | -------------------------------------------------------------------------------- /src/lib/cache-adapters/memory.ts: -------------------------------------------------------------------------------- 1 | import { ONE_YEAR_IN_SECONDS, type ImageCacheHandler } from '$lib/data.js'; 2 | 3 | /** 4 | * In memory cache 5 | * THis is not production ready, there is no cleanup, no expiration, no eviction 6 | * This is only for development purposes 7 | */ 8 | export class MemoryCache implements ImageCacheHandler { 9 | useRedirects = false; 10 | private cache = new Map(); 11 | private timeouts = new Map(); 12 | private ttls = new Map(); 13 | 14 | private maxTTL = 60 * 60 * 1000; 15 | 16 | constructor(maxTTL?: number) { 17 | if (maxTTL) { 18 | // make sure it fits in a 32 bit integer (that is the max supported by timeout) 19 | this.maxTTL = Math.min(maxTTL, 2 ** 31 - 1); 20 | } 21 | } 22 | 23 | async getKey(hash: string): Promise<{ 24 | value: Buffer | null; 25 | ttl?: number; 26 | }> { 27 | const data = this.cache.get(hash); 28 | 29 | if (!data) 30 | return { 31 | value: null, 32 | ttl: ONE_YEAR_IN_SECONDS 33 | }; 34 | return { 35 | value: data, 36 | ttl: this.ttls.get(hash) 37 | ? Math.floor(((this.ttls.get(hash) as number) - Date.now()) / 1000) 38 | : undefined 39 | }; 40 | } 41 | 42 | async setKey( 43 | hash: string, 44 | value: NodeJS.ReadableStream | Buffer, 45 | ttl?: number, 46 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 47 | contentType?: string 48 | ): Promise { 49 | if (Buffer.isBuffer(value)) { 50 | this.cache.set(hash, value); 51 | } else { 52 | const chunks: Buffer[] = []; 53 | for await (const chunk of value) { 54 | chunks.push(Buffer.from(chunk)); 55 | } 56 | this.cache.set(hash, Buffer.concat(chunks)); 57 | } 58 | 59 | if (ttl) { 60 | this.ttls.set(hash, Date.now() + ttl * 1000); 61 | if (this.timeouts.has(hash)) { 62 | clearTimeout(this.timeouts.get(hash) as NodeJS.Timeout); 63 | } 64 | const timeout = setTimeout( 65 | () => { 66 | this.delKey(hash); 67 | }, 68 | Math.min(ttl * 1000, 60 * 60 * 1000) 69 | ); 70 | this.timeouts.set(hash, timeout); 71 | } 72 | } 73 | 74 | async delKey(hash: string): Promise { 75 | this.cache.delete(hash); 76 | } 77 | 78 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 79 | async getPublicUrl(hash: string): Promise<{ 80 | url: string | null; 81 | ttl?: number; 82 | }> { 83 | return { 84 | url: null, 85 | ttl: ONE_YEAR_IN_SECONDS 86 | }; 87 | } 88 | } 89 | 90 | export default function createMemoryCache(options: { maxTTL?: number } = {}): ImageCacheHandler { 91 | return new MemoryCache(options.maxTTL); 92 | } 93 | -------------------------------------------------------------------------------- /src/lib/cache-adapters/s3.ts: -------------------------------------------------------------------------------- 1 | import { ONE_YEAR_IN_SECONDS, type ImageCacheHandler } from '$lib/data.js'; 2 | import { _getCacheControlHeader } from '$lib/handlers/helpers.js'; 3 | /* 4 | We are avoiding importing any dependencies at the top level, so we do not have to add them to peer 5 | If a user wants to use this adapter, they will have to install the dependencies themselves. 6 | */ 7 | 8 | export interface S3CacheOptions { 9 | region: string; 10 | bucket: string; 11 | prefix?: string; 12 | accessKeyId?: string; 13 | secretAccessKey?: string; 14 | endpoint?: string; 15 | forcePathStyle?: boolean; 16 | useRedirects?: boolean; 17 | } 18 | 19 | export class S3Cache implements ImageCacheHandler { 20 | useRedirects = false; 21 | 22 | private client: import('@aws-sdk/client-s3').S3Client | undefined; 23 | private bucket: string; 24 | private prefix: string; 25 | private clientInitPromise: Promise; 26 | private isInitialized = false; 27 | private endpoint: string | undefined; 28 | private forcePathStyle: boolean | undefined; 29 | 30 | constructor(options: S3CacheOptions) { 31 | const { 32 | region, 33 | bucket, 34 | prefix = '', 35 | accessKeyId, 36 | secretAccessKey, 37 | endpoint, 38 | forcePathStyle 39 | } = options; 40 | 41 | this.bucket = bucket; 42 | this.prefix = prefix; 43 | this.endpoint = endpoint; 44 | this.forcePathStyle = forcePathStyle; 45 | 46 | // Initialize the client asynchronously 47 | this.clientInitPromise = this.initClient( 48 | region, 49 | accessKeyId, 50 | secretAccessKey, 51 | endpoint, 52 | forcePathStyle 53 | ); 54 | this.useRedirects = options.useRedirects ?? false; 55 | } 56 | 57 | async getKey(hash: string): Promise<{ 58 | value: NodeJS.ReadableStream | Buffer | null; 59 | ttl?: number; 60 | }> { 61 | try { 62 | // Ensure client is initialized 63 | await this.ensureClient(); 64 | 65 | const AWS = await import('@aws-sdk/client-s3').catch(() => { 66 | throw new Error( 67 | 'The @aws-sdk/client-s3 package is required but not installed. Please run: npm install @aws-sdk/client-s3' 68 | ); 69 | }); 70 | 71 | const command = new AWS.GetObjectCommand({ 72 | Bucket: this.bucket, 73 | Key: this.getObjectKey(hash) 74 | }); 75 | 76 | const response = await (this.client as import('@aws-sdk/client-s3').S3Client).send(command); 77 | 78 | if (!response.Body) { 79 | return { 80 | value: null, 81 | ttl: ONE_YEAR_IN_SECONDS 82 | }; 83 | } 84 | 85 | const expires = response.Metadata?.expires; 86 | if (expires && Date.now() > parseInt(expires)) { 87 | // we do not care if it fails this is cache 88 | // we could even not delete the file since re-setting it will override it (default s3 behavior) 89 | this.delKey(hash).catch(() => {}); 90 | return { 91 | value: null, 92 | ttl: ONE_YEAR_IN_SECONDS 93 | }; 94 | } 95 | 96 | const ttl = expires 97 | ? Math.floor((parseInt(expires) - Date.now()) / 1000) 98 | : ONE_YEAR_IN_SECONDS; 99 | 100 | return { 101 | value: response.Body as unknown as NodeJS.ReadableStream, 102 | ttl 103 | }; 104 | } catch (error) { 105 | // Handle case where object doesn't exist 106 | if ((error as Error).name === 'NoSuchKey') { 107 | return { 108 | value: null, 109 | ttl: ONE_YEAR_IN_SECONDS 110 | }; 111 | } 112 | console.error(`Error fetching object from S3:`, error); 113 | return { 114 | value: null, 115 | ttl: ONE_YEAR_IN_SECONDS 116 | }; 117 | } 118 | } 119 | 120 | async setKey( 121 | hash: string, 122 | value: NodeJS.ReadableStream | Buffer, 123 | ttl?: number, 124 | contentType?: string 125 | ): Promise { 126 | try { 127 | // Ensure client is initialized 128 | await this.ensureClient(); 129 | 130 | const AWS = await import('@aws-sdk/client-s3').catch(() => { 131 | throw new Error( 132 | 'The @aws-sdk/client-s3 package is required but not installed. Please run: npm install @aws-sdk/client-s3' 133 | ); 134 | }); 135 | 136 | const key = this.getObjectKey(hash); 137 | 138 | if (!Buffer.isBuffer(value)) { 139 | const chunks: Buffer[] = []; 140 | for await (const chunk of value) { 141 | chunks.push(Buffer.from(chunk)); 142 | } 143 | const bufferValue = Buffer.concat(chunks); 144 | 145 | const command = new AWS.PutObjectCommand({ 146 | Bucket: this.bucket, 147 | Key: key, 148 | Body: bufferValue, 149 | ContentType: contentType, 150 | CacheControl: _getCacheControlHeader(ttl ?? ONE_YEAR_IN_SECONDS), 151 | ACL: 'public-read', 152 | ...(ttl ? { Metadata: { expires: `${Date.now() + ttl * 1000}` } } : {}) 153 | }); 154 | 155 | await (this.client as import('@aws-sdk/client-s3').S3Client).send(command); 156 | } else { 157 | const command = new AWS.PutObjectCommand({ 158 | Bucket: this.bucket, 159 | Key: key, 160 | Body: value, 161 | ContentType: contentType, 162 | ACL: 'public-read', 163 | ...(ttl ? { Metadata: { expires: `${Date.now() + ttl * 1000}` } } : {}) 164 | }); 165 | await (this.client as import('@aws-sdk/client-s3').S3Client).send(command); 166 | } 167 | } catch (error) { 168 | console.error(`Error uploading to S3:`, error); 169 | throw error; 170 | } 171 | } 172 | 173 | async delKey(hash: string): Promise { 174 | try { 175 | await this.ensureClient(); 176 | 177 | const AWS = await import('@aws-sdk/client-s3').catch(() => { 178 | throw new Error( 179 | 'The @aws-sdk/client-s3 package is required but not installed. Please run: npm install @aws-sdk/client-s3' 180 | ); 181 | }); 182 | 183 | const command = new AWS.DeleteObjectCommand({ 184 | Bucket: this.bucket, 185 | Key: this.getObjectKey(hash) 186 | }); 187 | await (this.client as import('@aws-sdk/client-s3').S3Client).send(command); 188 | } catch (error) { 189 | console.error(`Error deleting from S3:`, error); 190 | // We'll ignore errors if the object doesn't exist 191 | } 192 | } 193 | 194 | async getPublicUrl(hash: string): Promise<{ 195 | url: string | null; 196 | ttl?: number; 197 | }> { 198 | const exists = await this.checkIfFileExists(hash); 199 | if (!exists.exists) { 200 | return { 201 | url: null, 202 | ttl: ONE_YEAR_IN_SECONDS 203 | }; 204 | } 205 | if (!this.endpoint) { 206 | return { 207 | url: null, 208 | ttl: ONE_YEAR_IN_SECONDS 209 | }; 210 | } 211 | return { 212 | url: getPublicUrl(this.endpoint, this.bucket, this.getObjectKey(hash), this.forcePathStyle), 213 | ttl: exists.ttl 214 | }; 215 | } 216 | 217 | private async checkIfFileExists(hash: string): Promise<{ 218 | exists: boolean; 219 | ttl?: number; 220 | }> { 221 | await this.ensureClient(); 222 | 223 | const AWS = await import('@aws-sdk/client-s3').catch(() => { 224 | throw new Error( 225 | 'The @aws-sdk/client-s3 package is required but not installed. Please run: npm install @aws-sdk/client-s3' 226 | ); 227 | }); 228 | 229 | const command = new AWS.GetObjectCommand({ 230 | Bucket: this.bucket, 231 | Key: this.getObjectKey(hash) 232 | }); 233 | try { 234 | const response = await (this.client as import('@aws-sdk/client-s3').S3Client).send(command); 235 | 236 | if (!response.Body) 237 | return { 238 | exists: false, 239 | ttl: ONE_YEAR_IN_SECONDS 240 | }; 241 | const expires = response.Metadata?.expires; 242 | if (expires && Date.now() > parseInt(expires)) { 243 | this.delKey(hash).catch(() => {}); 244 | return { 245 | exists: false, 246 | ttl: ONE_YEAR_IN_SECONDS 247 | }; 248 | } 249 | return { 250 | exists: true, 251 | ttl: expires ? Math.floor((parseInt(expires) - Date.now()) / 1000) : ONE_YEAR_IN_SECONDS 252 | }; 253 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 254 | } catch (error) { 255 | return { 256 | exists: false, 257 | ttl: ONE_YEAR_IN_SECONDS 258 | }; 259 | } 260 | } 261 | 262 | private async initClient( 263 | region: string, 264 | accessKeyId?: string, 265 | secretAccessKey?: string, 266 | endpoint?: string, 267 | forcePathStyle?: boolean 268 | ): Promise { 269 | if (this.isInitialized) return; 270 | 271 | try { 272 | const AWS = await import('@aws-sdk/client-s3').catch(() => { 273 | throw new Error( 274 | 'The @aws-sdk/client-s3 package is required but not installed. Please run: npm install @aws-sdk/client-s3' 275 | ); 276 | }); 277 | 278 | const clientOptions: Record = { region }; 279 | 280 | if (accessKeyId && secretAccessKey) { 281 | clientOptions.credentials = { 282 | accessKeyId, 283 | secretAccessKey 284 | }; 285 | } 286 | 287 | if (endpoint) { 288 | clientOptions.endpoint = endpoint; 289 | } 290 | 291 | if (forcePathStyle !== undefined) { 292 | clientOptions.forcePathStyle = forcePathStyle; 293 | } 294 | 295 | this.client = new AWS.S3Client(clientOptions); 296 | this.isInitialized = true; 297 | } catch (error) { 298 | this.isInitialized = false; 299 | console.error('Failed to initialize S3 client:', error); 300 | throw error; 301 | } 302 | } 303 | 304 | private getObjectKey(hash: string): string { 305 | return this.prefix ? `${this.prefix}/${hash}` : hash; 306 | } 307 | 308 | private async ensureClient(): Promise { 309 | if (!this.isInitialized) { 310 | await this.clientInitPromise.catch((error) => { 311 | throw new Error(`S3 client initialization failed: ${error.message}`); 312 | }); 313 | 314 | if (!this.isInitialized) { 315 | throw new Error( 316 | 'S3 client not initialized. Make sure you have installed @aws-sdk/client-s3 package.' 317 | ); 318 | } 319 | } 320 | } 321 | } 322 | 323 | /** 324 | * Creates an S3 cache adapter for image optimization 325 | * Note: You must install @aws-sdk/client-s3 package to use this adapter 326 | * 327 | * @example 328 | * ```ts 329 | * import { createS3Cache } from 'sveltekit-image-optimize/cache-adapters/s3'; 330 | * 331 | * const cache = createS3Cache({ 332 | * region: 'us-east-1', 333 | * bucket: 'my-image-cache', 334 | * prefix: 'images', // optional 335 | * accessKeyId: 'YOUR_ACCESS_KEY', // optional, can use AWS environment variables 336 | * secretAccessKey: 'YOUR_SECRET_KEY', // optional, can use AWS environment variables 337 | * endpoint: 'https://custom-endpoint.com', // optional, for S3-compatible services 338 | * forcePathStyle: true // optional, for S3-compatible services 339 | * }); 340 | * ``` 341 | */ 342 | export default function createS3Cache(options: S3CacheOptions): ImageCacheHandler { 343 | return new S3Cache(options); 344 | } 345 | 346 | function getPublicUrl( 347 | endpoint: string, 348 | bucket: string, 349 | key: string, 350 | usePathStyle: boolean = false 351 | ): string { 352 | const normalizedEndpoint = endpoint.replace(/\/+$/, ''); // removes trailing slashes 353 | if (usePathStyle) { 354 | // https://endpoint/bucket/key 355 | return `${normalizedEndpoint}/${bucket}/${encodeURIComponent(key)}`; 356 | } else { 357 | // https://bucket.endpoint/key 358 | const url = new URL(normalizedEndpoint); 359 | return `${url.protocol}//${bucket}.${url.host}/${encodeURIComponent(key)}`; 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /src/lib/client.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_ROUTE } from './data.js'; 2 | 3 | export const imageOptimizerClientOptions = { 4 | _isInitialized: false, 5 | _route: DEFAULT_ROUTE, 6 | get route() { 7 | return this._route; 8 | }, 9 | set route(value) { 10 | // only setup once to prevent accidental override 11 | if (!this._isInitialized) { 12 | this._route = value; 13 | this._isInitialized = true; 14 | } 15 | } 16 | }; 17 | 18 | export function initImageOptimizerClient( 19 | options: { 20 | route?: string; 21 | } = {} 22 | ) { 23 | imageOptimizerClientOptions.route = options.route ?? DEFAULT_ROUTE; 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/components/Image.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {#if optimizeOptions?.preload === 'prefetch' || optimizeOptions?.preload === 'preload'} 15 | 20 | {/if} 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/lib/components/Picture.svelte: -------------------------------------------------------------------------------- 1 | 78 | 79 | 80 | {#each allSources as source (source.media)} 81 | 82 | {/each} 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/lib/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Image } from './Image.svelte'; 2 | export { default as Picture } from './Picture.svelte'; 3 | export * from './utils.js'; 4 | -------------------------------------------------------------------------------- /src/lib/components/utils.ts: -------------------------------------------------------------------------------- 1 | import { imageOptimizerClientOptions } from '$lib/client.js'; 2 | import type { AcceptedImageOutputFormats } from '$lib/data.js'; 3 | 4 | export type PictureOptimizeProps = { 5 | width?: number; 6 | height?: number; 7 | quality?: number; 8 | position?: string; 9 | fit?: string; 10 | background?: string; 11 | customName?: string; 12 | }; 13 | 14 | export type ImageOptimizeProps = PictureOptimizeProps & { 15 | preload?: 'prefetch' | 'preload'; 16 | format?: AcceptedImageOutputFormats; 17 | }; 18 | 19 | export function toOptimizedURL( 20 | url: string, 21 | optimizeOptions: PictureOptimizeProps & { format?: AcceptedImageOutputFormats } = {} 22 | ) { 23 | const query = new URLSearchParams(); 24 | query.set('url', url); 25 | if (optimizeOptions.width) query.set('width', optimizeOptions.width.toString()); 26 | if (optimizeOptions.height) query.set('height', optimizeOptions.height.toString()); 27 | if (optimizeOptions.quality) query.set('quality', optimizeOptions.quality.toString()); 28 | if (optimizeOptions.position) query.set('position', optimizeOptions.position); 29 | if (optimizeOptions.fit) query.set('fit', optimizeOptions.fit); 30 | if (optimizeOptions.background) query.set('background', optimizeOptions.background); 31 | if (optimizeOptions.format) query.set('format', optimizeOptions.format); 32 | return `${imageOptimizerClientOptions.route}/${extractFileNameFromURL(url, optimizeOptions.customName)}?${query.toString()}`; 33 | } 34 | 35 | function extractFileNameFromURL(url: string, customName?: string): string { 36 | if (customName && isValidFileName(customName)) { 37 | return customName; 38 | } 39 | 40 | try { 41 | const pathSegments = url.split('/').filter(Boolean); 42 | const lastSegment = pathSegments.pop(); 43 | 44 | const filenameWithoutQuery = lastSegment?.split('?')[0]; 45 | 46 | if (filenameWithoutQuery && /\.[a-zA-Z0-9]+$/.test(filenameWithoutQuery)) { 47 | return filenameWithoutQuery; 48 | } 49 | 50 | return slugifyURL(url); 51 | } catch { 52 | return slugifyURL(url); 53 | } 54 | } 55 | 56 | function isValidFileName(filename?: string): boolean { 57 | return !filename || (filename.length > 0 && filename.length <= 100); 58 | } 59 | 60 | /** 61 | * Creates a slug from a URL that can be used as a filename (this is only used if our other attempts fail) 62 | */ 63 | function slugifyURL(url: string): string { 64 | const withoutProtocol = url.replace(/^(https?:\/\/)?(www\.)?/, ''); 65 | 66 | return ( 67 | withoutProtocol 68 | .replace(/[^a-zA-Z0-9]/g, '-') 69 | .replace(/-+/g, '-') 70 | .replace(/^-|-$/g, '') 71 | .toLowerCase() 72 | .substring(0, 100) || 'file' 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/data.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_ROUTE = '/api/image-op'; 2 | 3 | export const ONE_YEAR_IN_SECONDS = 31536000; 4 | /** 5 | * @description Options for the image optimizer 6 | * @param route - The route to handle image optimization requests 7 | * @param formatPriority - The order of formats to try when optimizing images defaults to avif, webp, jpeg, jpg, png, gif 8 | * @param fallbackFormat - If the the format query is not specified, and the accept header did not specify a format, this is the format that will be used. Defaults to jpeg 9 | * @param quality - The quality of the image. Defaults to 75 10 | * @param cache - Optional cache handler for storing processed images 11 | * @param cacheTTLStrategy - The TTL for the cache. defaults to same as source. valid values are number of seconds, 'source', 'immutable' 12 | * @param allowedDomains - The domains that are allowed to be optimized. Defaults to all domains. (this could be useful to avoid DDOS attacks) 13 | * @param minSizeThreshold - The minimum size of the image to be optimized. Defaults to 5kb. 14 | * @param skipFormats - The formats that will not be optimized, this only applies to images without a resize query. Defaults to avif, webp 15 | */ 16 | export type Options = { 17 | route: string; 18 | formatPriority: Array; 19 | fallbackFormat: string; 20 | quality?: number; 21 | cache?: ImageCacheHandler; 22 | cacheTTLStrategy?: number | 'source' | 'immutable' | 'no-cache'; 23 | allowedDomains?: Array | ApproveDomainFunction; 24 | minSizeThreshold?: number; 25 | skipFormats?: Array; 26 | }; 27 | 28 | export type ApproveDomainFunction = (url: string) => boolean; 29 | 30 | export enum CacheTTLStrategy { 31 | source = 'source', 32 | immutable = 'immutable', 33 | noCache = 'no-cache' 34 | } 35 | 36 | export enum FitEnum { 37 | cover = 'cover', 38 | contain = 'contain', 39 | fill = 'fill', 40 | inside = 'inside', 41 | outside = 'outside' 42 | } 43 | 44 | export enum AcceptedImageOutputFormats { 45 | avif = 'avif', 46 | webp = 'webp', 47 | jpeg = 'jpeg', 48 | jpg = 'jpg', 49 | png = 'png', 50 | gif = 'gif', 51 | ico = 'ico', 52 | svg = 'svg' 53 | } 54 | 55 | export const DEFAULT_FORMAT_PRIORITIES = [ 56 | AcceptedImageOutputFormats.avif, 57 | AcceptedImageOutputFormats.webp, 58 | AcceptedImageOutputFormats.jpeg, 59 | AcceptedImageOutputFormats.jpg, 60 | AcceptedImageOutputFormats.png, 61 | AcceptedImageOutputFormats.gif 62 | ]; 63 | 64 | export const DEFAULT_SKIP_FORMATS = [ 65 | AcceptedImageOutputFormats.avif, 66 | AcceptedImageOutputFormats.webp 67 | ]; 68 | 69 | export const DEFAULT_MIN_SIZE_THRESHOLD = 5 * 2 ** 10; 70 | 71 | export const DEFAULT_QUALITY = 75; 72 | 73 | export const DEFAULT_QUALITY_PER_FORMAT = { 74 | [AcceptedImageOutputFormats.avif]: 50, 75 | [AcceptedImageOutputFormats.webp]: 80, 76 | [AcceptedImageOutputFormats.jpeg]: 80, 77 | [AcceptedImageOutputFormats.jpg]: 80, 78 | [AcceptedImageOutputFormats.png]: 100 79 | }; 80 | 81 | /** 82 | * Interface for handling image cache operations. 83 | */ 84 | export interface ImageCacheHandler { 85 | /** 86 | * Indicates whether redirects should be used. 87 | */ 88 | useRedirects: boolean; 89 | 90 | /** 91 | * Retrieves the cached data associated with the given hash. 92 | * @param hash - The unique identifier for the cached data. 93 | * @returns A promise that resolves to a readable stream, buffer, or null if the key does not exist. 94 | */ 95 | getKey(hash: string): Promise<{ 96 | value: NodeJS.ReadableStream | Buffer | null; 97 | ttl?: number; 98 | }>; 99 | 100 | /** 101 | * Stores data in the cache with the specified hash. 102 | * @param hash - The unique identifier for the data to be cached. 103 | * @param value - The data to be cached, which can be a readable stream, buffer, or readable stream. 104 | * @param ttl - Optional time to live for the cached data (in seconds). 105 | * @param contentType - Optional MIME type of the data being cached. 106 | * @returns A promise that resolves when the data has been successfully cached. 107 | */ 108 | setKey( 109 | hash: string, 110 | value: NodeJS.ReadableStream | Buffer | ReadableStream, 111 | ttl?: number, 112 | contentType?: string 113 | ): Promise; 114 | 115 | /** 116 | * Deletes the cached data associated with the given hash. 117 | * @param hash - The unique identifier for the cached data to be deleted. 118 | * @returns A promise that resolves when the data has been successfully deleted. 119 | */ 120 | delKey(hash: string): Promise; 121 | 122 | /** 123 | * Retrieves the public URL for the cached data associated with the given hash. 124 | * This is useful incase of using cdns for caching, the sveltekit endpoint will not pipe 125 | * the image stream, instead it will return a permanent redirect to the cached file. 126 | * @param hash - The unique identifier for the cached data. 127 | * @returns A promise that resolves to the public URL as a string, or null if the URL cannot be generated. 128 | */ 129 | getPublicUrl(hash: string): Promise<{ 130 | url: string | null; 131 | ttl?: number; 132 | }>; 133 | } 134 | 135 | export const DEFAULT_CACHE_TTL_STRATEGY = CacheTTLStrategy.source; 136 | -------------------------------------------------------------------------------- /src/lib/handlers/helpers.ts: -------------------------------------------------------------------------------- 1 | import { imageOptimizerClientOptions } from '$lib/client.js'; 2 | import { 3 | AcceptedImageOutputFormats, 4 | CacheTTLStrategy, 5 | DEFAULT_FORMAT_PRIORITIES, 6 | DEFAULT_MIN_SIZE_THRESHOLD, 7 | DEFAULT_QUALITY, 8 | DEFAULT_QUALITY_PER_FORMAT, 9 | DEFAULT_ROUTE, 10 | DEFAULT_SKIP_FORMATS, 11 | FitEnum, 12 | ONE_YEAR_IN_SECONDS, 13 | type ApproveDomainFunction, 14 | type Options 15 | } from '$lib/data.js'; 16 | import type { RequestEvent } from '@sveltejs/kit'; 17 | import { createHash } from 'crypto'; 18 | import { PassThrough } from 'stream'; 19 | 20 | function _getOutputFormat(acceptHeader = '', options: Options) { 21 | for (const format of options.formatPriority) { 22 | if (acceptHeader.includes(`image/${format}`)) { 23 | return format; 24 | } 25 | } 26 | return options.fallbackFormat; 27 | } 28 | 29 | export function _extractRequiredParams(event: RequestEvent, options: Options) { 30 | // event.url.search and event.url.searchParams can't be used while prerender 31 | const searchParams = new URL(event.request.url).searchParams; 32 | // if no width pr height was given then assume this was an optimize only request 33 | // TODO: need to revisit this if we add other filters and basic image manipulations 34 | const hasResize = searchParams.get('width') || searchParams.get('height'); 35 | const format = _extractFormatFromQueryParam( 36 | searchParams.get('format'), 37 | event.request.headers.get('accept') || '', 38 | options 39 | ); 40 | 41 | return { 42 | url: searchParams.get('url'), 43 | width: searchParams.get('width'), 44 | height: searchParams.get('height'), 45 | format: format, 46 | quality: _extractQualityFromQueryParam(searchParams.get('quality'), options, format), 47 | fit: _extractFitFromQueryParam(searchParams.get('fit')), 48 | position: searchParams.get('position') || 'center', 49 | background: _extractBackgroundColor(searchParams.get('background')), 50 | hasResize 51 | }; 52 | } 53 | 54 | function _extractFitFromQueryParam(fit: string | null): FitEnum { 55 | return FitEnum[fit as keyof typeof FitEnum] || FitEnum.cover; 56 | } 57 | 58 | function _extractFormatFromQueryParam( 59 | format: string | null, 60 | acceptHeaders: string, 61 | options: Options 62 | ): AcceptedImageOutputFormats { 63 | return ( 64 | AcceptedImageOutputFormats[format as keyof typeof AcceptedImageOutputFormats] || 65 | _getOutputFormat(acceptHeaders, options) 66 | ); 67 | } 68 | 69 | const UNIVERSALLY_SUPPORTED_FORMATS = [ 70 | AcceptedImageOutputFormats.png, 71 | AcceptedImageOutputFormats.jpeg, 72 | AcceptedImageOutputFormats.jpg, 73 | AcceptedImageOutputFormats.gif, 74 | AcceptedImageOutputFormats.ico, 75 | AcceptedImageOutputFormats.svg 76 | ]; 77 | 78 | export function _shouldSkip( 79 | sourceFormat: string, 80 | options: Options, 81 | acceptHeaders: string 82 | ): boolean { 83 | const accepted = 84 | acceptHeaders.includes(`image/${sourceFormat}`) || 85 | UNIVERSALLY_SUPPORTED_FORMATS.includes(sourceFormat as AcceptedImageOutputFormats); 86 | return ( 87 | accepted && 88 | !!options.skipFormats && 89 | options.skipFormats.includes(sourceFormat as AcceptedImageOutputFormats) 90 | ); 91 | } 92 | 93 | function _extractQualityFromQueryParam( 94 | quality: string | null, 95 | options: Options, 96 | format: AcceptedImageOutputFormats 97 | ): number { 98 | const formatQuality = 99 | DEFAULT_QUALITY_PER_FORMAT[format as keyof typeof DEFAULT_QUALITY_PER_FORMAT]; 100 | 101 | return quality && !isNaN(parseInt(quality)) 102 | ? parseInt(quality) 103 | : formatQuality || options.quality || DEFAULT_QUALITY; 104 | } 105 | 106 | export function _createHashString(str: string) { 107 | const hash = createHash('sha256'); 108 | hash.update(str); 109 | return hash.digest('base64url'); 110 | } 111 | 112 | const DEFAULT_PEEK_BYTES = 100 * 1024; // 100KB (somewhat safe, maybe even 50 is safe but we can't be sure) 113 | 114 | export async function _peekStream( 115 | stream: ReadableStreamDefaultReader>, 116 | byteCount: number = DEFAULT_PEEK_BYTES 117 | ) { 118 | const chunks: Uint8Array[] = []; 119 | let total = 0; 120 | 121 | while (total < byteCount) { 122 | const { done, value } = await stream.read(); 123 | if (done) break; 124 | chunks.push(value); 125 | total += value.length; 126 | } 127 | 128 | const peekedBuffer = Buffer.concat(chunks, total); 129 | return { peekedBuffer, remainingStream: stream }; 130 | } 131 | 132 | export function _finishRemainingStream( 133 | remainingStream: ReadableStreamDefaultReader>, 134 | peekedBuffer: Buffer 135 | ) { 136 | const passthrough = new PassThrough(); 137 | passthrough.write(peekedBuffer); 138 | 139 | (async () => { 140 | try { 141 | while (true) { 142 | const { done, value } = await remainingStream.read(); 143 | if (done) { 144 | passthrough.end(); 145 | break; 146 | } 147 | passthrough.write(value); 148 | } 149 | } catch (error) { 150 | passthrough.end(); 151 | console.error('Error reading stream:', error); 152 | } 153 | })(); 154 | 155 | return passthrough; 156 | } 157 | 158 | export function _createStreamResponse(stream: NodeJS.ReadableStream, headers: HeadersInit) { 159 | const responseStream = new ReadableStream({ 160 | start(controller) { 161 | stream.on('data', (chunk) => { 162 | controller.enqueue(chunk); 163 | }); 164 | stream.on('end', () => { 165 | controller.close(); 166 | }); 167 | stream.on('error', (err) => { 168 | console.error('Stream error:', err); 169 | controller.error(err); 170 | }); 171 | }, 172 | cancel() { 173 | if ('destroy' in stream && typeof stream.destroy === 'function') { 174 | stream.destroy(); 175 | } 176 | } 177 | }); 178 | 179 | return new Response(responseStream, { headers }); 180 | } 181 | 182 | export function _validateUrl(url: string | null): string | undefined { 183 | if (_isRelativeURL(url)) return; 184 | if (!url) return 'URL is required'; 185 | try { 186 | new URL(url); 187 | return; 188 | } catch { 189 | return 'Invalid URL'; 190 | } 191 | } 192 | 193 | export function _isAllowedDomain( 194 | url: string, 195 | allowedDomains: Array | ApproveDomainFunction | undefined 196 | ) { 197 | if (!allowedDomains || allowedDomains.length === 0 || _isRelativeURL(url)) return true; 198 | if (Array.isArray(allowedDomains)) { 199 | return allowedDomains.some((domain) => { 200 | if (typeof domain === 'string') return url.includes(domain); 201 | return domain.test(url); 202 | }); 203 | } else if (typeof allowedDomains === 'function') { 204 | return allowedDomains(url); 205 | } 206 | return false; 207 | } 208 | 209 | export function _isRelativeURL(url: string | null) { 210 | if (!url) return false; 211 | // if there is no protocol 'aka relative url' 212 | if (!url.includes('://')) { 213 | return true; 214 | } 215 | 216 | return false; 217 | } 218 | 219 | const CSS_COLOR_NAMES = [ 220 | 'aliceblue', 221 | 'antiquewhite', 222 | 'aqua', 223 | 'aquamarine', 224 | 'azure', 225 | 'beige', 226 | 'bisque', 227 | 'black', 228 | 'blanchedalmond', 229 | 'blue', 230 | 'blueviolet', 231 | 'brown', 232 | 'burlywood', 233 | 'cadetblue', 234 | 'chartreuse', 235 | 'chocolate', 236 | 'coral', 237 | 'cornflowerblue', 238 | 'cornsilk', 239 | 'crimson', 240 | 'cyan', 241 | 'darkblue', 242 | 'darkcyan', 243 | 'darkgoldenrod', 244 | 'darkgray', 245 | 'darkgreen', 246 | 'darkgrey', 247 | 'darkkhaki', 248 | 'darkmagenta', 249 | 'darkolivegreen', 250 | 'darkorange', 251 | 'darkorchid', 252 | 'darkred', 253 | 'darksalmon', 254 | 'darkseagreen', 255 | 'darkslateblue', 256 | 'darkslategray', 257 | 'darkslategrey', 258 | 'darkturquoise', 259 | 'darkviolet', 260 | 'deeppink', 261 | 'deepskyblue', 262 | 'dimgray', 263 | 'dimgrey', 264 | 'dodgerblue', 265 | 'firebrick', 266 | 'floralwhite', 267 | 'forestgreen', 268 | 'fuchsia', 269 | 'gainsboro', 270 | 'ghostwhite', 271 | 'gold', 272 | 'goldenrod', 273 | 'gray', 274 | 'green', 275 | 'greenyellow', 276 | 'grey', 277 | 'honeydew', 278 | 'hotpink', 279 | 'indianred', 280 | 'indigo', 281 | 'ivory', 282 | 'khaki', 283 | 'lavender', 284 | 'lavenderblush', 285 | 'lawngreen', 286 | 'lemonchiffon', 287 | 'lightblue', 288 | 'lightcoral', 289 | 'lightcyan', 290 | 'lightgoldenrodyellow', 291 | 'lightgray', 292 | 'lightgreen', 293 | 'lightgrey', 294 | 'lightpink', 295 | 'lightsalmon', 296 | 'lightseagreen', 297 | 'lightskyblue', 298 | 'lightslategray', 299 | 'lightslategrey', 300 | 'lightsteelblue', 301 | 'lightyellow', 302 | 'lime', 303 | 'limegreen', 304 | 'linen', 305 | 'magenta', 306 | 'maroon', 307 | 'mediumaquamarine', 308 | 'mediumblue', 309 | 'mediumorchid', 310 | 'mediumpurple', 311 | 'mediumseagreen', 312 | 'mediumslateblue', 313 | 'mediumspringgreen', 314 | 'mediumturquoise', 315 | 'mediumvioletred', 316 | 'midnightblue', 317 | 'mintcream', 318 | 'mistyrose', 319 | 'moccasin', 320 | 'navajowhite', 321 | 'navy', 322 | 'oldlace', 323 | 'olive', 324 | 'olivedrab', 325 | 'orange', 326 | 'orangered', 327 | 'orchid', 328 | 'palegoldenrod', 329 | 'palegreen', 330 | 'paleturquoise', 331 | 'palevioletred', 332 | 'papayawhip', 333 | 'peachpuff', 334 | 'peru', 335 | 'pink', 336 | 'plum', 337 | 'powderblue', 338 | 'purple', 339 | 'rebeccapurple', 340 | 'red', 341 | 'rosybrown', 342 | 'royalblue', 343 | 'saddlebrown', 344 | 'salmon', 345 | 'sandybrown', 346 | 'seagreen', 347 | 'seashell', 348 | 'sienna', 349 | 'silver', 350 | 'skyblue', 351 | 'slateblue', 352 | 'slategray', 353 | 'slategrey', 354 | 'snow', 355 | 'springgreen', 356 | 'steelblue', 357 | 'tan', 358 | 'teal', 359 | 'thistle', 360 | 'tomato', 361 | 'turquoise', 362 | 'violet', 363 | 'wheat', 364 | 'white', 365 | 'whitesmoke', 366 | 'yellow', 367 | 'yellowgreen' 368 | ]; 369 | 370 | function _isValidBackgroundColor(color: string | null): boolean { 371 | if (!color) return false; 372 | if (color === 'transparent') return true; 373 | const hex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/; 374 | const rgb = /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/; 375 | const rgba = /^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0|1|0?\.\d+)\s*\)$/; 376 | const named = CSS_COLOR_NAMES.includes(color.toLowerCase()); 377 | return hex.test(color) || rgb.test(color) || rgba.test(color) || named; 378 | } 379 | 380 | export function _extractBackgroundColor(color: string | null): string | undefined { 381 | if (!color) return undefined; 382 | 383 | if (color === 'transparent') return '#00000000'; 384 | if (_isValidBackgroundColor(color)) return color; 385 | return undefined; 386 | } 387 | 388 | export function _extractTTLFromCacheControlHeader(cacheControlHeader: string) { 389 | const regex = /max-age=(\d+)/; 390 | const match = cacheControlHeader.match(regex); 391 | return match ? parseInt(match[1]) : ONE_YEAR_IN_SECONDS; 392 | } 393 | 394 | export function _getCacheControlHeader(ttl: number) { 395 | if (ttl === ONE_YEAR_IN_SECONDS) { 396 | return `public, max-age=${ONE_YEAR_IN_SECONDS}, immutable`; 397 | } 398 | return `public, max-age=${ttl}`; 399 | } 400 | 401 | export function _validateAndGetOptions(options: Partial = {}) { 402 | imageOptimizerClientOptions.route = options.route ?? DEFAULT_ROUTE; 403 | 404 | if ( 405 | options.cacheTTLStrategy && 406 | typeof options.cacheTTLStrategy !== 'number' && 407 | !Object.values(CacheTTLStrategy).includes(options.cacheTTLStrategy as CacheTTLStrategy) 408 | ) { 409 | throw new Error( 410 | `[svelte-image-optimizer] Invalid cacheTTLStrategy: ${options.cacheTTLStrategy}` 411 | ); 412 | } 413 | 414 | return Object.freeze({ 415 | route: imageOptimizerClientOptions.route, 416 | formatPriority: options.formatPriority ?? DEFAULT_FORMAT_PRIORITIES, 417 | fallbackFormat: options.fallbackFormat ?? AcceptedImageOutputFormats.jpeg, 418 | quality: options.quality, 419 | cache: options.cache, 420 | cacheTTLStrategy: options.cacheTTLStrategy ?? 'source', 421 | allowedDomains: options.allowedDomains ?? [], 422 | minSizeThreshold: options.minSizeThreshold ?? DEFAULT_MIN_SIZE_THRESHOLD, 423 | skipFormats: options.skipFormats ?? DEFAULT_SKIP_FORMATS 424 | }); 425 | } 426 | -------------------------------------------------------------------------------- /src/lib/handlers/image.utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { isGifAnimated, isWebPAnimated } from './images.utils.js'; 3 | import { readFileSync } from 'fs'; 4 | import { join } from 'path'; 5 | 6 | describe('Image Animation Detection', () => { 7 | const testImagesDir = join(process.cwd(), 'images'); 8 | const MAX_BUFFER_SIZE = 10 * 1024; // 10KB 9 | 10 | function readPartialFile(filePath: string): Buffer { 11 | const buffer = readFileSync(filePath); 12 | return buffer.subarray(0, Math.min(buffer.length, MAX_BUFFER_SIZE)); 13 | } 14 | 15 | describe('GIF Animation Detection', () => { 16 | it('should detect animated GIF with full file read', () => { 17 | const animatedGif = readFileSync(join(testImagesDir, 'animated.gif')); 18 | expect(isGifAnimated(animatedGif)).toBe(true); 19 | }); 20 | 21 | it('should detect animated GIF with partial file read', () => { 22 | const animatedGif = readPartialFile(join(testImagesDir, 'animated.gif')); 23 | expect(isGifAnimated(animatedGif)).toBe(true); 24 | }); 25 | 26 | it('should detect non-animated GIF with full file read', () => { 27 | const nonAnimatedGif = readFileSync(join(testImagesDir, 'non-animated.gif')); 28 | expect(isGifAnimated(nonAnimatedGif)).toBe(false); 29 | }); 30 | 31 | it('should detect non-animated GIF with partial file read', () => { 32 | const nonAnimatedGif = readPartialFile(join(testImagesDir, 'non-animated.gif')); 33 | expect(isGifAnimated(nonAnimatedGif)).toBe(false); 34 | }); 35 | }); 36 | 37 | describe('WebP Animation Detection', () => { 38 | it('should detect animated WebP with full file read', () => { 39 | const animatedWebP = readFileSync(join(testImagesDir, 'animated.webp')); 40 | expect(isWebPAnimated(animatedWebP)).toBe(true); 41 | }); 42 | 43 | it('should detect animated WebP with partial file read', () => { 44 | const animatedWebP = readPartialFile(join(testImagesDir, 'animated.webp')); 45 | expect(isWebPAnimated(animatedWebP)).toBe(true); 46 | }); 47 | 48 | it('should detect non-animated WebP with full file read', () => { 49 | const nonAnimatedWebP = readFileSync(join(testImagesDir, 'non-animated.webp')); 50 | expect(isWebPAnimated(nonAnimatedWebP)).toBe(false); 51 | }); 52 | 53 | it('should detect non-animated WebP with partial file read', () => { 54 | const nonAnimatedWebP = readPartialFile(join(testImagesDir, 'non-animated.webp')); 55 | expect(isWebPAnimated(nonAnimatedWebP)).toBe(false); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/lib/handlers/imageHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Handle, RequestEvent } from '@sveltejs/kit'; 2 | import sharp from 'sharp'; 3 | import { URL } from 'url'; 4 | import { PassThrough } from 'stream'; 5 | import { detectContentType, isImageAnimated } from './images.utils.js'; 6 | import { CacheTTLStrategy, FitEnum, ONE_YEAR_IN_SECONDS, type Options } from '$lib/data.js'; 7 | import { 8 | _extractRequiredParams, 9 | _validateUrl, 10 | _createHashString, 11 | _createStreamResponse, 12 | _peekStream, 13 | _finishRemainingStream, 14 | _extractTTLFromCacheControlHeader, 15 | _getCacheControlHeader, 16 | _isAllowedDomain, 17 | _isRelativeURL, 18 | _shouldSkip, 19 | _validateAndGetOptions 20 | } from './helpers.js'; 21 | 22 | const _createImageOptimizerHooksHandler = (options: Options) => { 23 | return async function ({ event, resolve }) { 24 | const { url } = event; 25 | 26 | if (url.pathname.startsWith(options.route) && event.request.method === 'GET') { 27 | return imageTransformHandler(event, options); 28 | } else if (url.pathname === options.route && event.request.method === 'OPTIONS') { 29 | /* do not allow cors for now 30 | TODO: add cors to options 31 | */ 32 | return new Response(null, { 33 | status: 403 34 | }); 35 | } 36 | 37 | return resolve(event); 38 | } as Handle; 39 | }; 40 | 41 | const _createImageOptimizerRouteHandler = (options: Options) => { 42 | return async function (event: RequestEvent) { 43 | return imageTransformHandler(event, options); 44 | }; 45 | }; 46 | 47 | async function imageTransformHandler(event: RequestEvent, options: Options) { 48 | const { 49 | width, 50 | height, 51 | format, 52 | quality, 53 | fit, 54 | position, 55 | url: requestedUrl, 56 | background, 57 | hasResize 58 | } = _extractRequiredParams(event, options); 59 | 60 | let url = requestedUrl; 61 | 62 | const urlError = _validateUrl(url); 63 | if (urlError) return new Response(urlError, { status: 400 }); 64 | 65 | if (!_isAllowedDomain(url as string, options.allowedDomains)) { 66 | return new Response('Not allowed', { status: 403 }); 67 | } 68 | 69 | if (_isRelativeURL(url)) { 70 | url = new URL(url as string, event.url.origin).toString(); 71 | } 72 | // TODO: why are we doing this again ? 73 | const imageUrl = new URL(url as string); 74 | 75 | const imageHash = _createHashString( 76 | `${imageUrl.toString()}-${width}-${height}-${format}-${quality}-${fit}-${position}-${background}` 77 | ); 78 | 79 | if (options.cache && options.cacheTTLStrategy !== CacheTTLStrategy.noCache) { 80 | if (options.cache.useRedirects) { 81 | const publicUrlData = await options.cache.getPublicUrl(imageHash); 82 | const publicUrl = publicUrlData.url; 83 | const ttl = publicUrlData.ttl; 84 | 85 | if (publicUrl) { 86 | return new Response(null, { 87 | status: 302, 88 | headers: { 89 | Location: publicUrl, 90 | 'Cache-Control': _getCacheControlHeader(ttl ?? ONE_YEAR_IN_SECONDS), 91 | 'x-img-optimizer-cache': 'HIT' 92 | } 93 | }); 94 | } 95 | } 96 | try { 97 | const result = await options.cache.getKey(imageHash); 98 | const cachedImage = result.value; 99 | const ttl = result.ttl; 100 | if (cachedImage) { 101 | return _createStreamResponse( 102 | Buffer.isBuffer(cachedImage) ? new PassThrough().end(cachedImage) : cachedImage, 103 | { 104 | 'Content-Type': `image/${format}`, 105 | 'Cache-Control': _getCacheControlHeader(ttl ?? ONE_YEAR_IN_SECONDS), 106 | 'x-img-optimizer-cache': 'HIT' 107 | } 108 | ); 109 | } 110 | } catch (error) { 111 | console.error('Cache error:', error); 112 | } 113 | } 114 | 115 | const res = await event.fetch(imageUrl.toString()); 116 | 117 | if (!res.ok) return new Response('Failed to fetch image', { status: res.status }); 118 | if (!res.body) return new Response('No body', { status: 404 }); 119 | 120 | let contentType = res.headers.get('content-type'); 121 | const sourceCacheTTLHeader = res.headers.get('cache-control'); 122 | 123 | let cacheControlHeader = `public, max-age=${ONE_YEAR_IN_SECONDS}, immutable`; 124 | if (options.cacheTTLStrategy === CacheTTLStrategy.source) { 125 | cacheControlHeader = 126 | sourceCacheTTLHeader || `public, max-age=${ONE_YEAR_IN_SECONDS}, immutable`; 127 | } else if (options.cacheTTLStrategy === CacheTTLStrategy.immutable) { 128 | cacheControlHeader = `public, max-age=${ONE_YEAR_IN_SECONDS}, immutable`; 129 | } else if (options.cacheTTLStrategy === CacheTTLStrategy.noCache) { 130 | cacheControlHeader = `no-cache, no-store, must-revalidate`; 131 | } else if (typeof options.cacheTTLStrategy === 'number') { 132 | cacheControlHeader = `public, max-age=${options.cacheTTLStrategy}`; 133 | } 134 | 135 | const size = res.headers.get('content-length'); 136 | 137 | const aboveMinSizeThreshold = 138 | !size || 139 | (options.minSizeThreshold && 140 | !isNaN(parseInt(size)) && 141 | parseInt(size) > options.minSizeThreshold); 142 | 143 | const stream = res.body.getReader(); 144 | const { peekedBuffer, remainingStream } = await _peekStream(stream); 145 | 146 | contentType = contentType || detectContentType(peekedBuffer) || `application/octet-stream`; 147 | 148 | const sourceFormat = contentType?.split('/')[1]; 149 | 150 | const shouldSkip = 151 | !aboveMinSizeThreshold || 152 | (!hasResize && _shouldSkip(sourceFormat, options, event.request.headers.get('accept') || '')); 153 | 154 | const isAnimated = isImageAnimated(peekedBuffer, contentType as string); 155 | 156 | const passthrough = _finishRemainingStream(remainingStream, peekedBuffer); 157 | 158 | try { 159 | if (isAnimated || shouldSkip) { 160 | /* 161 | For animated images, return the passthrough stream directly 162 | TODO: maybe expose a manual animatedImage handler to the developer 163 | this way they can use ffmpeg or image magick to convert the image 164 | Or even add an optimizeAnimatedImages option to the image optimizer 165 | in that case the images will be static and animations will lost 166 | */ 167 | const response = _createStreamResponse(passthrough, { 168 | 'Content-Type': contentType as string, 169 | 'Cache-Control': cacheControlHeader, 170 | 'x-img-optimizer-cache': shouldSkip ? 'SKIP' : 'MISS' 171 | }); 172 | 173 | return response; 174 | } else { 175 | const transformer = sharp(); 176 | 177 | transformer.resize({ 178 | width: width ? parseInt(width) : undefined, 179 | height: height ? parseInt(height) : undefined, 180 | fit: fit as FitEnum, 181 | position: position, 182 | withoutEnlargement: contentType !== 'image/svg+xml', 183 | ...(background && (fit === 'contain' || fit === 'fill') ? { background } : {}) 184 | }); 185 | 186 | if (format) { 187 | transformer.toFormat(format as keyof sharp.FormatEnum, { 188 | quality: quality, 189 | ...(format === 'avif' ? { chromaSubsampling: '4:2:0' } : {}) 190 | }); 191 | } 192 | 193 | if (options.cache && options.cacheTTLStrategy !== CacheTTLStrategy.noCache) { 194 | const transformedStream = passthrough.pipe(transformer); 195 | const cacheStream = new PassThrough(); 196 | const outputStream = new PassThrough(); 197 | 198 | transformedStream.pipe(cacheStream); 199 | transformedStream.pipe(outputStream); 200 | 201 | const ttl = _extractTTLFromCacheControlHeader(cacheControlHeader); 202 | 203 | options.cache.setKey(imageHash, cacheStream, ttl, `image/${format}`).catch((err) => { 204 | console.error('Error caching transformed image:', err); 205 | }); 206 | 207 | return _createStreamResponse(outputStream, { 208 | 'Content-Type': `image/${format}`, 209 | 'Cache-Control': cacheControlHeader, 210 | 'x-img-optimizer-cache': 'MISS' 211 | }); 212 | } else { 213 | return _createStreamResponse(passthrough.pipe(transformer), { 214 | 'Content-Type': `image/${format}`, 215 | 'Cache-Control': cacheControlHeader, 216 | 'x-img-optimizer-cache': 'MISS' 217 | }); 218 | } 219 | } 220 | } catch (error) { 221 | passthrough.end(); 222 | console.error('Error in image transformation:', error); 223 | return new Response('Error processing image', { status: 500 }); 224 | } 225 | } 226 | 227 | /** 228 | * Creates and returns a server hook handler to be use 229 | */ 230 | export function createImageOptimizer(options: Partial = {}) { 231 | return _createImageOptimizerHooksHandler(_validateAndGetOptions(options)); 232 | } 233 | 234 | /** 235 | * Creates and returns a server route handler to be used as a route handler in a +server.(ts|js) file 236 | */ 237 | export function createImageOptimizerRoute(options: Partial = {}) { 238 | return _createImageOptimizerRouteHandler(_validateAndGetOptions(options)); 239 | } 240 | -------------------------------------------------------------------------------- /src/lib/handlers/images.utils.ts: -------------------------------------------------------------------------------- 1 | export function isGifAnimated(buffer: Buffer) { 2 | const signature = buffer.subarray(0, 6).toString('ascii'); 3 | if (signature !== 'GIF89a' && signature !== 'GIF87a') return false; 4 | 5 | const GCE = Buffer.from([0x21, 0xf9, 0x04]); 6 | let count = 0; 7 | let index = 0; 8 | 9 | while ((index = buffer.indexOf(GCE, index)) !== -1) { 10 | count++; 11 | index++; 12 | if (count > 1) return true; 13 | } 14 | 15 | return false; 16 | } 17 | 18 | export function isWebPAnimated(buffer: Buffer) { 19 | if (buffer.subarray(0, 4).toString() !== 'RIFF') return false; 20 | if (buffer.subarray(8, 12).toString() !== 'WEBP') return false; 21 | 22 | return buffer.includes(Buffer.from('ANIM')); 23 | } 24 | 25 | export function isImageAnimated(buffer: Buffer, contentType: string) { 26 | if (contentType === 'image/gif') return isGifAnimated(buffer); 27 | if (contentType === 'image/webp') return isWebPAnimated(buffer); 28 | return false; 29 | } 30 | 31 | /** 32 | * Inspects the first few bytes of a buffer to determine if 33 | * it matches the "magic number" of known file signatures. 34 | * https://en.wikipedia.org/wiki/List_of_file_signatures 35 | * also see https://github.com/vercel/next.js/blob/fabab8cf05da106410d97d818adf2f6b4742d614/packages/next/src/server/image-optimizer.ts 36 | * Why rewrite this when it's already done, Am I right? 37 | */ 38 | export function detectContentType(buffer: Buffer) { 39 | if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) { 40 | return 'image/jpeg'; 41 | } 42 | if ([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => buffer[i] === b)) { 43 | return 'image/png'; 44 | } 45 | if ([0x47, 0x49, 0x46, 0x38].every((b, i) => buffer[i] === b)) { 46 | return 'image/gif'; 47 | } 48 | if ( 49 | [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50].every( 50 | (b, i) => !b || buffer[i] === b 51 | ) 52 | ) { 53 | return 'image/webp'; 54 | } 55 | if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) { 56 | return 'image/svg+xml'; 57 | } 58 | if ( 59 | [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every( 60 | (b, i) => !b || buffer[i] === b 61 | ) 62 | ) { 63 | return 'image/avif'; 64 | } 65 | if ([0x00, 0x00, 0x01, 0x00].every((b, i) => buffer[i] === b)) { 66 | return 'image/ico'; 67 | } 68 | return null; 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handlers/imageHandler.js'; 2 | export * from './data.js'; 3 | export * from './cache-adapters/index.js'; 4 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 153 | 154 |
155 |

Image Optimizer Demo

156 | 157 |

158 | This is a demo of the image optimizer. It is a simple demo that shows how to use the image 159 | optimizer. 160 |

161 | 162 |
163 |
164 | 165 | 166 |
167 | 168 |
169 |
170 | 171 | 172 |
173 | 174 |
175 | 176 | 177 |
178 |
179 | 180 |
181 |
182 | 183 | 184 |
185 | 186 |
187 | 188 | 195 |
196 |
197 | 198 |
199 |
200 | 201 | 208 |
209 | 210 |
211 | 212 | 223 |
224 |
225 | 226 |
227 |
228 | 229 | 235 |
236 | 237 |
238 | 239 | 244 |
245 |
246 | 247 | 248 |
249 | 250 |
251 |

Optimized Image

252 | {#if showImage} 253 |
254 | Optimized image 260 |
261 |
262 |

Image Comparison

263 | 264 | {#if isLoading} 265 |
Loading image details...
266 | {:else if originalImageDetails && optimizedImageDetails} 267 |
268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 |
PropertyOriginal ImageOptimized Image
Content Type{originalImageDetails.contentType}{optimizedImageDetails.contentType}
Size{originalImageDetails.contentLength}{optimizedImageDetails.contentLength}
Cache Control{originalImageDetails.cacheControl}{optimizedImageDetails.cacheControl}
Load Time{originalImageDetails.loadTime}{optimizedImageDetails.loadTime}
Status{originalImageDetails.status} {originalImageDetails.statusText}{optimizedImageDetails.status} {optimizedImageDetails.statusText}
Redirected{originalImageDetails.wasRedirected ? 'Yes' : 'No'}{optimizedImageDetails.wasRedirected ? 'Yes' : 'No'}
Cache Status (server){originalImageDetails.cacheStatus}{optimizedImageDetails.cacheStatus}
314 |
315 | 316 |
317 | 331 |

332 | The first time an image is loaded will be somewhat slower, because it is being 333 | optimized, that is why it is recommended to add caching 334 |

335 |
336 | {:else} 337 |
Failed to load image details. Please try again.
338 | {/if} 339 |
340 | {:else} 341 |
Click "Apply" to see the optimized image
342 | {/if} 343 |
344 |
345 | 346 | 527 | -------------------------------------------------------------------------------- /src/routes/page.svelte.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest'; 2 | import '@testing-library/jest-dom/vitest'; 3 | import { render, screen } from '@testing-library/svelte'; 4 | import Page from './+page.svelte'; 5 | 6 | describe('/+page.svelte', () => { 7 | test('should render h1', () => { 8 | render(Page); 9 | expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanshield-sidepack/sveltekit-image-optimize/1048200380017b88aa99190386124e4a147c53da/static/favicon.png -------------------------------------------------------------------------------- /static/orange50.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanshield-sidepack/sveltekit-image-optimize/1048200380017b88aa99190386124e4a147c53da/static/orange50.jpg -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://svelte.dev/docs/kit/integrations 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 | adapter: adapter(), 15 | prerender: { 16 | handleHttpError: 'warn' 17 | } 18 | } 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "module": "NodeNext", 13 | "moduleResolution": "NodeNext" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { svelteTesting } from '@testing-library/svelte/vite'; 2 | import { sveltekit } from '@sveltejs/kit/vite'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [sveltekit()], 7 | test: { 8 | workspace: [ 9 | { 10 | extends: './vite.config.ts', 11 | plugins: [svelteTesting()], 12 | test: { 13 | name: 'client', 14 | environment: 'jsdom', 15 | clearMocks: true, 16 | include: ['src/**/*.svelte.{test,spec}.{js,ts}'], 17 | exclude: ['src/lib/server/**'], 18 | setupFiles: ['./vitest-setup-client.ts'] 19 | } 20 | }, 21 | { 22 | extends: './vite.config.ts', 23 | test: { 24 | name: 'server', 25 | environment: 'node', 26 | include: ['src/**/*.{test,spec}.{js,ts}'], 27 | exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] 28 | } 29 | } 30 | ] 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /vitest-setup-client.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | import { vi } from 'vitest'; 3 | 4 | // required for svelte5 + jsdom as jsdom does not support matchMedia 5 | Object.defineProperty(window, 'matchMedia', { 6 | writable: true, 7 | enumerable: true, 8 | value: vi.fn().mockImplementation((query) => ({ 9 | matches: false, 10 | media: query, 11 | onchange: null, 12 | addEventListener: vi.fn(), 13 | removeEventListener: vi.fn(), 14 | dispatchEvent: vi.fn() 15 | })) 16 | }); 17 | 18 | // add more mocks here if you need them 19 | --------------------------------------------------------------------------------