@all1ndev/next-signed-image 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 all1ndev
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 CHANGED
@@ -1,3 +1,207 @@
1
- # Temporary Holding Version
1
+ # @all1ndev/next-signed-image
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A drop-in [`next/image`](https://nextjs.org/docs/app/api-reference/components/image)
4
+ replacement for image services that only accept URLs signed with a server-side secret:
5
+ [Thumbor](https://www.thumbor.org/), [imgproxy](https://imgproxy.net/), signed imgix,
6
+ Cloudinary or S3 URLs.
7
+
8
+ - Server components and server-rendered client components get **signed URLs directly**, with
9
+ no redirect hop, your LCP image included.
10
+ - Client components **hydrate without a mismatch**: the browser reads the signed `srcset` back
11
+ from the server's `<img>` instead of trying to sign.
12
+ - Only an image that **first renders in the browser** (after a click or a soft navigation)
13
+ goes through `/api/image`, which signs and answers `302`.
14
+ - The signer and its secret **never reach the browser bundle**.
15
+
16
+ Requires Next.js 16+, React 19+, App Router.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install @all1ndev/next-signed-image
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### 1. Write the signer
27
+
28
+ The signer is your own module, so your app keeps its env, its secrets and its source policy.
29
+ It must export `imageSigner`:
30
+
31
+ ```ts
32
+ // src/image-signer.ts
33
+ import { defineImageSigner } from "@all1ndev/next-signed-image/route";
34
+
35
+ export const imageSigner = defineImageSigner({
36
+ delivery(src) {
37
+ const origin = URL.parse(src)?.origin;
38
+ if (origin === "https://cdn.example.com") return "as-is"; // already display-sized
39
+ if (origin === "https://photos.example.com") return "sign";
40
+ return "route"; // refused: /api/image answers 403
41
+ },
42
+ sign({ src, width, quality }) {
43
+ return signWithYourService(src, width, quality, process.env.IMAGE_SIGNING_KEY);
44
+ },
45
+ });
46
+ ```
47
+
48
+ ### 2. Wrap the Next config
49
+
50
+ ```ts
51
+ // next.config.ts
52
+ import { withSignedImages } from "@all1ndev/next-signed-image/config";
53
+
54
+ export default withSignedImages(
55
+ {
56
+ /* your config */
57
+ },
58
+ { signer: "./src/image-signer.ts" },
59
+ );
60
+ ```
61
+
62
+ ### 3. Add the route
63
+
64
+ ```ts
65
+ // src/app/api/image/route.ts
66
+ import { createImageRoute } from "@all1ndev/next-signed-image/route";
67
+
68
+ import { imageSigner } from "@/image-signer";
69
+
70
+ export const GET = createImageRoute(imageSigner);
71
+ ```
72
+
73
+ ### 4. Render images
74
+
75
+ ```tsx
76
+ import { Image } from "@all1ndev/next-signed-image";
77
+
78
+ <Image src="https://photos.example.com/car.jpg" alt="" width={640} height={480} preload />;
79
+ ```
80
+
81
+ `Image` takes the `next/image` props except `loader` and `unoptimized`. Use it from server and
82
+ client components alike. A static import (`import hero from "./hero.png"`) is a same-origin
83
+ file, so it is served as-is with its intrinsic size, and the signer is never asked.
84
+
85
+ ## The signer contract
86
+
87
+ ```ts
88
+ type ImageSigner = {
89
+ delivery(src: string): "as-is" | "sign" | "route";
90
+ sign(image: { src: string; width: number; quality: number }): string;
91
+ };
92
+ ```
93
+
94
+ | `delivery(src)` | Server render | `/api/image` |
95
+ | --------------- | -------------------------------------------- | -------------------------------------- |
96
+ | `"sign"` | `sign()` for each width next/image asks for | `302` to `sign()` |
97
+ | `"as-is"` | the URL itself, `unoptimized` | `302` to the normalized http(s) URL |
98
+ | `"route"` | points at `/api/image` (one broken image) | `403` |
99
+
100
+ Same-origin paths (`/public` files, static imports) and `.svg` sources are always served
101
+ as-is, before the signer is asked, because the browser has to reach the same decision without
102
+ it. The route never applies that rule: it trusts only `delivery()`.
103
+
104
+ `sign()` is called only after `delivery()` returned `"sign"`, so it is the place to enforce
105
+ your source policy again. Sign the normalized URL, not the raw input, and encode it so the
106
+ signed URL holds no space (a `srcset` separates candidates with spaces).
107
+
108
+ `defineImageSigner` returns the signer unchanged and throws if the module is ever evaluated in
109
+ a browser, standing in for `server-only`, which a client component's SSR pass cannot import.
110
+
111
+ ### Example: Thumbor
112
+
113
+ With [`thumbor-client`](https://www.npmjs.com/package/thumbor-client) (any HMAC-SHA1
114
+ implementation works):
115
+
116
+ ```ts
117
+ import { defineImageSigner } from "@all1ndev/next-signed-image/route";
118
+ import { createThumbor } from "thumbor-client";
119
+
120
+ const thumbor = createThumbor({ url: process.env.THUMBOR_URL!, key: process.env.THUMBOR_KEY! });
121
+ const allowed = new Set(process.env.THUMBOR_ALLOWED_SOURCE_ORIGINS!.split(","));
122
+
123
+ export const imageSigner = defineImageSigner({
124
+ delivery: (src) => (allowed.has(URL.parse(src)?.origin ?? "") ? "sign" : "route"),
125
+ sign({ src, width, quality }) {
126
+ const source = new URL(src);
127
+ if (!allowed.has(source.origin)) throw new Error(`Not an allowed image source: ${src}`);
128
+ return thumbor
129
+ .image(source.href, { encode: true })
130
+ .resize(width, 0)
131
+ .quality(quality)
132
+ .noUpscale()
133
+ .url();
134
+ },
135
+ });
136
+ ```
137
+
138
+ The `THUMBOR_*` variables belong to your app. Read them lazily (inside the functions), so a
139
+ build without them still succeeds.
140
+
141
+ ## Sizes and qualities
142
+
143
+ `withSignedImages` sets `images.deviceSizes`, `imageSizes` and `qualities` (Next's defaults,
144
+ quality 75 only), and the route signs exactly those. To change them, pass **the same object**
145
+ to both, since a width the route rejects breaks every browser-rendered image:
146
+
147
+ ```ts
148
+ // src/image-sizes.ts
149
+ import { defaultImageSizes } from "@all1ndev/next-signed-image/config";
150
+
151
+ export const sizes = { ...defaultImageSizes, qualities: [50, 75] };
152
+ ```
153
+
154
+ ```ts
155
+ withSignedImages(config, { signer: "./src/image-signer.ts", sizes });
156
+ createImageRoute(imageSigner, { sizes });
157
+ ```
158
+
159
+ ## How it works
160
+
161
+ `@all1ndev/next-signed-image` is a conditional export. Server components resolve its
162
+ `react-server` build, which renders once: it runs `getImageProps` with a signing loader,
163
+ renders a plain `<img>` and, for `preload`, calls React DOM's `preload()` (getImageProps
164
+ emits no preload hint of its own). Client components resolve the `default` build, which
165
+ renders twice, on the server and in the browser, and must produce the same attributes both
166
+ times. It decides how to deliver the image once per mount, in a lazy `useState` initializer:
167
+
168
+ ```ts
169
+ serverDelivery(props, imageSigner) ?? // SSR: sign (the signer is a stub in the browser)
170
+ hydratedDelivery(props, useId()) ?? // hydration: read the server's decision from the DOM
171
+ browserDelivery(props.src); // browser-only render: as-is, or the /api/image redirect
172
+ ```
173
+
174
+ The server-rendered `<img>` carries `data-image-id` (from `useId`, identical in the server
175
+ render and during hydration) and `data-image-delivery`. While hydrating, the initializer finds
176
+ that `<img>` and pairs its `srcset` URLs with the widths next/image derives from the same
177
+ props. If the two lists differ in length, it falls back to the redirect rather than guess.
178
+
179
+ The signer reaches the package through the specifier `@all1ndev/next-signed-image/signer`.
180
+ `withSignedImages` aliases it, for Turbopack and webpack, to your module on the server and to
181
+ a stub that exports `undefined` in the browser. Without the alias, it resolves to a module
182
+ that throws a "wrap your next.config with withSignedImages" error.
183
+
184
+ Every render passes a `loader`: with `images.loader: "custom"` and no `loaderFile`, Next throws
185
+ "missing loader" before it reads `unoptimized`. That also makes a stray `next/image` fail
186
+ loudly instead of reaching `/_next/image`.
187
+
188
+ ## Caveats
189
+
190
+ - **Extra attributes.** A client `Image` renders `data-image-id` and `data-image-delivery` on
191
+ its `<img>`.
192
+ - **A DOM read during the first browser render.** Hydration calls
193
+ `document.querySelector` inside the `useState` initializer. It reads only the element React
194
+ is about to hydrate, and only once per mount.
195
+ - **Changing `src`.** A client `Image` whose `src` changes after mount goes through
196
+ `/api/image`: the browser cannot sign.
197
+ - **Env in prerendered shells.** Any server render of a signed source calls your signer, so
198
+ if it reads env, the usual rules apply (`await connection()` inside a prerendered shell).
199
+ - **`basePath`.** The route path is fixed at `/api/image`, without a `basePath` prefix.
200
+ - **Test runners** must alias `@all1ndev/next-signed-image/signer` to a signer module, as
201
+ `withSignedImages` does for Next (see `vitest.config.ts` in this repository).
202
+ - **Browser support.** Client code stays within the Safari 16.4 API floor (no `URL.parse`,
203
+ `Object.groupBy` or `Promise.withResolvers`).
204
+
205
+ ## License
206
+
207
+ MIT
@@ -0,0 +1,14 @@
1
+ import type { NextConfig } from "next";
2
+ import { type ImageSizes } from "./sizes.js";
3
+ export { defaultImageSizes, type ImageSizes } from "./sizes.js";
4
+ export type SignedImagesOptions = {
5
+ /** The app's signer module, relative to the project root. It exports `imageSigner`. */
6
+ signer: string;
7
+ /** Pass the same object to `createImageRoute`: a width the route rejects breaks every image. */
8
+ sizes?: ImageSizes;
9
+ };
10
+ /**
11
+ * Points the package's signer specifier at the app's signer on the server and at a stub in the
12
+ * browser, and makes every image go through a loader (no `/_next/image`).
13
+ */
14
+ export declare function withSignedImages(nextConfig: NextConfig, { signer, sizes }: SignedImagesOptions): NextConfig;
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { defaultImageSizes } from "./sizes.js";
4
+ export { defaultImageSizes } from "./sizes.js";
5
+ const SIGNER = "@all1ndev/next-signed-image/signer";
6
+ const BROWSER_SIGNER = "@all1ndev/next-signed-image/signer/browser";
7
+ /**
8
+ * Points the package's signer specifier at the app's signer on the server and at a stub in the
9
+ * browser, and makes every image go through a loader (no `/_next/image`).
10
+ */
11
+ export function withSignedImages(nextConfig, { signer, sizes = defaultImageSizes }) {
12
+ const { loaderFile: _loaderFile, ...images } = nextConfig.images ?? {};
13
+ const browserStub = fileURLToPath(new URL("./signer.browser.js", import.meta.url));
14
+ return {
15
+ ...nextConfig,
16
+ images: { ...images, loader: "custom", ...sizes },
17
+ turbopack: {
18
+ ...nextConfig.turbopack,
19
+ resolveAlias: {
20
+ ...nextConfig.turbopack?.resolveAlias,
21
+ // `browser` is the only condition Turbopack's resolveAlias supports.
22
+ [SIGNER]: { browser: BROWSER_SIGNER, default: signer },
23
+ },
24
+ },
25
+ webpack(config, context) {
26
+ const result = nextConfig.webpack ? nextConfig.webpack(config, context) : config;
27
+ result.resolve.alias = {
28
+ ...result.resolve.alias,
29
+ [`${SIGNER}$`]: context.isServer ? path.resolve(context.dir, signer) : browserStub,
30
+ };
31
+ return result;
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,64 @@
1
+ import { type ImageLoader, type ImageLoaderProps, type ImageProps as NextImageProps } from "next/image";
2
+ export { DEFAULT_QUALITY, defaultImageSizes, type ImageSizes } from "./sizes.js";
3
+ /**
4
+ * The app's signing policy and key, provider-agnostic. Lives in the app's own module (the one
5
+ * named by `withSignedImages({ signer })`), exported as `imageSigner`.
6
+ */
7
+ export type ImageSigner = {
8
+ /**
9
+ * How a remote `src` is served: `"as-is"` by its own URL (a file that gains nothing from a
10
+ * resize), `"sign"` through signed URLs, `"route"` refused (the route answers 403).
11
+ * Same-origin paths and SVGs never reach it: they are always served as-is.
12
+ */
13
+ delivery(src: string): "as-is" | "sign" | "route";
14
+ /** A signed URL for `src` resized to `width`. Only called after `delivery` returned "sign". */
15
+ sign(image: {
16
+ src: string;
17
+ width: number;
18
+ quality: number;
19
+ }): string;
20
+ };
21
+ export type ImageProps = Omit<NextImageProps, "loader" | "unoptimized">;
22
+ /** The URL a `src` points at: a static import's file, or the string itself. */
23
+ export declare function srcUrl(src: ImageProps["src"]): string;
24
+ /** Signed URLs by width. */
25
+ export type SignedWidths = Readonly<Record<number, string>>;
26
+ /**
27
+ * How an image is served: by its own URL, through the /api/image redirect, or from URLs signed
28
+ * on the server. `kind` is also the `data-image-delivery` value hydration reads.
29
+ */
30
+ export type Delivery = {
31
+ readonly kind: "as-is";
32
+ } | {
33
+ readonly kind: "route";
34
+ } | {
35
+ readonly kind: "signed";
36
+ readonly widths: SignedWidths;
37
+ };
38
+ export declare const AS_IS: Delivery;
39
+ export declare const ROUTE: Delivery;
40
+ export declare const IMAGE_ROUTE = "/api/image";
41
+ /**
42
+ * Served by URL, never signed, and decidable without the signer so the browser agrees:
43
+ * same-origin assets (/public, static imports) have no origin a resizer could fetch from, and
44
+ * SVGs are vectors with nothing to resize.
45
+ */
46
+ export declare function isServedAsIs(src: string): boolean;
47
+ /** What a render with no server behind it can decide: no key, no signing policy. */
48
+ export declare function browserDelivery(src: string): Delivery;
49
+ /**
50
+ * How an `Image` rendered on the server is served. A signed source is signed for exactly the
51
+ * widths next/image requests with these props. `undefined` without a signer (the browser).
52
+ */
53
+ export declare function serverDelivery(props: Readonly<ImageProps>, signer: ImageSigner | undefined): Delivery | undefined;
54
+ export declare function nextImageDelivery(delivery: Delivery): {
55
+ loader: ImageLoader;
56
+ unoptimized?: true;
57
+ };
58
+ /**
59
+ * Pairs a server-rendered `srcset` with the widths next/image derives from the same props,
60
+ * which it lists in the same order (`widthsSrcSet` is that srcset with each width as its URL).
61
+ * `undefined` when the two disagree.
62
+ */
63
+ export declare function signedWidthsFromSrcSet(srcSet: string, widthsSrcSet: string): SignedWidths | undefined;
64
+ export declare function imageRouteUrl({ src, width, quality }: ImageLoaderProps): string;
@@ -0,0 +1,106 @@
1
+ import { getImageProps, } from "next/image";
2
+ import { DEFAULT_QUALITY } from "./sizes.js";
3
+ export { DEFAULT_QUALITY, defaultImageSizes } from "./sizes.js";
4
+ /** The URL a `src` points at: a static import's file, or the string itself. */
5
+ export function srcUrl(src) {
6
+ if (typeof src === "string")
7
+ return src;
8
+ return "default" in src ? src.default.src : src.src;
9
+ }
10
+ export const AS_IS = { kind: "as-is" };
11
+ export const ROUTE = { kind: "route" };
12
+ export const IMAGE_ROUTE = "/api/image";
13
+ const SAME_ORIGIN_PROBE = "https://same-origin.invalid";
14
+ /**
15
+ * Served by URL, never signed, and decidable without the signer so the browser agrees:
16
+ * same-origin assets (/public, static imports) have no origin a resizer could fetch from, and
17
+ * SVGs are vectors with nothing to resize.
18
+ */
19
+ export function isServedAsIs(src) {
20
+ return isSameOriginPath(src) || isSvg(src);
21
+ }
22
+ /** What a render with no server behind it can decide: no key, no signing policy. */
23
+ export function browserDelivery(src) {
24
+ return isServedAsIs(src) ? AS_IS : ROUTE;
25
+ }
26
+ /**
27
+ * How an `Image` rendered on the server is served. A signed source is signed for exactly the
28
+ * widths next/image requests with these props. `undefined` without a signer (the browser).
29
+ */
30
+ export function serverDelivery(props, signer) {
31
+ if (!signer)
32
+ return undefined;
33
+ const src = srcUrl(props.src);
34
+ if (isServedAsIs(src))
35
+ return AS_IS;
36
+ const kind = signer.delivery(src);
37
+ if (kind === "as-is")
38
+ return AS_IS;
39
+ if (kind !== "sign")
40
+ return ROUTE;
41
+ const widths = {};
42
+ getImageProps({
43
+ ...props,
44
+ loader: ({ src, width, quality = DEFAULT_QUALITY }) => (widths[width] = signer.sign({ src, width, quality })),
45
+ });
46
+ return { kind: "signed", widths };
47
+ }
48
+ // Every path names a loader: with `images.loader: "custom"` and no loaderFile, Next throws
49
+ // "missing loader" before it reads `unoptimized`.
50
+ export function nextImageDelivery(delivery) {
51
+ if (delivery.kind === "as-is")
52
+ return { unoptimized: true, loader: imageRouteUrl };
53
+ if (delivery.kind === "route")
54
+ return { loader: imageRouteUrl };
55
+ const { widths } = delivery;
56
+ return {
57
+ loader: (props) => widths[props.width] ?? imageRouteUrl(props),
58
+ };
59
+ }
60
+ /**
61
+ * Pairs a server-rendered `srcset` with the widths next/image derives from the same props,
62
+ * which it lists in the same order (`widthsSrcSet` is that srcset with each width as its URL).
63
+ * `undefined` when the two disagree.
64
+ */
65
+ export function signedWidthsFromSrcSet(srcSet, widthsSrcSet) {
66
+ const urls = srcSetUrls(srcSet);
67
+ const widths = srcSetUrls(widthsSrcSet).map(Number);
68
+ if (urls.length === 0 || urls.length !== widths.length)
69
+ return undefined;
70
+ return Object.fromEntries(widths.map((width, index) => [width, urls[index] ?? ""]));
71
+ }
72
+ // Next joins candidates with ", "; a signed URL must hold no space (encode the source).
73
+ function srcSetUrls(srcSet) {
74
+ return srcSet
75
+ .split(", ")
76
+ .map((candidate) => candidate.trim().split(" ")[0] ?? "")
77
+ .filter(Boolean);
78
+ }
79
+ // The browser holds no key: /api/image signs and redirects.
80
+ export function imageRouteUrl({ src, width, quality }) {
81
+ const params = new URLSearchParams({
82
+ src,
83
+ w: String(width),
84
+ q: String(quality ?? DEFAULT_QUALITY),
85
+ });
86
+ return `${IMAGE_ROUTE}?${params}`;
87
+ }
88
+ // Resolve as the browser will, not by prefix: `//host`, `/\host` and a tab or newline
89
+ // between the slashes all leave the origin despite the leading slash.
90
+ function isSameOriginPath(src) {
91
+ if (!src.startsWith("/"))
92
+ return false;
93
+ return parseRelative(src)?.origin === SAME_ORIGIN_PROBE;
94
+ }
95
+ function isSvg(src) {
96
+ return parseRelative(src)?.pathname.toLowerCase().endsWith(".svg") ?? false;
97
+ }
98
+ // Not URL.parse: this runs in the browser, and URL.parse is above the Safari 16.4 floor.
99
+ function parseRelative(src) {
100
+ try {
101
+ return new URL(src, SAME_ORIGIN_PROBE);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
@@ -0,0 +1,9 @@
1
+ import { type JSX } from "react";
2
+ import { type ImageProps } from "./delivery.js";
3
+ export type { ImageProps } from "./delivery.js";
4
+ /**
5
+ * The `default` branch, for client components. Rendered on the server (SSR) it signs;
6
+ * hydrating, it reuses what the server rendered; only an image that first renders in the
7
+ * browser goes through the /api/image redirect.
8
+ */
9
+ export declare function Image(props: Readonly<ImageProps>): JSX.Element;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { imageSigner } from "@all1ndev/next-signed-image/signer";
3
+ import NextImage, { getImageProps } from "next/image";
4
+ import { useId, useState } from "react";
5
+ import { AS_IS, browserDelivery, nextImageDelivery, ROUTE, serverDelivery, signedWidthsFromSrcSet, srcUrl, } from "./delivery.js";
6
+ /**
7
+ * The `default` branch, for client components. Rendered on the server (SSR) it signs;
8
+ * hydrating, it reuses what the server rendered; only an image that first renders in the
9
+ * browser goes through the /api/image redirect.
10
+ */
11
+ export function Image(props) {
12
+ const id = useId();
13
+ const src = srcUrl(props.src);
14
+ // Lazy, once per mount: the first browser render must match the server's markup, and
15
+ // later renders keep that decision rather than falling back to the redirect.
16
+ const [first] = useState(() => ({
17
+ src,
18
+ delivery: serverDelivery(props, imageSigner) ??
19
+ hydratedDelivery(props, id) ??
20
+ browserDelivery(src),
21
+ }));
22
+ const delivery = first.src === src ? first.delivery : browserDelivery(src);
23
+ return (_jsx(NextImage, { ...props, ...nextImageDelivery(delivery), alt: props.alt, "data-image-delivery": delivery.kind, "data-image-id": id }));
24
+ }
25
+ // `useId` matches between the server render and hydration, and a browser-only render gets a
26
+ // different one, so finding the element means this render is hydrating it.
27
+ function hydratedDelivery(props, id) {
28
+ if (typeof document === "undefined")
29
+ return undefined;
30
+ const img = document.querySelector(`img[data-image-id="${CSS.escape(id)}"]`);
31
+ const kind = img?.getAttribute("data-image-delivery");
32
+ if (kind === "as-is")
33
+ return AS_IS;
34
+ if (kind === "route")
35
+ return ROUTE;
36
+ if (kind !== "signed")
37
+ return undefined;
38
+ const { srcSet: widthsSrcSet } = getImageProps({
39
+ ...props,
40
+ loader: ({ width }) => String(width),
41
+ }).props;
42
+ const widths = signedWidthsFromSrcSet(img?.getAttribute("srcset") ?? "", widthsSrcSet ?? "");
43
+ return widths && { kind: "signed", widths };
44
+ }
@@ -0,0 +1,4 @@
1
+ import type { JSX } from "react";
2
+ import { type ImageProps } from "./delivery.js";
3
+ export type { ImageProps } from "./delivery.js";
4
+ export declare function Image(props: Readonly<ImageProps>): JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { imageSigner } from "@all1ndev/next-signed-image/signer";
3
+ import { getImageProps } from "next/image";
4
+ import { preload } from "react-dom";
5
+ import { nextImageDelivery, ROUTE, serverDelivery } from "./delivery.js";
6
+ // The `react-server` branch: renders once, on the server, so it needs none of the client
7
+ // branch's hydration bookkeeping.
8
+ export function Image(props) {
9
+ const delivery = serverDelivery(props, imageSigner) ?? ROUTE;
10
+ const { props: imgProps } = getImageProps({ ...props, ...nextImageDelivery(delivery) });
11
+ // getImageProps only computes attributes; next/image's own preload hint comes from its
12
+ // component, which this <img> replaces, so the same options are passed here.
13
+ if (props.preload === true) {
14
+ preload(imgProps.src, {
15
+ as: "image",
16
+ imageSrcSet: imgProps.srcSet,
17
+ imageSizes: imgProps.sizes,
18
+ crossOrigin: imgProps.crossOrigin,
19
+ referrerPolicy: imgProps.referrerPolicy,
20
+ fetchPriority: imgProps.fetchPriority,
21
+ });
22
+ }
23
+ return _jsx("img", { ...imgProps, alt: props.alt });
24
+ }
@@ -0,0 +1,15 @@
1
+ import { type ImageSigner, type ImageSizes } from "./delivery.js";
2
+ export type { ImageSigner, ImageSizes } from "./delivery.js";
3
+ /**
4
+ * Marks the app's signer. Stands in for `server-only`, which the SSR pass of a client
5
+ * component cannot import: it throws if the signer module is ever evaluated in a browser.
6
+ */
7
+ export declare function defineImageSigner<Signer extends ImageSigner>(signer: Signer): Signer;
8
+ /**
9
+ * The App Router GET handler behind /api/image, for images that first render in the browser:
10
+ * 400 for a width or quality next/image would not request, 403 for a source the signer
11
+ * refuses, else a 302 to the signed URL or to the file itself.
12
+ */
13
+ export declare function createImageRoute(signer: ImageSigner, { sizes }?: {
14
+ sizes?: ImageSizes;
15
+ }): (request: Request) => Response;
package/dist/route.js ADDED
@@ -0,0 +1,54 @@
1
+ import { defaultImageSizes } from "./delivery.js";
2
+ /**
3
+ * Marks the app's signer. Stands in for `server-only`, which the SSR pass of a client
4
+ * component cannot import: it throws if the signer module is ever evaluated in a browser.
5
+ */
6
+ export function defineImageSigner(signer) {
7
+ if (typeof window !== "undefined") {
8
+ throw new Error("@all1ndev/next-signed-image: the image signer was bundled for the browser. Is next.config wrapped with withSignedImages?");
9
+ }
10
+ return signer;
11
+ }
12
+ /**
13
+ * The App Router GET handler behind /api/image, for images that first render in the browser:
14
+ * 400 for a width or quality next/image would not request, 403 for a source the signer
15
+ * refuses, else a 302 to the signed URL or to the file itself.
16
+ */
17
+ export function createImageRoute(signer, { sizes = defaultImageSizes } = {}) {
18
+ const widths = new Set([...sizes.deviceSizes, ...sizes.imageSizes]);
19
+ const qualities = new Set(sizes.qualities);
20
+ // Signing exists so outsiders cannot make the resizer render arbitrary sizes or sources.
21
+ return function GET(request) {
22
+ const params = new URL(request.url).searchParams;
23
+ const src = params.get("src");
24
+ const width = Number(params.get("w"));
25
+ const quality = Number(params.get("q"));
26
+ if (!src || !widths.has(width) || !qualities.has(quality)) {
27
+ return new Response("Bad request", { status: 400 });
28
+ }
29
+ const kind = signer.delivery(src);
30
+ if (kind === "sign")
31
+ return redirect(signer.sign({ src, width, quality }));
32
+ // The target is request input: redirect only to its normalized http(s) form.
33
+ const asIs = kind === "as-is" ? parseHttpUrl(src) : null;
34
+ if (asIs)
35
+ return redirect(asIs.href);
36
+ return new Response("Forbidden", { status: 403 });
37
+ };
38
+ }
39
+ // 302, not 308: a permanent redirect is cached for good and would outlive a key rotation.
40
+ function redirect(location) {
41
+ return new Response(null, {
42
+ status: 302,
43
+ headers: { Location: location, "Cache-Control": "public, max-age=86400" },
44
+ });
45
+ }
46
+ function parseHttpUrl(src) {
47
+ try {
48
+ const url = new URL(src);
49
+ return url.protocol === "https:" || url.protocol === "http:" ? url : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
@@ -0,0 +1,2 @@
1
+ import type { ImageSigner } from "./delivery.js";
2
+ export declare const imageSigner: ImageSigner | undefined;
@@ -0,0 +1,3 @@
1
+ // The browser branch of `@all1ndev/next-signed-image/signer`: the browser holds no key, so a client
2
+ // `Image` reads its server's decision back from the hydrated <img> instead.
3
+ export const imageSigner = undefined;
@@ -0,0 +1,2 @@
1
+ import type { ImageSigner } from "./delivery.js";
2
+ export declare const imageSigner: ImageSigner | undefined;
@@ -0,0 +1,6 @@
1
+ // What `@all1ndev/next-signed-image/signer` resolves to on the server when the app's next.config
2
+ // does not alias it to the app's signer module: evaluating it throws.
3
+ function unconfigured() {
4
+ throw new Error('@all1ndev/next-signed-image: no image signer is configured. Wrap your next.config with withSignedImages(config, { signer: "./src/image-signer.ts" }) from "@all1ndev/next-signed-image/config", or alias "@all1ndev/next-signed-image/signer" to that module in your test runner.');
5
+ }
6
+ export const imageSigner = unconfigured();
@@ -0,0 +1,8 @@
1
+ /** The widths and qualities next/image may request, and so the only ones the route signs. */
2
+ export type ImageSizes = {
3
+ deviceSizes: number[];
4
+ imageSizes: number[];
5
+ qualities: number[];
6
+ };
7
+ export declare const DEFAULT_QUALITY = 75;
8
+ export declare const defaultImageSizes: ImageSizes;
package/dist/sizes.js ADDED
@@ -0,0 +1,8 @@
1
+ export const DEFAULT_QUALITY = 75;
2
+ // Next's own defaults (`imageConfigDefault` in next/dist/shared/lib/image-config.js), pinned so
3
+ // a Next upgrade that changes them cannot desync the route from the loader.
4
+ export const defaultImageSizes = {
5
+ deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
6
+ imageSizes: [32, 48, 64, 96, 128, 256, 384],
7
+ qualities: [DEFAULT_QUALITY],
8
+ };
package/package.json CHANGED
@@ -1,6 +1,89 @@
1
1
  {
2
2
  "name": "@all1ndev/next-signed-image",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "A drop-in next/image replacement for image services that need server-side signed URLs (Thumbor, imgproxy, signed imgix/Cloudinary/S3), with no hydration mismatch.",
5
+ "keywords": [
6
+ "next",
7
+ "nextjs",
8
+ "next/image",
9
+ "image",
10
+ "signed-url",
11
+ "thumbor",
12
+ "imgproxy",
13
+ "react-server-components"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Andrei L (https://all1n.dev)",
17
+ "homepage": "https://github.com/all1ndev/next-signed-image/tree/main/packages/next-signed-image#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/all1ndev/next-signed-image.git",
21
+ "directory": "packages/next-signed-image"
22
+ },
23
+ "bugs": "https://github.com/all1ndev/next-signed-image/issues",
24
+ "type": "module",
25
+ "sideEffects": [
26
+ "./dist/signer.default.js"
27
+ ],
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/image.client.d.ts",
34
+ "react-server": "./dist/image.server.js",
35
+ "default": "./dist/image.client.js"
36
+ },
37
+ "./delivery": {
38
+ "types": "./dist/delivery.d.ts",
39
+ "default": "./dist/delivery.js"
40
+ },
41
+ "./route": {
42
+ "types": "./dist/route.d.ts",
43
+ "default": "./dist/route.js"
44
+ },
45
+ "./config": {
46
+ "types": "./dist/config.d.ts",
47
+ "default": "./dist/config.js"
48
+ },
49
+ "./signer": {
50
+ "types": "./dist/signer.default.d.ts",
51
+ "browser": "./dist/signer.browser.js",
52
+ "default": "./dist/signer.default.js"
53
+ },
54
+ "./signer/browser": {
55
+ "types": "./dist/signer.browser.d.ts",
56
+ "default": "./dist/signer.browser.js"
57
+ },
58
+ "./package.json": "./package.json"
59
+ },
60
+ "peerDependencies": {
61
+ "@types/react": ">=19",
62
+ "next": ">=16",
63
+ "react": ">=19",
64
+ "react-dom": ">=19"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "@types/react": {
68
+ "optional": true
69
+ }
70
+ },
71
+ "devDependencies": {
72
+ "@types/node": "24.19.0",
73
+ "@types/react": "19.3.0",
74
+ "@types/react-dom": "19.3.0",
75
+ "next": "16.4.2",
76
+ "react": "19.3.0",
77
+ "react-dom": "19.3.0",
78
+ "typescript": "7.1.0-beta",
79
+ "vitest": "4.1.12"
80
+ },
81
+ "engines": {
82
+ "node": ">=22"
83
+ },
84
+ "scripts": {
85
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
86
+ "test": "vitest run",
87
+ "typecheck": "tsc --noEmit"
88
+ }
6
89
  }