@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 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 };
@@ -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 };
@@ -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
+ }