@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 +21 -0
- package/README.md +206 -2
- package/dist/config.d.ts +14 -0
- package/dist/config.js +34 -0
- package/dist/delivery.d.ts +64 -0
- package/dist/delivery.js +106 -0
- package/dist/image.client.d.ts +9 -0
- package/dist/image.client.js +44 -0
- package/dist/image.server.d.ts +4 -0
- package/dist/image.server.js +24 -0
- package/dist/route.d.ts +15 -0
- package/dist/route.js +54 -0
- package/dist/signer.browser.d.ts +2 -0
- package/dist/signer.browser.js +3 -0
- package/dist/signer.default.d.ts +2 -0
- package/dist/signer.default.js +6 -0
- package/dist/sizes.d.ts +8 -0
- package/dist/sizes.js +8 -0
- package/package.json +86 -3
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
|
-
#
|
|
1
|
+
# @all1ndev/next-signed-image
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/config.d.ts
ADDED
|
@@ -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;
|
package/dist/delivery.js
ADDED
|
@@ -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,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
|
+
}
|
package/dist/route.d.ts
ADDED
|
@@ -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,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();
|
package/dist/sizes.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|