@pracht/image 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/config-BavYkV_M.mjs +100 -0
- package/dist/index.d.mts +117 -0
- package/dist/index.mjs +129 -0
- package/dist/node.d.mts +83 -0
- package/dist/node.mjs +270 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jovi De Croock
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @pracht/image
|
|
2
|
+
|
|
3
|
+
Responsive, CLS-safe `<Image>` component for [pracht](https://github.com/JoviDeCroock/pracht)
|
|
4
|
+
apps with pluggable optimization backends per deployment target, mirroring
|
|
5
|
+
next/image's loader pattern.
|
|
6
|
+
|
|
7
|
+
- Plain `<img>` markup: SSR-safe, no hydration, zero client runtime.
|
|
8
|
+
- Required `width`/`height` (or `fill`) to prevent layout shift, with dev
|
|
9
|
+
warnings when missing.
|
|
10
|
+
- `srcset` across configurable device-size breakpoints, `sizes` support,
|
|
11
|
+
`loading="lazy"` + `decoding="async"` by default, `priority` for
|
|
12
|
+
above-the-fold images.
|
|
13
|
+
- Loaders for the built-in endpoint, Cloudflare Image Resizing, Vercel Image
|
|
14
|
+
Optimization, or plain passthrough.
|
|
15
|
+
- A Node optimization endpoint (`@pracht/image/node`) backed by
|
|
16
|
+
[sharp](https://sharp.pixelplumbing.com) (optional peer dependency) with a
|
|
17
|
+
trusted-local-origin/remote-allowlist security model and revalidated cache
|
|
18
|
+
headers.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add @pracht/image
|
|
22
|
+
pnpm add sharp # only needed for the built-in endpoint
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { Image } from "@pracht/image";
|
|
27
|
+
|
|
28
|
+
<Image src="/banner.jpg" alt="Banner" width={1200} height={280} priority />;
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// src/api/_pracht/image.ts — mounts the optimization endpoint
|
|
33
|
+
import { createImageHandler } from "@pracht/image/node";
|
|
34
|
+
|
|
35
|
+
const imageHandler = createImageHandler({
|
|
36
|
+
localOrigin: process.env.PRACHT_ORIGIN,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const GET = imageHandler;
|
|
40
|
+
export const HEAD = imageHandler;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Set `localOrigin` to the same trusted public origin used by
|
|
44
|
+
`nodeAdapter({ canonicalOrigin })`. It may be omitted for loopback development.
|
|
45
|
+
|
|
46
|
+
See [docs/IMAGES.md](https://github.com/JoviDeCroock/pracht/blob/main/docs/IMAGES.md)
|
|
47
|
+
for the full guide: loader configuration, endpoint security options, and
|
|
48
|
+
per-adapter guidance.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//#region src/loaders.ts
|
|
2
|
+
const DEFAULT_QUALITY = 75;
|
|
3
|
+
/**
|
|
4
|
+
* The default optimization endpoint. It maps onto an API route file at
|
|
5
|
+
* `src/api/_pracht/image.ts` that re-exports the handler from
|
|
6
|
+
* `@pracht/image/node`, so it works on every adapter without extra wiring.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_IMAGE_ENDPOINT = "/api/_pracht/image";
|
|
9
|
+
/**
|
|
10
|
+
* Build a loader for a pracht image optimization endpoint. Use this when the
|
|
11
|
+
* handler is mounted somewhere other than {@link DEFAULT_IMAGE_ENDPOINT}.
|
|
12
|
+
*/
|
|
13
|
+
function createDefaultLoader(endpoint = DEFAULT_IMAGE_ENDPOINT) {
|
|
14
|
+
return ({ src, width, quality }) => `${endpoint}?url=${encodeURIComponent(src)}&w=${width}&q=${quality ?? 75}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Targets the pracht image endpoint served by `createImageHandler()` from
|
|
18
|
+
* `@pracht/image/node` (mounted as the `src/api/_pracht/image.ts` API route).
|
|
19
|
+
*/
|
|
20
|
+
const defaultLoader = createDefaultLoader();
|
|
21
|
+
/**
|
|
22
|
+
* Cloudflare Image Resizing. Requires the zone to have Image Resizing
|
|
23
|
+
* enabled; `format=auto` lets Cloudflare negotiate WebP/AVIF.
|
|
24
|
+
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
|
25
|
+
*/
|
|
26
|
+
const cloudflareLoader = ({ src, width, quality }) => {
|
|
27
|
+
const source = src.startsWith("/") ? src.slice(1) : src;
|
|
28
|
+
return `/cdn-cgi/image/width=${width},quality=${quality ?? 75},format=auto/${source}`;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Vercel Image Optimization. Note that Vercel only serves widths listed in
|
|
32
|
+
* the `images.sizes` field of your project configuration.
|
|
33
|
+
* https://vercel.com/docs/image-optimization
|
|
34
|
+
*/
|
|
35
|
+
const vercelLoader = ({ src, width, quality }) => `/_vercel/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality ?? 75}`;
|
|
36
|
+
/**
|
|
37
|
+
* No optimization: the browser fetches the original file. Use for static
|
|
38
|
+
* hosts without an image service. `<Image>` skips `srcset` entirely when
|
|
39
|
+
* every candidate resolves to the same URL.
|
|
40
|
+
*/
|
|
41
|
+
const passthroughLoader = ({ src }) => src;
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/config.ts
|
|
44
|
+
/**
|
|
45
|
+
* Device-width breakpoints used for `srcset` candidates when an image is
|
|
46
|
+
* responsive (`fill` or a `sizes` prop). Matches the next/image defaults.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_DEVICE_SIZES = [
|
|
49
|
+
640,
|
|
50
|
+
750,
|
|
51
|
+
828,
|
|
52
|
+
1080,
|
|
53
|
+
1200,
|
|
54
|
+
1920,
|
|
55
|
+
2048,
|
|
56
|
+
3840
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* Additional small widths used to snap fixed-size images to cache-friendly
|
|
60
|
+
* buckets. Matches the next/image defaults.
|
|
61
|
+
*/
|
|
62
|
+
const DEFAULT_IMAGE_SIZES = [
|
|
63
|
+
16,
|
|
64
|
+
32,
|
|
65
|
+
48,
|
|
66
|
+
64,
|
|
67
|
+
96,
|
|
68
|
+
128,
|
|
69
|
+
256,
|
|
70
|
+
384
|
|
71
|
+
];
|
|
72
|
+
const defaults = {
|
|
73
|
+
loader: defaultLoader,
|
|
74
|
+
deviceSizes: DEFAULT_DEVICE_SIZES,
|
|
75
|
+
imageSizes: DEFAULT_IMAGE_SIZES,
|
|
76
|
+
quality: 75
|
|
77
|
+
};
|
|
78
|
+
let current = defaults;
|
|
79
|
+
/**
|
|
80
|
+
* Configure `<Image>` globally. Call once at module scope (for example in
|
|
81
|
+
* `src/routes.ts`) so the configuration applies on the server and in the
|
|
82
|
+
* browser. Individual components can still override via props.
|
|
83
|
+
*/
|
|
84
|
+
function configureImage(overrides) {
|
|
85
|
+
current = {
|
|
86
|
+
...current,
|
|
87
|
+
...overrides,
|
|
88
|
+
...overrides.deviceSizes ? { deviceSizes: [...overrides.deviceSizes].sort((a, b) => a - b) } : void 0,
|
|
89
|
+
...overrides.imageSizes ? { imageSizes: [...overrides.imageSizes].sort((a, b) => a - b) } : void 0
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function getImageConfig() {
|
|
93
|
+
return current;
|
|
94
|
+
}
|
|
95
|
+
/** Restore the default configuration. Primarily useful in tests. */
|
|
96
|
+
function resetImageConfig() {
|
|
97
|
+
current = defaults;
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
export { resetImageConfig as a, cloudflareLoader as c, passthroughLoader as d, vercelLoader as f, getImageConfig as i, createDefaultLoader as l, DEFAULT_IMAGE_SIZES as n, DEFAULT_IMAGE_ENDPOINT as o, configureImage as r, DEFAULT_QUALITY as s, DEFAULT_DEVICE_SIZES as t, defaultLoader as u };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { JSX, VNode } from "preact";
|
|
2
|
+
|
|
3
|
+
//#region src/loaders.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A loader turns an image source plus a target width into a concrete URL.
|
|
6
|
+
* Loaders mirror the next/image loader contract so migration is mechanical.
|
|
7
|
+
*/
|
|
8
|
+
interface ImageLoaderArgs {
|
|
9
|
+
src: string;
|
|
10
|
+
width: number;
|
|
11
|
+
quality?: number;
|
|
12
|
+
}
|
|
13
|
+
type ImageLoader = (args: ImageLoaderArgs) => string;
|
|
14
|
+
declare const DEFAULT_QUALITY = 75;
|
|
15
|
+
/**
|
|
16
|
+
* The default optimization endpoint. It maps onto an API route file at
|
|
17
|
+
* `src/api/_pracht/image.ts` that re-exports the handler from
|
|
18
|
+
* `@pracht/image/node`, so it works on every adapter without extra wiring.
|
|
19
|
+
*/
|
|
20
|
+
declare const DEFAULT_IMAGE_ENDPOINT = "/api/_pracht/image";
|
|
21
|
+
/**
|
|
22
|
+
* Build a loader for a pracht image optimization endpoint. Use this when the
|
|
23
|
+
* handler is mounted somewhere other than {@link DEFAULT_IMAGE_ENDPOINT}.
|
|
24
|
+
*/
|
|
25
|
+
declare function createDefaultLoader(endpoint?: string): ImageLoader;
|
|
26
|
+
/**
|
|
27
|
+
* Targets the pracht image endpoint served by `createImageHandler()` from
|
|
28
|
+
* `@pracht/image/node` (mounted as the `src/api/_pracht/image.ts` API route).
|
|
29
|
+
*/
|
|
30
|
+
declare const defaultLoader: ImageLoader;
|
|
31
|
+
/**
|
|
32
|
+
* Cloudflare Image Resizing. Requires the zone to have Image Resizing
|
|
33
|
+
* enabled; `format=auto` lets Cloudflare negotiate WebP/AVIF.
|
|
34
|
+
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
|
35
|
+
*/
|
|
36
|
+
declare const cloudflareLoader: ImageLoader;
|
|
37
|
+
/**
|
|
38
|
+
* Vercel Image Optimization. Note that Vercel only serves widths listed in
|
|
39
|
+
* the `images.sizes` field of your project configuration.
|
|
40
|
+
* https://vercel.com/docs/image-optimization
|
|
41
|
+
*/
|
|
42
|
+
declare const vercelLoader: ImageLoader;
|
|
43
|
+
/**
|
|
44
|
+
* No optimization: the browser fetches the original file. Use for static
|
|
45
|
+
* hosts without an image service. `<Image>` skips `srcset` entirely when
|
|
46
|
+
* every candidate resolves to the same URL.
|
|
47
|
+
*/
|
|
48
|
+
declare const passthroughLoader: ImageLoader;
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/image.d.ts
|
|
51
|
+
interface ImageProps extends Omit<JSX.HTMLAttributes<HTMLImageElement>, "src" | "srcset" | "srcSet" | "width" | "height" | "sizes" | "loading" | "alt" | "style"> {
|
|
52
|
+
/** Source path (`/hero.jpg`) or absolute URL. Passed to the loader. */
|
|
53
|
+
src: string;
|
|
54
|
+
/** Required for accessibility. Use `alt=""` for decorative images. */
|
|
55
|
+
alt: string;
|
|
56
|
+
/** Intrinsic width in pixels. Required unless `fill` is set. */
|
|
57
|
+
width?: number | `${number}`;
|
|
58
|
+
/** Intrinsic height in pixels. Required unless `fill` is set. */
|
|
59
|
+
height?: number | `${number}`;
|
|
60
|
+
/**
|
|
61
|
+
* Stretch the image to fill its nearest positioned ancestor instead of
|
|
62
|
+
* reserving intrinsic dimensions. Applies `position: absolute; inset: 0`.
|
|
63
|
+
*/
|
|
64
|
+
fill?: boolean;
|
|
65
|
+
/** Standard `sizes` attribute; switches the srcset to `w` descriptors. */
|
|
66
|
+
sizes?: string;
|
|
67
|
+
/** Quality hint forwarded to the loader (1-100). */
|
|
68
|
+
quality?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Mark as above-the-fold: loads eagerly with `fetchpriority="high"`.
|
|
71
|
+
* Everything else defaults to `loading="lazy"` + `decoding="async"`.
|
|
72
|
+
*/
|
|
73
|
+
priority?: boolean;
|
|
74
|
+
loading?: "lazy" | "eager";
|
|
75
|
+
/** Per-component loader override; falls back to the configured loader. */
|
|
76
|
+
loader?: ImageLoader;
|
|
77
|
+
style?: string | JSX.CSSProperties;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Responsive, CLS-safe `<img>`. Renders plain markup — no client runtime, no
|
|
81
|
+
* hydration requirement — and delegates URL generation to a pluggable
|
|
82
|
+
* loader (see `configureImage()` and the `loader` prop).
|
|
83
|
+
*/
|
|
84
|
+
declare function Image(props: ImageProps): VNode;
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/config.d.ts
|
|
87
|
+
/**
|
|
88
|
+
* Device-width breakpoints used for `srcset` candidates when an image is
|
|
89
|
+
* responsive (`fill` or a `sizes` prop). Matches the next/image defaults.
|
|
90
|
+
*/
|
|
91
|
+
declare const DEFAULT_DEVICE_SIZES: readonly number[];
|
|
92
|
+
/**
|
|
93
|
+
* Additional small widths used to snap fixed-size images to cache-friendly
|
|
94
|
+
* buckets. Matches the next/image defaults.
|
|
95
|
+
*/
|
|
96
|
+
declare const DEFAULT_IMAGE_SIZES: readonly number[];
|
|
97
|
+
interface ImageConfig {
|
|
98
|
+
/** Loader used when a component does not pass its own `loader` prop. */
|
|
99
|
+
loader: ImageLoader;
|
|
100
|
+
/** Breakpoints used for responsive (`fill`/`sizes`) srcsets, ascending. */
|
|
101
|
+
deviceSizes: readonly number[];
|
|
102
|
+
/** Extra small widths merged into the snap list for fixed images. */
|
|
103
|
+
imageSizes: readonly number[];
|
|
104
|
+
/** Default quality when a component does not pass `quality`. */
|
|
105
|
+
quality: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Configure `<Image>` globally. Call once at module scope (for example in
|
|
109
|
+
* `src/routes.ts`) so the configuration applies on the server and in the
|
|
110
|
+
* browser. Individual components can still override via props.
|
|
111
|
+
*/
|
|
112
|
+
declare function configureImage(overrides: Partial<ImageConfig>): void;
|
|
113
|
+
declare function getImageConfig(): ImageConfig;
|
|
114
|
+
/** Restore the default configuration. Primarily useful in tests. */
|
|
115
|
+
declare function resetImageConfig(): void;
|
|
116
|
+
//#endregion
|
|
117
|
+
export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_ENDPOINT, DEFAULT_IMAGE_SIZES, DEFAULT_QUALITY, Image, type ImageConfig, type ImageLoader, type ImageLoaderArgs, type ImageProps, cloudflareLoader, configureImage, createDefaultLoader, defaultLoader, getImageConfig, passthroughLoader, resetImageConfig, vercelLoader };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { a as resetImageConfig, c as cloudflareLoader, d as passthroughLoader, f as vercelLoader, i as getImageConfig, l as createDefaultLoader, n as DEFAULT_IMAGE_SIZES, o as DEFAULT_IMAGE_ENDPOINT, r as configureImage, s as DEFAULT_QUALITY, t as DEFAULT_DEVICE_SIZES, u as defaultLoader } from "./config-BavYkV_M.mjs";
|
|
2
|
+
import { h } from "preact";
|
|
3
|
+
//#region src/image.ts
|
|
4
|
+
const FILL_STYLE = {
|
|
5
|
+
position: "absolute",
|
|
6
|
+
height: "100%",
|
|
7
|
+
width: "100%",
|
|
8
|
+
left: 0,
|
|
9
|
+
top: 0,
|
|
10
|
+
right: 0,
|
|
11
|
+
bottom: 0
|
|
12
|
+
};
|
|
13
|
+
const FILL_STYLE_STRING = "position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;";
|
|
14
|
+
const warned = /* @__PURE__ */ new Set();
|
|
15
|
+
function getNodeEnv() {
|
|
16
|
+
return globalThis.process?.env?.NODE_ENV;
|
|
17
|
+
}
|
|
18
|
+
function getImportMetaDev() {
|
|
19
|
+
const env = import.meta.env;
|
|
20
|
+
if (env?.MODE === "production") return false;
|
|
21
|
+
if (typeof env?.DEV === "boolean") return env.DEV;
|
|
22
|
+
if (typeof env?.MODE === "string") return env.MODE !== "production";
|
|
23
|
+
}
|
|
24
|
+
function isDevWarningsEnabled() {
|
|
25
|
+
const nodeEnv = getNodeEnv();
|
|
26
|
+
if (nodeEnv === "production") return false;
|
|
27
|
+
if (typeof nodeEnv === "string") return true;
|
|
28
|
+
return getImportMetaDev() ?? false;
|
|
29
|
+
}
|
|
30
|
+
function warnOnce(key, message) {
|
|
31
|
+
if (!isDevWarningsEnabled() || warned.has(key)) return;
|
|
32
|
+
warned.add(key);
|
|
33
|
+
console.error(message);
|
|
34
|
+
}
|
|
35
|
+
function toDimension(value) {
|
|
36
|
+
if (value == null) return void 0;
|
|
37
|
+
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
|
|
38
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
39
|
+
}
|
|
40
|
+
/** Snap a target width to the smallest configured size that covers it. */
|
|
41
|
+
function snapToSizes(allSizes, target) {
|
|
42
|
+
for (const size of allSizes) if (size >= target) return size;
|
|
43
|
+
return allSizes[allSizes.length - 1];
|
|
44
|
+
}
|
|
45
|
+
function planSrcSet(deviceSizes, imageSizes, width, sizes) {
|
|
46
|
+
const allSizes = [...imageSizes, ...deviceSizes].sort((a, b) => a - b);
|
|
47
|
+
if (sizes) {
|
|
48
|
+
const viewportRatios = [];
|
|
49
|
+
const vwPattern = /(^|\s)(1?\d?\d)vw/g;
|
|
50
|
+
let match = vwPattern.exec(sizes);
|
|
51
|
+
while (match) {
|
|
52
|
+
viewportRatios.push(Number.parseInt(match[2], 10));
|
|
53
|
+
match = vwPattern.exec(sizes);
|
|
54
|
+
}
|
|
55
|
+
if (viewportRatios.length > 0) {
|
|
56
|
+
const smallestRatio = Math.min(...viewportRatios) / 100;
|
|
57
|
+
const floor = deviceSizes[0] * smallestRatio;
|
|
58
|
+
const widths = allSizes.filter((size) => size >= floor);
|
|
59
|
+
return {
|
|
60
|
+
widths: widths.length > 0 ? widths : [...deviceSizes],
|
|
61
|
+
descriptor: "w"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
widths: allSizes,
|
|
66
|
+
descriptor: "w"
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (width == null) return {
|
|
70
|
+
widths: [...deviceSizes],
|
|
71
|
+
descriptor: "w"
|
|
72
|
+
};
|
|
73
|
+
const oneX = snapToSizes(allSizes, width);
|
|
74
|
+
const twoX = snapToSizes(allSizes, width * 2);
|
|
75
|
+
return {
|
|
76
|
+
widths: oneX === twoX ? [oneX] : [oneX, twoX],
|
|
77
|
+
descriptor: "x"
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Responsive, CLS-safe `<img>`. Renders plain markup — no client runtime, no
|
|
82
|
+
* hydration requirement — and delegates URL generation to a pluggable
|
|
83
|
+
* loader (see `configureImage()` and the `loader` prop).
|
|
84
|
+
*/
|
|
85
|
+
function Image(props) {
|
|
86
|
+
const { src, alt, width, height, fill = false, sizes, quality, priority = false, loading, loader, style, ...rest } = props;
|
|
87
|
+
const config = getImageConfig();
|
|
88
|
+
const resolvedLoader = loader ?? config.loader;
|
|
89
|
+
const resolvedQuality = quality ?? config.quality;
|
|
90
|
+
const numericWidth = toDimension(width);
|
|
91
|
+
const numericHeight = toDimension(height);
|
|
92
|
+
if (isDevWarningsEnabled()) {
|
|
93
|
+
if (!fill && (numericWidth == null || numericHeight == null)) warnOnce(`dimensions:${src}`, `[pracht/image] <Image src="${src}"> is missing required "width" and "height" props. Provide the intrinsic dimensions (or use the "fill" prop) so the browser can reserve space and avoid layout shift.`);
|
|
94
|
+
if (fill && (width != null || height != null)) warnOnce(`fill-dimensions:${src}`, `[pracht/image] <Image src="${src}"> uses "fill" together with "width"/"height". "fill" images size themselves to their positioned parent; remove the explicit dimensions.`);
|
|
95
|
+
}
|
|
96
|
+
const effectiveSizes = sizes ?? (fill ? "100vw" : void 0);
|
|
97
|
+
const plan = planSrcSet(config.deviceSizes, config.imageSizes, numericWidth, effectiveSizes);
|
|
98
|
+
const candidates = plan.widths.map((candidateWidth) => resolvedLoader({
|
|
99
|
+
src,
|
|
100
|
+
width: candidateWidth,
|
|
101
|
+
quality: resolvedQuality
|
|
102
|
+
}));
|
|
103
|
+
const largestSrc = candidates[candidates.length - 1];
|
|
104
|
+
const optimized = new Set(candidates).size > 1;
|
|
105
|
+
const srcset = optimized ? candidates.map((url, index) => plan.descriptor === "w" ? `${url} ${plan.widths[index]}w` : `${url} ${index + 1}x`).join(", ") : void 0;
|
|
106
|
+
let mergedStyle = style;
|
|
107
|
+
if (fill) mergedStyle = typeof style === "string" ? `${FILL_STYLE_STRING}${style}` : {
|
|
108
|
+
...FILL_STYLE,
|
|
109
|
+
...style
|
|
110
|
+
};
|
|
111
|
+
const imgProps = {
|
|
112
|
+
...rest,
|
|
113
|
+
src: largestSrc,
|
|
114
|
+
alt,
|
|
115
|
+
decoding: rest.decoding ?? "async",
|
|
116
|
+
loading: loading ?? (priority ? "eager" : "lazy")
|
|
117
|
+
};
|
|
118
|
+
if (srcset) imgProps.srcset = srcset;
|
|
119
|
+
if (optimized && effectiveSizes) imgProps.sizes = effectiveSizes;
|
|
120
|
+
if (!fill) {
|
|
121
|
+
if (numericWidth != null) imgProps.width = numericWidth;
|
|
122
|
+
if (numericHeight != null) imgProps.height = numericHeight;
|
|
123
|
+
}
|
|
124
|
+
if (priority) imgProps.fetchpriority = "high";
|
|
125
|
+
if (mergedStyle != null) imgProps.style = mergedStyle;
|
|
126
|
+
return h("img", imgProps);
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_ENDPOINT, DEFAULT_IMAGE_SIZES, DEFAULT_QUALITY, Image, cloudflareLoader, configureImage, createDefaultLoader, defaultLoader, getImageConfig, passthroughLoader, resetImageConfig, vercelLoader };
|
package/dist/node.d.mts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
//#region src/node.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Allowlist entry for remote image sources, mirroring next/image's
|
|
4
|
+
* `remotePatterns`. Without any patterns only same-origin (relative) `url`
|
|
5
|
+
* values are accepted, which keeps the endpoint from becoming an open proxy.
|
|
6
|
+
*/
|
|
7
|
+
interface RemotePattern {
|
|
8
|
+
/** Restrict to a protocol; both http and https match when omitted. */
|
|
9
|
+
protocol?: "http" | "https";
|
|
10
|
+
/** Exact hostname, or a `*.example.com` suffix wildcard. */
|
|
11
|
+
hostname: string;
|
|
12
|
+
/** Exact port; any port matches when omitted. */
|
|
13
|
+
port?: string;
|
|
14
|
+
/** Path prefix (e.g. `/uploads/`); any path matches when omitted. */
|
|
15
|
+
pathname?: string;
|
|
16
|
+
}
|
|
17
|
+
interface CreateImageHandlerOptions {
|
|
18
|
+
/** Remote sources to allow. Defaults to none (same-origin only). */
|
|
19
|
+
remotePatterns?: RemotePattern[];
|
|
20
|
+
/**
|
|
21
|
+
* Trusted public origin used to resolve relative image paths in production.
|
|
22
|
+
* Required for non-loopback requests so an attacker-controlled Host header
|
|
23
|
+
* cannot turn the endpoint into an open proxy. Loopback origins remain
|
|
24
|
+
* available without configuration for local development.
|
|
25
|
+
*/
|
|
26
|
+
localOrigin?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Widths the endpoint will produce. Requests for other widths are rejected
|
|
29
|
+
* so a caller cannot fill caches with arbitrary variants. Defaults to the
|
|
30
|
+
* union of the default device and image sizes; keep this in sync with
|
|
31
|
+
* `configureImage({ deviceSizes, imageSizes })` when you customize those.
|
|
32
|
+
*/
|
|
33
|
+
allowedWidths?: number[];
|
|
34
|
+
/** Hard cap on the `w` parameter. Defaults to 3840. */
|
|
35
|
+
maxWidth?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Modern formats to negotiate via the `Accept` header, tried in order.
|
|
38
|
+
* Defaults to `["image/webp"]`; add `"image/avif"` to opt in to AVIF
|
|
39
|
+
* (smaller files, noticeably slower to encode).
|
|
40
|
+
*/
|
|
41
|
+
formats?: Array<"image/avif" | "image/webp">;
|
|
42
|
+
/** Cache-Control for successful responses. Defaults to 4 hours with revalidation. */
|
|
43
|
+
cacheControl?: string;
|
|
44
|
+
/** Reject source images larger than this many bytes. Defaults to 25 MiB. */
|
|
45
|
+
maxSourceBytes?: number;
|
|
46
|
+
/** Maximum number of validated redirects to follow. Defaults to 3. */
|
|
47
|
+
maxRedirects?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Override how source images are fetched (useful for tests/CDNs). Redirect
|
|
50
|
+
* responses must be returned without following them; the handler validates
|
|
51
|
+
* each Location before making the next request.
|
|
52
|
+
*/
|
|
53
|
+
fetchImage?: (url: URL, request: Request, signal?: AbortSignal) => Promise<Response>;
|
|
54
|
+
/** Override how sharp is imported (useful for tests). */
|
|
55
|
+
loadSharp?: () => Promise<unknown>;
|
|
56
|
+
}
|
|
57
|
+
interface ImageHandlerArgs {
|
|
58
|
+
request: Request;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create the pracht image optimization endpoint.
|
|
63
|
+
*
|
|
64
|
+
* Mount it as an API route so it works with every adapter and in `pracht dev`
|
|
65
|
+
* without extra wiring:
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* // src/api/_pracht/image.ts
|
|
69
|
+
* import { createImageHandler } from "@pracht/image/node";
|
|
70
|
+
* const imageHandler = createImageHandler();
|
|
71
|
+
* export const GET = imageHandler;
|
|
72
|
+
* export const HEAD = imageHandler;
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* The handler resizes and re-encodes images with sharp (an optional peer
|
|
76
|
+
* dependency — install it in your app), negotiates WebP/AVIF via the `Accept`
|
|
77
|
+
* header, and answers with cacheable responses keyed on the query string.
|
|
78
|
+
* Relative sources resolve against a trusted `localOrigin` in production;
|
|
79
|
+
* `remotePatterns` opts specific remote hosts in.
|
|
80
|
+
*/
|
|
81
|
+
declare function createImageHandler(options?: CreateImageHandlerOptions): (args: ImageHandlerArgs) => Promise<Response>;
|
|
82
|
+
//#endregion
|
|
83
|
+
export { CreateImageHandlerOptions, RemotePattern, createImageHandler };
|
package/dist/node.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { n as DEFAULT_IMAGE_SIZES, t as DEFAULT_DEVICE_SIZES } from "./config-BavYkV_M.mjs";
|
|
2
|
+
//#region src/node.ts
|
|
3
|
+
const SHARP_INSTALL_HINT = "Image optimization requires the optional \"sharp\" dependency. Install it in your app with \"pnpm add sharp\" (or npm install / yarn add) to enable the pracht image endpoint.";
|
|
4
|
+
const DEFAULT_CACHE_CONTROL = "public, max-age=14400, must-revalidate";
|
|
5
|
+
const DEFAULT_MAX_WIDTH = 3840;
|
|
6
|
+
const DEFAULT_MAX_SOURCE_BYTES = 25 * 1024 * 1024;
|
|
7
|
+
const DEFAULT_MAX_REDIRECTS = 3;
|
|
8
|
+
const REDIRECT_STATUSES = new Set([
|
|
9
|
+
301,
|
|
10
|
+
302,
|
|
11
|
+
303,
|
|
12
|
+
307,
|
|
13
|
+
308
|
|
14
|
+
]);
|
|
15
|
+
function createSharpImporter(load) {
|
|
16
|
+
let cached;
|
|
17
|
+
return () => {
|
|
18
|
+
cached ??= load().then((mod) => mod.default ?? mod, (error) => {
|
|
19
|
+
cached = void 0;
|
|
20
|
+
throw error;
|
|
21
|
+
});
|
|
22
|
+
return cached;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function errorResponse(status, message) {
|
|
26
|
+
return new Response(message, {
|
|
27
|
+
status,
|
|
28
|
+
headers: {
|
|
29
|
+
"content-type": "text/plain; charset=utf-8",
|
|
30
|
+
"cache-control": "no-store"
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function matchesHostname(hostname, pattern) {
|
|
35
|
+
const host = hostname.toLowerCase();
|
|
36
|
+
const expected = pattern.toLowerCase();
|
|
37
|
+
if (expected.startsWith("*.")) return host.endsWith(expected.slice(1)) && host.length > expected.length - 1;
|
|
38
|
+
return host === expected;
|
|
39
|
+
}
|
|
40
|
+
function matchesRemotePatterns(url, patterns) {
|
|
41
|
+
return patterns.some((pattern) => {
|
|
42
|
+
if (pattern.protocol && `${pattern.protocol}:` !== url.protocol) return false;
|
|
43
|
+
if (pattern.port !== void 0 && pattern.port !== url.port) return false;
|
|
44
|
+
if (!matchesHostname(url.hostname, pattern.hostname)) return false;
|
|
45
|
+
if (pattern.pathname) {
|
|
46
|
+
const prefix = pattern.pathname.endsWith("/") ? pattern.pathname : `${pattern.pathname}/`;
|
|
47
|
+
if (url.pathname !== pattern.pathname && !url.pathname.startsWith(prefix)) return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function normalizeLocalOrigin(value) {
|
|
53
|
+
if (value === void 0) return void 0;
|
|
54
|
+
const url = new URL(value);
|
|
55
|
+
if (url.protocol !== "http:" && url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("createImageHandler({ localOrigin }) expects an http(s) origin without a path.");
|
|
56
|
+
return url.origin;
|
|
57
|
+
}
|
|
58
|
+
function isLoopbackHostname(hostname) {
|
|
59
|
+
const host = hostname.toLowerCase();
|
|
60
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
61
|
+
}
|
|
62
|
+
function isAllowedTarget(url, localOrigin, patterns) {
|
|
63
|
+
if (url.protocol !== "http:" && url.protocol !== "https:" || url.username || url.password) return false;
|
|
64
|
+
return localOrigin !== void 0 && url.origin === localOrigin || matchesRemotePatterns(url, patterns);
|
|
65
|
+
}
|
|
66
|
+
function acceptsFormat(accept, format) {
|
|
67
|
+
return accept.split(",").some((entry) => {
|
|
68
|
+
const [mediaType, ...parameters] = entry.split(";");
|
|
69
|
+
if (mediaType.trim().toLowerCase() !== format) return false;
|
|
70
|
+
for (const parameter of parameters) {
|
|
71
|
+
const [name, value] = parameter.trim().split("=");
|
|
72
|
+
if (name?.toLowerCase() === "q" && Number(value) === 0) return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function readCappedBody(response, maxBytes) {
|
|
78
|
+
if (!response.body) return new Uint8Array(await response.arrayBuffer());
|
|
79
|
+
const reader = response.body.getReader();
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let total = 0;
|
|
82
|
+
while (true) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) break;
|
|
85
|
+
total += value.byteLength;
|
|
86
|
+
if (total > maxBytes) {
|
|
87
|
+
await reader.cancel();
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
chunks.push(value);
|
|
91
|
+
}
|
|
92
|
+
const body = new Uint8Array(total);
|
|
93
|
+
let offset = 0;
|
|
94
|
+
for (const chunk of chunks) {
|
|
95
|
+
body.set(chunk, offset);
|
|
96
|
+
offset += chunk.byteLength;
|
|
97
|
+
}
|
|
98
|
+
return body;
|
|
99
|
+
}
|
|
100
|
+
function responseBody(bytes) {
|
|
101
|
+
const body = new ArrayBuffer(bytes.byteLength);
|
|
102
|
+
new Uint8Array(body).set(bytes);
|
|
103
|
+
return body;
|
|
104
|
+
}
|
|
105
|
+
function imageResponseBody(request, bytes) {
|
|
106
|
+
return request.method === "HEAD" ? null : responseBody(bytes);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create the pracht image optimization endpoint.
|
|
110
|
+
*
|
|
111
|
+
* Mount it as an API route so it works with every adapter and in `pracht dev`
|
|
112
|
+
* without extra wiring:
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* // src/api/_pracht/image.ts
|
|
116
|
+
* import { createImageHandler } from "@pracht/image/node";
|
|
117
|
+
* const imageHandler = createImageHandler();
|
|
118
|
+
* export const GET = imageHandler;
|
|
119
|
+
* export const HEAD = imageHandler;
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* The handler resizes and re-encodes images with sharp (an optional peer
|
|
123
|
+
* dependency — install it in your app), negotiates WebP/AVIF via the `Accept`
|
|
124
|
+
* header, and answers with cacheable responses keyed on the query string.
|
|
125
|
+
* Relative sources resolve against a trusted `localOrigin` in production;
|
|
126
|
+
* `remotePatterns` opts specific remote hosts in.
|
|
127
|
+
*/
|
|
128
|
+
function createImageHandler(options = {}) {
|
|
129
|
+
const remotePatterns = options.remotePatterns ?? [];
|
|
130
|
+
const configuredLocalOrigin = normalizeLocalOrigin(options.localOrigin);
|
|
131
|
+
const allowedWidths = new Set(options.allowedWidths ?? [...DEFAULT_IMAGE_SIZES, ...DEFAULT_DEVICE_SIZES]);
|
|
132
|
+
const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
133
|
+
const formats = options.formats ?? ["image/webp"];
|
|
134
|
+
const cacheControl = options.cacheControl ?? DEFAULT_CACHE_CONTROL;
|
|
135
|
+
const maxSourceBytes = options.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES;
|
|
136
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
137
|
+
if (!Number.isInteger(maxRedirects) || maxRedirects < 0) throw new Error("createImageHandler({ maxRedirects }) expects a non-negative integer.");
|
|
138
|
+
const fetchImage = options.fetchImage ?? ((url, _request, signal) => fetch(url, {
|
|
139
|
+
headers: { accept: "image/*,*/*;q=0.8" },
|
|
140
|
+
redirect: "manual",
|
|
141
|
+
signal
|
|
142
|
+
}));
|
|
143
|
+
const importSharp = createSharpImporter(options.loadSharp ?? (() => import("sharp")));
|
|
144
|
+
return async function handleImageRequest({ request, signal }) {
|
|
145
|
+
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", {
|
|
146
|
+
status: 405,
|
|
147
|
+
headers: { allow: "GET, HEAD" }
|
|
148
|
+
});
|
|
149
|
+
const requestUrl = new URL(request.url);
|
|
150
|
+
const localOrigin = configuredLocalOrigin ?? (isLoopbackHostname(requestUrl.hostname) ? requestUrl.origin : void 0);
|
|
151
|
+
const source = requestUrl.searchParams.get("url");
|
|
152
|
+
const widthParam = requestUrl.searchParams.get("w");
|
|
153
|
+
const qualityParam = requestUrl.searchParams.get("q");
|
|
154
|
+
if (!source) return errorResponse(400, "Missing required \"url\" query parameter.");
|
|
155
|
+
if (source.startsWith("//")) return errorResponse(400, "Protocol-relative \"url\" values are not allowed.");
|
|
156
|
+
let target;
|
|
157
|
+
if (/^https?:\/\//i.test(source)) {
|
|
158
|
+
try {
|
|
159
|
+
target = new URL(source);
|
|
160
|
+
} catch {
|
|
161
|
+
return errorResponse(400, `Invalid "url" parameter: ${source}`);
|
|
162
|
+
}
|
|
163
|
+
if (target.username || target.password) return errorResponse(400, "The \"url\" parameter may not contain credentials.");
|
|
164
|
+
if (!matchesRemotePatterns(target, remotePatterns)) return errorResponse(403, `Remote image "${source}" is not allowed. Add its host to the remotePatterns option of createImageHandler() to opt it in.`);
|
|
165
|
+
} else if (source.startsWith("/")) {
|
|
166
|
+
if (!localOrigin) return errorResponse(500, "Relative image sources require createImageHandler({ localOrigin }) outside local development.");
|
|
167
|
+
target = new URL(source, localOrigin);
|
|
168
|
+
} else return errorResponse(400, "The \"url\" parameter must be a relative path (starting with \"/\") or an absolute http(s) URL.");
|
|
169
|
+
if (!widthParam) return errorResponse(400, "Missing required \"w\" query parameter.");
|
|
170
|
+
const width = Number(widthParam);
|
|
171
|
+
if (!Number.isInteger(width) || width <= 0) return errorResponse(400, "The \"w\" parameter must be a positive integer.");
|
|
172
|
+
if (width > maxWidth) return errorResponse(400, `The "w" parameter may not exceed ${maxWidth}.`);
|
|
173
|
+
if (allowedWidths.size > 0 && !allowedWidths.has(width)) return errorResponse(400, `The width ${width} is not allowed. Allowed widths: ${[...allowedWidths].sort((a, b) => a - b).join(", ")}.`);
|
|
174
|
+
let quality = 75;
|
|
175
|
+
if (qualityParam !== null) {
|
|
176
|
+
quality = Number(qualityParam);
|
|
177
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100) return errorResponse(400, "The \"q\" parameter must be an integer between 1 and 100.");
|
|
178
|
+
}
|
|
179
|
+
let upstream;
|
|
180
|
+
let currentTarget = target;
|
|
181
|
+
let redirectCount = 0;
|
|
182
|
+
while (true) {
|
|
183
|
+
try {
|
|
184
|
+
upstream = await fetchImage(currentTarget, request, signal);
|
|
185
|
+
} catch {
|
|
186
|
+
return errorResponse(502, `Failed to fetch source image "${source}".`);
|
|
187
|
+
}
|
|
188
|
+
if (!REDIRECT_STATUSES.has(upstream.status)) break;
|
|
189
|
+
const location = upstream.headers.get("location");
|
|
190
|
+
if (!location) return errorResponse(502, `Source image "${source}" returned a redirect without Location.`);
|
|
191
|
+
if (redirectCount >= maxRedirects) return errorResponse(502, `Source image "${source}" exceeded ${maxRedirects} redirects.`);
|
|
192
|
+
let nextTarget;
|
|
193
|
+
try {
|
|
194
|
+
nextTarget = new URL(location, currentTarget);
|
|
195
|
+
} catch {
|
|
196
|
+
return errorResponse(502, `Source image "${source}" returned an invalid redirect.`);
|
|
197
|
+
}
|
|
198
|
+
if (!isAllowedTarget(nextTarget, localOrigin, remotePatterns)) return errorResponse(403, `Source image "${source}" redirected to a host that is not allowed.`);
|
|
199
|
+
try {
|
|
200
|
+
await upstream.body?.cancel();
|
|
201
|
+
} catch {}
|
|
202
|
+
currentTarget = nextTarget;
|
|
203
|
+
redirectCount += 1;
|
|
204
|
+
}
|
|
205
|
+
if (upstream.url) {
|
|
206
|
+
let finalUrl;
|
|
207
|
+
try {
|
|
208
|
+
finalUrl = new URL(upstream.url);
|
|
209
|
+
} catch {
|
|
210
|
+
finalUrl = void 0;
|
|
211
|
+
}
|
|
212
|
+
if (finalUrl && !isAllowedTarget(finalUrl, localOrigin, remotePatterns)) return errorResponse(403, `Source image "${source}" redirected to a host that is not allowed.`);
|
|
213
|
+
}
|
|
214
|
+
if (!upstream.ok) return errorResponse(502, `Source image "${source}" responded with ${upstream.status}.`);
|
|
215
|
+
const sourceType = (upstream.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
216
|
+
if (!sourceType.startsWith("image/")) return errorResponse(415, `Source "${source}" is not an image (got "${sourceType}").`);
|
|
217
|
+
const sourceBytes = await readCappedBody(upstream, maxSourceBytes);
|
|
218
|
+
if (sourceBytes == null) return errorResponse(413, `Source image "${source}" exceeds ${maxSourceBytes} bytes.`);
|
|
219
|
+
const baseHeaders = {
|
|
220
|
+
"cache-control": cacheControl,
|
|
221
|
+
vary: "Accept",
|
|
222
|
+
"x-content-type-options": "nosniff"
|
|
223
|
+
};
|
|
224
|
+
if (sourceType === "image/svg+xml" || sourceType === "image/gif") {
|
|
225
|
+
const headers = {
|
|
226
|
+
...baseHeaders,
|
|
227
|
+
"content-type": sourceType
|
|
228
|
+
};
|
|
229
|
+
if (sourceType === "image/svg+xml") headers["content-disposition"] = "attachment";
|
|
230
|
+
return new Response(imageResponseBody(request, sourceBytes), { headers });
|
|
231
|
+
}
|
|
232
|
+
let sharp;
|
|
233
|
+
try {
|
|
234
|
+
sharp = await importSharp();
|
|
235
|
+
} catch {
|
|
236
|
+
return errorResponse(500, SHARP_INSTALL_HINT);
|
|
237
|
+
}
|
|
238
|
+
const accept = request.headers.get("accept") ?? "";
|
|
239
|
+
let pipeline = sharp(sourceBytes).rotate().resize({
|
|
240
|
+
width,
|
|
241
|
+
withoutEnlargement: true
|
|
242
|
+
});
|
|
243
|
+
let contentType;
|
|
244
|
+
if (formats.includes("image/avif") && acceptsFormat(accept, "image/avif")) {
|
|
245
|
+
pipeline = pipeline.avif({ quality });
|
|
246
|
+
contentType = "image/avif";
|
|
247
|
+
} else if (formats.includes("image/webp") && acceptsFormat(accept, "image/webp")) {
|
|
248
|
+
pipeline = pipeline.webp({ quality });
|
|
249
|
+
contentType = "image/webp";
|
|
250
|
+
} else if (sourceType === "image/png") {
|
|
251
|
+
pipeline = pipeline.png();
|
|
252
|
+
contentType = "image/png";
|
|
253
|
+
} else {
|
|
254
|
+
pipeline = pipeline.jpeg({ quality });
|
|
255
|
+
contentType = "image/jpeg";
|
|
256
|
+
}
|
|
257
|
+
let output;
|
|
258
|
+
try {
|
|
259
|
+
output = await pipeline.toBuffer();
|
|
260
|
+
} catch {
|
|
261
|
+
return errorResponse(500, `Failed to optimize source image "${source}".`);
|
|
262
|
+
}
|
|
263
|
+
return new Response(imageResponseBody(request, output), { headers: {
|
|
264
|
+
...baseHeaders,
|
|
265
|
+
"content-type": contentType
|
|
266
|
+
} });
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
export { createImageHandler };
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pracht/image",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Responsive, CLS-safe <Image> component for Pracht with pluggable optimization loaders and a Node image endpoint.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pracht",
|
|
7
|
+
"preact",
|
|
8
|
+
"image",
|
|
9
|
+
"responsive",
|
|
10
|
+
"srcset",
|
|
11
|
+
"lazy-loading",
|
|
12
|
+
"image-optimization",
|
|
13
|
+
"sharp"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/image",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/JoviDeCroock/pracht/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/JoviDeCroock/pracht",
|
|
23
|
+
"directory": "packages/image"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.mts",
|
|
32
|
+
"default": "./dist/index.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./node": {
|
|
35
|
+
"types": "./dist/node.d.mts",
|
|
36
|
+
"default": "./dist/node.mjs"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"provenance": false,
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"preact": "^10.0.0",
|
|
45
|
+
"sharp": "^0.33.0 || ^0.34.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"sharp": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"sharp": "^0.34.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsdown"
|
|
57
|
+
}
|
|
58
|
+
}
|