@rebask/core 0.3.0 → 0.4.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/dist/components/image.d.ts +16 -3
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +50 -68
- package/dist/components/link.d.ts.map +1 -1
- package/dist/components/link.js +6 -2
- package/dist/lib/image-optimization/fetch-remote-image.d.ts +17 -0
- package/dist/lib/image-optimization/fetch-remote-image.d.ts.map +1 -0
- package/dist/lib/image-optimization/fetch-remote-image.js +157 -0
- package/dist/lib/image-optimization/get-img-props.d.ts +3 -5
- package/dist/lib/image-optimization/get-img-props.d.ts.map +1 -1
- package/dist/lib/image-optimization/get-img-props.js +66 -40
- package/dist/lib/image-optimization/handler.d.ts +0 -3
- package/dist/lib/image-optimization/handler.d.ts.map +1 -1
- package/dist/lib/image-optimization/handler.js +339 -124
- package/dist/lib/image-optimization/image-config.d.ts +31 -4
- package/dist/lib/image-optimization/image-config.d.ts.map +1 -1
- package/dist/lib/image-optimization/image-config.js +6 -0
- package/dist/lib/image-optimization/image-error.d.ts +8 -0
- package/dist/lib/image-optimization/image-error.d.ts.map +1 -0
- package/dist/lib/image-optimization/image-error.js +10 -0
- package/dist/lib/image-optimization/image-plugin.d.ts.map +1 -1
- package/dist/lib/image-optimization/image-plugin.js +27 -14
- package/dist/lib/image-optimization/is-private-ip.d.ts +2 -0
- package/dist/lib/image-optimization/is-private-ip.d.ts.map +1 -0
- package/dist/lib/image-optimization/is-private-ip.js +43 -0
- package/dist/lib/image-optimization/match-patterns.d.ts +4 -0
- package/dist/lib/image-optimization/match-patterns.d.ts.map +1 -0
- package/dist/lib/image-optimization/match-patterns.js +39 -0
- package/dist/lib/image-optimization/read-body.d.ts +2 -0
- package/dist/lib/image-optimization/read-body.d.ts.map +1 -0
- package/dist/lib/image-optimization/read-body.js +27 -0
- package/dist/lib/image-optimization/static-image-loader.d.ts +2 -0
- package/dist/lib/image-optimization/static-image-loader.d.ts.map +1 -0
- package/dist/lib/image-optimization/static-image-loader.js +60 -0
- package/dist/lib/server-router/build-entries.d.ts.map +1 -1
- package/dist/lib/server-router/route-entries.js +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +12 -4
- package/package.json +2 -1
- package/dist/__type_probe__.d.ts +0 -2
- package/dist/__type_probe__.d.ts.map +0 -1
- package/dist/__type_probe__.js +0 -4
- package/dist/components/error-boundary-client.d.ts +0 -49
- package/dist/components/error-boundary-client.d.ts.map +0 -1
- package/dist/components/error-boundary-client.js +0 -71
- package/dist/components/error-boundary.browser.d.ts +0 -8
- package/dist/components/error-boundary.browser.d.ts.map +0 -1
- package/dist/components/error-boundary.browser.js +0 -6
- package/dist/components/error.d.ts +0 -2
- package/dist/components/error.d.ts.map +0 -1
- package/dist/components/error.js +0 -5
- package/dist/components/not-found-activator.d.ts +0 -11
- package/dist/components/not-found-activator.d.ts.map +0 -1
- package/dist/components/not-found-activator.js +0 -26
- package/dist/components/not-found-boundary-client.d.ts +0 -3
- package/dist/components/not-found-boundary-client.d.ts.map +0 -1
- package/dist/components/not-found-boundary-client.js +0 -20
- package/dist/components/not-found-boundary.browser.d.ts +0 -8
- package/dist/components/not-found-boundary.browser.d.ts.map +0 -1
- package/dist/components/not-found-boundary.browser.js +0 -4
- package/dist/components/not-found-boundary.d.ts +0 -8
- package/dist/components/not-found-boundary.d.ts.map +0 -1
- package/dist/components/not-found-boundary.js +0 -9
- package/dist/components/not-found-thrower.d.ts +0 -2
- package/dist/components/not-found-thrower.d.ts.map +0 -1
- package/dist/components/not-found-thrower.js +0 -7
- package/dist/components/not-found.d.ts +0 -2
- package/dist/components/not-found.d.ts.map +0 -1
- package/dist/components/not-found.js +0 -5
- package/dist/components/status-page.d.ts +0 -6
- package/dist/components/status-page.d.ts.map +0 -1
- package/dist/components/status-page.js +0 -61
- package/dist/handler.d.ts +0 -12
- package/dist/handler.d.ts.map +0 -1
- package/dist/handler.js +0 -21
- package/dist/handlers/rsc/constants.d.ts +0 -3
- package/dist/handlers/rsc/constants.d.ts.map +0 -1
- package/dist/handlers/rsc/constants.js +0 -2
- package/dist/handlers/rsc/create-response.d.ts +0 -3
- package/dist/handlers/rsc/create-response.d.ts.map +0 -1
- package/dist/handlers/rsc/create-response.js +0 -378
- package/dist/handlers/rsc/render-with-retries.d.ts +0 -4
- package/dist/handlers/rsc/render-with-retries.d.ts.map +0 -1
- package/dist/handlers/rsc/render-with-retries.js +0 -154
- package/dist/handlers/rsc/retry-loop.d.ts +0 -3
- package/dist/handlers/rsc/retry-loop.d.ts.map +0 -1
- package/dist/handlers/rsc/retry-loop.js +0 -145
- package/dist/handlers/rsc/stream-rsc-payload.d.ts +0 -9
- package/dist/handlers/rsc/stream-rsc-payload.d.ts.map +0 -1
- package/dist/handlers/rsc/stream-rsc-payload.js +0 -20
- package/dist/handlers/rsc/types.d.ts +0 -26
- package/dist/handlers/rsc/types.d.ts.map +0 -1
- package/dist/handlers/rsc/types.js +0 -7
- package/dist/handlers/rsc.d.ts +0 -20
- package/dist/handlers/rsc.d.ts.map +0 -1
- package/dist/handlers/rsc.js +0 -872
- package/dist/integration.d.ts +0 -14
- package/dist/integration.d.ts.map +0 -1
- package/dist/integration.js +0 -9
- package/dist/lib/dev-module-load-race.d.ts +0 -8
- package/dist/lib/dev-module-load-race.d.ts.map +0 -1
- package/dist/lib/dev-module-load-race.js +0 -31
- package/dist/lib/dev-page-exports.d.ts +0 -5
- package/dist/lib/dev-page-exports.d.ts.map +0 -1
- package/dist/lib/dev-page-exports.js +0 -14
- package/dist/lib/environment-names.d.ts +0 -11
- package/dist/lib/environment-names.d.ts.map +0 -1
- package/dist/lib/environment-names.js +0 -10
- package/dist/lib/initial-hash-scroll.d.ts.map +0 -1
- package/dist/lib/not-found.d.ts +0 -5
- package/dist/lib/not-found.d.ts.map +0 -1
- package/dist/lib/not-found.js +0 -7
- package/dist/lib/page-exports.d.ts +0 -9
- package/dist/lib/page-exports.d.ts.map +0 -1
- package/dist/lib/page-exports.js +0 -47
- package/dist/lib/pattern-segments.d.ts +0 -8
- package/dist/lib/pattern-segments.d.ts.map +0 -1
- package/dist/lib/pattern-segments.js +0 -22
- package/dist/lib/ppr/cache-status.d.ts +0 -3
- package/dist/lib/ppr/cache-status.d.ts.map +0 -1
- package/dist/lib/ppr/cache-status.js +0 -5
- package/dist/lib/server-router-contract.d.ts +0 -42
- package/dist/lib/server-router-contract.d.ts.map +0 -1
- package/dist/lib/server-router-contract.js +0 -6
- package/dist/lib/server-router-loader.d.ts +0 -10
- package/dist/lib/server-router-loader.d.ts.map +0 -1
- package/dist/lib/server-router-loader.js +0 -180
- package/dist/lib/server-router.d.ts +0 -8
- package/dist/lib/server-router.d.ts.map +0 -1
- package/dist/lib/server-router.js +0 -173
- package/dist/plugins/ppr-upgrade.d.ts +0 -36
- package/dist/plugins/ppr-upgrade.d.ts.map +0 -1
- package/dist/plugins/ppr-upgrade.js +0 -32
- package/dist/plugins/server-router-fallback.d.ts +0 -15
- package/dist/plugins/server-router-fallback.d.ts.map +0 -1
- package/dist/plugins/server-router-fallback.js +0 -30
- package/dist/server.d.ts +0 -10
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -57
|
@@ -1,165 +1,380 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
4
|
import { imageConfig, publicDir } from "virtual:rebask/image-config";
|
|
5
|
+
import { LRUCache } from "../lru-cache.js";
|
|
6
|
+
import { fetchRemoteImage, PUBLIC_IMAGE_CACHE_CONTROL, } from "./fetch-remote-image.js";
|
|
4
7
|
import { imageConfigDefault } from "./image-config.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
import { ImageError, INVALID_UPSTREAM, NOT_ALLOWED, TIMED_OUT } from "./image-error.js";
|
|
9
|
+
import { hasLocalMatch, hasRemoteMatch } from "./match-patterns.js";
|
|
10
|
+
import { readBody } from "./read-body.js";
|
|
11
|
+
const LOCAL_FETCH_TIMEOUT_MS = 7000;
|
|
12
|
+
const LOCAL_URL_BASE = "http://n";
|
|
13
|
+
const isDev = import.meta.env.DEV;
|
|
14
|
+
const NOT_AN_IMAGE = "The requested resource isn't a valid image.";
|
|
15
|
+
const imageCache = new LRUCache(imageConfig?.maximumCacheSize ?? imageConfigDefault.maximumCacheSize, (image) => image.buffer.byteLength);
|
|
16
|
+
function resolveLocalImage(href, publicDir) {
|
|
17
|
+
const url = new URL(href, LOCAL_URL_BASE);
|
|
18
|
+
let pathname;
|
|
19
|
+
try {
|
|
20
|
+
pathname = decodeURIComponent(url.pathname);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (pathname.includes("\0"))
|
|
26
|
+
return;
|
|
27
|
+
const fullPath = resolve(publicDir, `.${pathname}`);
|
|
28
|
+
const relativePath = relative(publicDir, fullPath);
|
|
29
|
+
// On Windows, a colon in the name makes `relative` return an absolute path.
|
|
30
|
+
if (!relativePath ||
|
|
31
|
+
relativePath === ".." ||
|
|
32
|
+
relativePath.startsWith(`..${sep}`) ||
|
|
33
|
+
isAbsolute(relativePath)) {
|
|
34
|
+
return;
|
|
15
35
|
}
|
|
16
|
-
|
|
17
|
-
return Buffer.from(arrayBuffer);
|
|
36
|
+
return { path: fullPath, pathname: `/${relativePath.split(sep).join("/")}`, search: url.search };
|
|
18
37
|
}
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
function getFileName(href, contentType) {
|
|
39
|
+
const name = new URL(href, LOCAL_URL_BASE).pathname.split("/").pop();
|
|
40
|
+
const base = name?.replace(/\.[^.]*$/, "") || "image";
|
|
41
|
+
const extension = contentType === "image/jpeg" ? "jpg" : contentType.slice("image/".length).split("+")[0];
|
|
42
|
+
return `${base}.${extension}`;
|
|
43
|
+
}
|
|
44
|
+
function getResponseCacheControl(cacheControl, serverMaxAge) {
|
|
45
|
+
if (cacheControl.noStore)
|
|
46
|
+
return "no-store";
|
|
47
|
+
const scope = cacheControl.private ? "private" : "public";
|
|
48
|
+
const browserMaxAge = cacheControl.noCache
|
|
49
|
+
? 0
|
|
50
|
+
: (cacheControl.browserMaxAge ??
|
|
51
|
+
(cacheControl.private || cacheControl.maxAge !== undefined ? 0 : serverMaxAge));
|
|
52
|
+
const directives = [scope, `max-age=${isDev ? 0 : browserMaxAge}`];
|
|
53
|
+
if (!isDev &&
|
|
54
|
+
!cacheControl.private &&
|
|
55
|
+
!cacheControl.noCache &&
|
|
56
|
+
cacheControl.sharedMaxAge !== undefined) {
|
|
57
|
+
directives.push(`s-maxage=${cacheControl.sharedMaxAge}`);
|
|
28
58
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
59
|
+
directives.push("must-revalidate");
|
|
60
|
+
return directives.join(", ");
|
|
61
|
+
}
|
|
62
|
+
async function fetchLocalAsset(href, request, maximumResponseBody) {
|
|
63
|
+
const requestUrl = new URL(request.url);
|
|
64
|
+
const url = new URL(href, requestUrl);
|
|
65
|
+
if (url.origin !== requestUrl.origin) {
|
|
66
|
+
throw new ImageError(400, NOT_ALLOWED);
|
|
34
67
|
}
|
|
35
|
-
|
|
36
|
-
|
|
68
|
+
const signal = AbortSignal.timeout(LOCAL_FETCH_TIMEOUT_MS);
|
|
69
|
+
const response = await fetch(url, { redirect: "manual", signal }).catch(() => {
|
|
70
|
+
if (signal.aborted) {
|
|
71
|
+
throw new ImageError(504, TIMED_OUT);
|
|
72
|
+
}
|
|
73
|
+
throw new ImageError(400, INVALID_UPSTREAM);
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok || !response.body) {
|
|
76
|
+
await response.body?.cancel();
|
|
77
|
+
throw new ImageError(400, INVALID_UPSTREAM);
|
|
37
78
|
}
|
|
38
|
-
|
|
39
|
-
|
|
79
|
+
return readBody(response.body, maximumResponseBody, signal);
|
|
80
|
+
}
|
|
81
|
+
function sendImage(request, image, config, cacheControl, cacheStatus, href) {
|
|
82
|
+
const headers = new Headers({
|
|
83
|
+
Vary: "Accept",
|
|
84
|
+
"Cache-Control": cacheControl,
|
|
85
|
+
"X-Rebask-Cache": cacheStatus,
|
|
86
|
+
ETag: image.etag,
|
|
87
|
+
});
|
|
88
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
89
|
+
if (ifNoneMatch
|
|
90
|
+
?.split(",")
|
|
91
|
+
.some((value) => value.trim() === "*" || value.trim().replace(/^W\//, "") === image.etag)) {
|
|
92
|
+
return new Response(null, { status: 304, headers });
|
|
40
93
|
}
|
|
41
|
-
|
|
42
|
-
|
|
94
|
+
headers.set("Content-Type", image.contentType);
|
|
95
|
+
headers.set("Content-Length", String(image.buffer.byteLength));
|
|
96
|
+
headers.set("Content-Security-Policy", config.contentSecurityPolicy);
|
|
97
|
+
headers.set("Content-Disposition", `${config.contentDispositionType}; filename="${getFileName(href, image.contentType)}"`);
|
|
98
|
+
if (request.method === "HEAD") {
|
|
99
|
+
return new Response(null, { status: 200, headers });
|
|
43
100
|
}
|
|
44
|
-
|
|
101
|
+
const body = new Uint8Array(image.buffer.byteLength);
|
|
102
|
+
body.set(image.buffer);
|
|
103
|
+
return new Response(body, { status: 200, headers });
|
|
104
|
+
}
|
|
105
|
+
function hasFtypBrand(buffer, brand) {
|
|
106
|
+
if (buffer.byteLength < 16)
|
|
107
|
+
return false;
|
|
108
|
+
const boxSize = buffer.readUInt32BE(0);
|
|
109
|
+
if (boxSize < 16 || boxSize > buffer.byteLength)
|
|
110
|
+
return false;
|
|
111
|
+
if (buffer.toString("ascii", 4, 8) !== "ftyp")
|
|
112
|
+
return false;
|
|
113
|
+
// The major brand and the compatible brands all live inside the ftyp box.
|
|
114
|
+
return buffer.subarray(8, boxSize).includes(brand);
|
|
45
115
|
}
|
|
46
116
|
function detectContentType(buffer) {
|
|
47
|
-
// Simple detection based on magic bytes
|
|
48
117
|
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
49
118
|
return "image/jpeg";
|
|
50
119
|
}
|
|
51
|
-
if (buffer[0] === 0x89 && buffer[1] === 0x50) {
|
|
120
|
+
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
|
52
121
|
return "image/png";
|
|
53
122
|
}
|
|
54
|
-
|
|
123
|
+
const magic = buffer.toString("ascii", 0, 4);
|
|
124
|
+
if (magic === "GIF8") {
|
|
55
125
|
return "image/gif";
|
|
56
126
|
}
|
|
57
|
-
if (
|
|
127
|
+
if (magic === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
|
|
58
128
|
return "image/webp";
|
|
59
129
|
}
|
|
60
|
-
|
|
61
|
-
|
|
130
|
+
if (hasFtypBrand(buffer, "avif")) {
|
|
131
|
+
return "image/avif";
|
|
132
|
+
}
|
|
133
|
+
if (hasFtypBrand(buffer, "heic") ||
|
|
134
|
+
hasFtypBrand(buffer, "heix") ||
|
|
135
|
+
hasFtypBrand(buffer, "hevc") ||
|
|
136
|
+
hasFtypBrand(buffer, "hevx")) {
|
|
137
|
+
return "image/heic";
|
|
138
|
+
}
|
|
139
|
+
const head = buffer.subarray(0, 1024).toString("utf8").trimStart();
|
|
140
|
+
if (/^(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE[^>]*>\s*)?<svg[\s/>]/i.test(head)) {
|
|
141
|
+
return "image/svg+xml";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function isAnimated(sharp, buffer, contentType) {
|
|
145
|
+
if (contentType !== "image/gif" && contentType !== "image/webp")
|
|
146
|
+
return false;
|
|
147
|
+
const metadata = await sharp(buffer).metadata();
|
|
148
|
+
return (metadata.pages ?? 1) > 1;
|
|
149
|
+
}
|
|
150
|
+
function negotiateFormat(accept, formats) {
|
|
151
|
+
const accepted = new Map();
|
|
152
|
+
for (const part of accept.split(",")) {
|
|
153
|
+
const [rawType, ...params] = part.split(";");
|
|
154
|
+
const type = rawType?.trim().toLowerCase();
|
|
155
|
+
if (!type)
|
|
156
|
+
continue;
|
|
157
|
+
let quality = 1;
|
|
158
|
+
for (const param of params) {
|
|
159
|
+
const [key, value] = param.split("=");
|
|
160
|
+
if (key?.trim().toLowerCase() === "q") {
|
|
161
|
+
quality = Number.parseFloat(value?.trim().replace(/^"|"$/g, "") ?? "1");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
accepted.set(type, Number.isNaN(quality) ? 0 : quality);
|
|
165
|
+
}
|
|
166
|
+
function qualityFor(type) {
|
|
167
|
+
return accepted.get(type) ?? accepted.get("image/*") ?? accepted.get("*/*") ?? 0;
|
|
168
|
+
}
|
|
169
|
+
let format;
|
|
170
|
+
let bestQuality = 0;
|
|
171
|
+
for (const candidate of formats) {
|
|
172
|
+
const quality = qualityFor(candidate);
|
|
173
|
+
if (quality > bestQuality) {
|
|
174
|
+
bestQuality = quality;
|
|
175
|
+
format = candidate;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
format,
|
|
180
|
+
acceptsAvif: qualityFor("image/avif") > 0,
|
|
181
|
+
acceptsWebp: qualityFor("image/webp") > 0,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function validateUrlParam(rawUrl, config) {
|
|
185
|
+
if (!rawUrl) {
|
|
186
|
+
return { error: '"url" parameter is required' };
|
|
187
|
+
}
|
|
188
|
+
if (rawUrl.startsWith("//")) {
|
|
189
|
+
return { error: '"url" parameter cannot be a protocol-relative URL' };
|
|
190
|
+
}
|
|
191
|
+
if (rawUrl.startsWith("/")) {
|
|
192
|
+
if (/[\\\t\n\r]/.test(rawUrl)) {
|
|
193
|
+
return { error: '"url" parameter is invalid' };
|
|
194
|
+
}
|
|
195
|
+
const imagePath = config.path.replace(/\/$/, "");
|
|
196
|
+
if (imagePath) {
|
|
197
|
+
let pathname;
|
|
198
|
+
try {
|
|
199
|
+
pathname = decodeURIComponent(new URL(rawUrl, LOCAL_URL_BASE).pathname);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
pathname = undefined;
|
|
203
|
+
}
|
|
204
|
+
if (pathname && (pathname === imagePath || pathname.startsWith(`${imagePath}/`))) {
|
|
205
|
+
return { error: '"url" parameter cannot be recursive' };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { href: rawUrl, isLocal: true };
|
|
209
|
+
}
|
|
210
|
+
let url;
|
|
211
|
+
try {
|
|
212
|
+
url = new URL(rawUrl);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return { error: '"url" parameter is invalid' };
|
|
216
|
+
}
|
|
217
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
218
|
+
return { error: '"url" parameter is invalid' };
|
|
219
|
+
}
|
|
220
|
+
if (!hasRemoteMatch(config.remotePatterns, url)) {
|
|
221
|
+
return { error: NOT_ALLOWED };
|
|
222
|
+
}
|
|
223
|
+
return { href: url.href, isLocal: false };
|
|
62
224
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Handler for image optimization requests.
|
|
65
|
-
*/
|
|
66
225
|
export async function handleImage(request, sharp) {
|
|
67
|
-
|
|
226
|
+
const config = imageConfig;
|
|
227
|
+
if (!config) {
|
|
68
228
|
return new Response("Image optimization is not configured", { status: 404 });
|
|
69
229
|
}
|
|
70
|
-
const
|
|
71
|
-
...imageConfigDefault,
|
|
72
|
-
...imageConfig,
|
|
73
|
-
};
|
|
74
|
-
const url = new URL(request.url);
|
|
75
|
-
const params = url.searchParams;
|
|
76
|
-
const imageUrl = params.get("url");
|
|
230
|
+
const params = new URL(request.url).searchParams;
|
|
77
231
|
const widthStr = params.get("w");
|
|
78
232
|
const qualityStr = params.get("q");
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
233
|
+
if (!widthStr) {
|
|
234
|
+
return new Response('"w" parameter (width) is required', { status: 400 });
|
|
235
|
+
}
|
|
236
|
+
const urlResult = validateUrlParam(params.get("url"), config);
|
|
237
|
+
if ("error" in urlResult) {
|
|
238
|
+
return new Response(urlResult.error, { status: 400 });
|
|
239
|
+
}
|
|
240
|
+
if (!/^[0-9]+$/.test(widthStr)) {
|
|
241
|
+
return new Response('"w" parameter (width) must be an integer greater than 0', {
|
|
82
242
|
status: 400,
|
|
83
243
|
});
|
|
84
244
|
}
|
|
85
245
|
const width = parseInt(widthStr, 10);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (Number.isNaN(width) || width <= 0) {
|
|
89
|
-
return new Response("Invalid width parameter", {
|
|
90
|
-
status: 400,
|
|
91
|
-
});
|
|
246
|
+
if (!config.deviceSizes.includes(width) && !config.imageSizes.includes(width)) {
|
|
247
|
+
return new Response(`"w" parameter (width) of ${width} is not allowed`, { status: 400 });
|
|
92
248
|
}
|
|
93
|
-
|
|
94
|
-
|
|
249
|
+
const quality = qualityStr ? parseInt(qualityStr, 10) : (config.qualities?.[0] ?? 75);
|
|
250
|
+
if ((qualityStr && !/^[0-9]+$/.test(qualityStr)) || quality < 1 || quality > 100) {
|
|
251
|
+
return new Response('"q" parameter (quality) must be an integer between 1 and 100', {
|
|
95
252
|
status: 400,
|
|
96
253
|
});
|
|
97
254
|
}
|
|
255
|
+
if (config.qualities?.length && !config.qualities.includes(quality)) {
|
|
256
|
+
return new Response(`"q" parameter (quality) of ${quality} is not allowed`, { status: 400 });
|
|
257
|
+
}
|
|
98
258
|
try {
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
259
|
+
const { href, isLocal } = urlResult;
|
|
260
|
+
const { format: negotiatedFormat, acceptsAvif, acceptsWebp, } = negotiateFormat(request.headers.get("accept") ?? "", config.formats);
|
|
261
|
+
const formatKey = negotiatedFormat ?? `source|${acceptsAvif}|${acceptsWebp}`;
|
|
262
|
+
const cacheKey = `${href}\n${width}\n${quality}\n${formatKey}`;
|
|
263
|
+
const cached = isDev ? undefined : imageCache.get(cacheKey);
|
|
264
|
+
if (cached) {
|
|
265
|
+
if (cached.expiresAt > Date.now()) {
|
|
266
|
+
return sendImage(request, cached, config, cached.cacheControl, "HIT", href);
|
|
267
|
+
}
|
|
268
|
+
imageCache.delete(cacheKey);
|
|
269
|
+
}
|
|
270
|
+
let imageBuffer;
|
|
271
|
+
let upstreamCacheControl = PUBLIC_IMAGE_CACHE_CONTROL;
|
|
272
|
+
if (isLocal) {
|
|
273
|
+
if (publicDir) {
|
|
274
|
+
const local = resolveLocalImage(href, publicDir);
|
|
275
|
+
if (!local)
|
|
276
|
+
return new Response(NOT_ALLOWED, { status: 400 });
|
|
277
|
+
try {
|
|
278
|
+
imageBuffer = await readFile(local.path);
|
|
279
|
+
if (!hasLocalMatch(config.localPatterns, local.pathname, local.search)) {
|
|
280
|
+
return new Response(NOT_ALLOWED, { status: 400 });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
const code = error.code;
|
|
285
|
+
if (code === "ENOENT") {
|
|
286
|
+
// Static imports are Vite-emitted assets outside publicDir. Fetch them from the app
|
|
287
|
+
// itself so they go through the optimizer.
|
|
288
|
+
imageBuffer = await fetchLocalAsset(href, request, config.maximumResponseBody);
|
|
289
|
+
}
|
|
290
|
+
else if (code === "EISDIR" || code === "ENOTDIR") {
|
|
291
|
+
throw new ImageError(400, NOT_AN_IMAGE);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
imageBuffer = await fetchLocalAsset(href, request, config.maximumResponseBody);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const remote = await fetchRemoteImage(href, config);
|
|
304
|
+
imageBuffer = remote.buffer;
|
|
305
|
+
upstreamCacheControl = remote.cacheControl;
|
|
306
|
+
}
|
|
307
|
+
const maxAge = !upstreamCacheControl.noStore &&
|
|
308
|
+
!upstreamCacheControl.private &&
|
|
309
|
+
!upstreamCacheControl.noCache &&
|
|
310
|
+
(upstreamCacheControl.maxAge === undefined || upstreamCacheControl.maxAge > 0)
|
|
311
|
+
? Math.max(config.minimumCacheTTL, upstreamCacheControl.maxAge ?? 0)
|
|
312
|
+
: 0;
|
|
313
|
+
const responseCacheControl = getResponseCacheControl(upstreamCacheControl, maxAge);
|
|
314
|
+
const respond = (buffer, responseType) => {
|
|
315
|
+
const etag = `"${createHash("sha256").update(buffer).digest("base64url")}"`;
|
|
316
|
+
if (!isDev && maxAge > 0) {
|
|
317
|
+
imageCache.set(cacheKey, {
|
|
318
|
+
buffer,
|
|
319
|
+
contentType: responseType,
|
|
320
|
+
etag,
|
|
321
|
+
cacheControl: responseCacheControl,
|
|
322
|
+
expiresAt: Date.now() + maxAge * 1000,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return sendImage(request, { buffer, contentType: responseType, etag }, config, responseCacheControl, "MISS", href);
|
|
326
|
+
};
|
|
327
|
+
const sourceType = detectContentType(imageBuffer);
|
|
328
|
+
if (!sourceType) {
|
|
329
|
+
throw new ImageError(400, NOT_AN_IMAGE);
|
|
330
|
+
}
|
|
331
|
+
if (sourceType === "image/svg+xml") {
|
|
332
|
+
if (!config.dangerouslyAllowSVG) {
|
|
333
|
+
throw new ImageError(400, '"url" parameter is valid but image type is not allowed');
|
|
334
|
+
}
|
|
335
|
+
return respond(imageBuffer, sourceType);
|
|
336
|
+
}
|
|
337
|
+
if (sourceType === "image/heic" || (await isAnimated(sharp, imageBuffer, sourceType))) {
|
|
338
|
+
return respond(imageBuffer, sourceType);
|
|
339
|
+
}
|
|
340
|
+
let contentType = sourceType;
|
|
341
|
+
if (negotiatedFormat) {
|
|
342
|
+
contentType = negotiatedFormat;
|
|
343
|
+
}
|
|
344
|
+
else if ((sourceType === "image/webp" && !acceptsWebp) ||
|
|
345
|
+
(sourceType === "image/avif" && !acceptsAvif)) {
|
|
346
|
+
contentType = "image/jpeg";
|
|
347
|
+
}
|
|
348
|
+
let optimizedBuffer;
|
|
349
|
+
try {
|
|
350
|
+
const transformer = sharp(imageBuffer).rotate().timeout({ seconds: 7 });
|
|
351
|
+
transformer.resize(width, undefined, { withoutEnlargement: true });
|
|
352
|
+
if (contentType === "image/avif") {
|
|
353
|
+
// Match the visual quality of the same webp quality.
|
|
354
|
+
transformer.avif({ quality: Math.max(Math.round(quality * (50 / 80)), 1), effort: 3 });
|
|
355
|
+
}
|
|
356
|
+
else if (contentType === "image/webp") {
|
|
357
|
+
transformer.webp({ quality });
|
|
358
|
+
}
|
|
359
|
+
else if (contentType === "image/png") {
|
|
360
|
+
transformer.png({ quality });
|
|
361
|
+
}
|
|
362
|
+
else if (contentType === "image/jpeg") {
|
|
363
|
+
transformer.jpeg({ quality, mozjpeg: true });
|
|
364
|
+
}
|
|
365
|
+
optimizedBuffer = await transformer.toBuffer();
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
console.error("[rebask] Image optimization failed:", error);
|
|
369
|
+
return respond(imageBuffer, sourceType);
|
|
370
|
+
}
|
|
371
|
+
return respond(optimizedBuffer, contentType);
|
|
158
372
|
}
|
|
159
373
|
catch (error) {
|
|
374
|
+
if (error instanceof ImageError) {
|
|
375
|
+
return new Response(error.message, { status: error.statusCode });
|
|
376
|
+
}
|
|
160
377
|
console.error("[rebask] Image optimization error:", error);
|
|
161
|
-
return new Response(
|
|
162
|
-
status: 500,
|
|
163
|
-
});
|
|
378
|
+
return new Response("Image optimization failed", { status: 500 });
|
|
164
379
|
}
|
|
165
380
|
}
|
|
@@ -4,11 +4,24 @@ export type ImageLoaderProps = {
|
|
|
4
4
|
quality?: number;
|
|
5
5
|
config: Readonly<ImageConfigComplete>;
|
|
6
6
|
};
|
|
7
|
-
type ImageFormat = "image/avif" | "image/webp";
|
|
8
|
-
type
|
|
7
|
+
export type ImageFormat = "image/avif" | "image/webp";
|
|
8
|
+
export type ImageBlur = {
|
|
9
|
+
dataURL: string;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
export type LocalPattern = {
|
|
14
|
+
/** `*` matches one path segment, `**` matches any number of segments. */
|
|
9
15
|
pathname?: string;
|
|
16
|
+
/** Exact query string including the leading `?`. Empty means no query. Omit to allow any. */
|
|
10
17
|
search?: string;
|
|
11
18
|
};
|
|
19
|
+
export type RemotePattern = LocalPattern & {
|
|
20
|
+
protocol?: "http" | "https";
|
|
21
|
+
/** `*` matches one subdomain segment, `**` matches any number of segments. */
|
|
22
|
+
hostname: string;
|
|
23
|
+
port?: string;
|
|
24
|
+
};
|
|
12
25
|
/**
|
|
13
26
|
* Image configurations
|
|
14
27
|
*/
|
|
@@ -31,8 +44,23 @@ export type ImageConfigComplete = {
|
|
|
31
44
|
qualities: number[] | undefined;
|
|
32
45
|
/** Unoptimized mode */
|
|
33
46
|
unoptimized: boolean;
|
|
34
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Patterns for files under the public directory (undefined = allow all).
|
|
49
|
+
* Static imports are always allowed.
|
|
50
|
+
*/
|
|
35
51
|
localPatterns?: LocalPattern[];
|
|
52
|
+
/** Remote image URL patterns (default: allow no remote images) */
|
|
53
|
+
remotePatterns: RemotePattern[];
|
|
54
|
+
/** Allow SVG images to be served by the optimizer. */
|
|
55
|
+
dangerouslyAllowSVG: boolean;
|
|
56
|
+
/** Allow remote images to resolve to private or loopback addresses. */
|
|
57
|
+
dangerouslyAllowLocalIP: boolean;
|
|
58
|
+
/** Maximum number of redirects to follow for a remote image. */
|
|
59
|
+
maximumRedirects: number;
|
|
60
|
+
/** Maximum number of bytes to download for a remote image. */
|
|
61
|
+
maximumResponseBody: number;
|
|
62
|
+
/** Maximum number of bytes kept in the in-memory image cache. */
|
|
63
|
+
maximumCacheSize: number;
|
|
36
64
|
/**
|
|
37
65
|
* Path to the image handler file, relative to the project root.
|
|
38
66
|
* @default "rebask.image.ts" or "rebask.image.js"
|
|
@@ -41,5 +69,4 @@ export type ImageConfigComplete = {
|
|
|
41
69
|
};
|
|
42
70
|
export type ImageConfig = Partial<ImageConfigComplete>;
|
|
43
71
|
export declare const imageConfigDefault: ImageConfigComplete;
|
|
44
|
-
export {};
|
|
45
72
|
//# sourceMappingURL=image-config.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-config.d.ts","sourceRoot":"","sources":["../../../src/lib/image-optimization/image-config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG;IAC9B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAA;CACrC,CAAA;AAED,
|
|
1
|
+
{"version":3,"file":"image-config.d.ts","sourceRoot":"","sources":["../../../src/lib/image-optimization/image-config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG;IAC9B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAA;CACrC,CAAA;AAED,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,YAAY,CAAA;AAErD,MAAM,MAAM,SAAS,GAAG;IACvB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6FAA6F;IAC7F,MAAM,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAED,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG;IAC1C,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IAC3B,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG;IACjC,yCAAyC;IACzC,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,wCAAwC;IACxC,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAA;IACZ,mCAAmC;IACnC,eAAe,EAAE,MAAM,CAAA;IACvB,yBAAyB;IACzB,OAAO,EAAE,WAAW,EAAE,CAAA;IACtB,8BAA8B;IAC9B,qBAAqB,EAAE,MAAM,CAAA;IAC7B,+BAA+B;IAC/B,sBAAsB,EAAE,QAAQ,GAAG,YAAY,CAAA;IAC/C,qBAAqB;IACrB,SAAS,EAAE,MAAM,EAAE,GAAG,SAAS,CAAA;IAC/B,uBAAuB;IACvB,WAAW,EAAE,OAAO,CAAA;IACpB;;;OAGG;IACH,aAAa,CAAC,EAAE,YAAY,EAAE,CAAA;IAC9B,kEAAkE;IAClE,cAAc,EAAE,aAAa,EAAE,CAAA;IAC/B,sDAAsD;IACtD,mBAAmB,EAAE,OAAO,CAAA;IAC5B,uEAAuE;IACvE,uBAAuB,EAAE,OAAO,CAAA;IAChC,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,CAAA;IACxB,8DAA8D;IAC9D,mBAAmB,EAAE,MAAM,CAAA;IAC3B,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,CAAA;IACxB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAA;AAEtD,eAAO,MAAM,kBAAkB,EAAE,mBAgBhC,CAAA"}
|
|
@@ -6,6 +6,12 @@ export const imageConfigDefault = {
|
|
|
6
6
|
formats: ["image/webp"],
|
|
7
7
|
contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`,
|
|
8
8
|
contentDispositionType: "attachment",
|
|
9
|
+
remotePatterns: [],
|
|
10
|
+
dangerouslyAllowSVG: false,
|
|
11
|
+
dangerouslyAllowLocalIP: false,
|
|
12
|
+
maximumRedirects: 3,
|
|
13
|
+
maximumResponseBody: 50_000_000,
|
|
14
|
+
maximumCacheSize: 50_000_000,
|
|
9
15
|
qualities: [75],
|
|
10
16
|
unoptimized: false,
|
|
11
17
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const NOT_ALLOWED = "\"url\" parameter is not allowed";
|
|
2
|
+
export declare const INVALID_UPSTREAM = "\"url\" parameter is valid but upstream response is invalid";
|
|
3
|
+
export declare const TIMED_OUT = "\"url\" parameter is valid but upstream response timed out";
|
|
4
|
+
export declare class ImageError extends Error {
|
|
5
|
+
readonly statusCode: number;
|
|
6
|
+
constructor(statusCode: number, message: string);
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=image-error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-error.d.ts","sourceRoot":"","sources":["../../../src/lib/image-optimization/image-error.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,qCAAmC,CAAA;AAC3D,eAAO,MAAM,gBAAgB,gEAA8D,CAAA;AAC3F,eAAO,MAAM,SAAS,+DAA6D,CAAA;AAEnF,qBAAa,UAAW,SAAQ,KAAK;IACpC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAE3B,YAAY,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAG9C;CACD"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const NOT_ALLOWED = '"url" parameter is not allowed';
|
|
2
|
+
export const INVALID_UPSTREAM = '"url" parameter is valid but upstream response is invalid';
|
|
3
|
+
export const TIMED_OUT = '"url" parameter is valid but upstream response timed out';
|
|
4
|
+
export class ImageError extends Error {
|
|
5
|
+
statusCode;
|
|
6
|
+
constructor(statusCode, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.statusCode = statusCode;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-plugin.d.ts","sourceRoot":"","sources":["../../../src/lib/image-optimization/image-plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAClC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,mBAAmB,EAAsB,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"image-plugin.d.ts","sourceRoot":"","sources":["../../../src/lib/image-optimization/image-plugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAClC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,mBAAmB,EAAsB,MAAM,mBAAmB,CAAA;AAMlG,qCAAqC;AACrC,eAAO,MAAM,wBAAwB,iBAAiB,CAAA;AA4BtD,MAAM,WAAW,oBAAoB;IACpC,2CAA2C;IAC3C,cAAc,EAAE,MAAM,mBAAmB,CAAA;IACzC,8CAA8C;IAC9C,UAAU,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;IAClC;;;OAGG;IACH,cAAc,EAAE,MAAM,IAAI,CAAA;CAC1B;AAED,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,WAAW,GAAG,MAAM,CAgGzD"}
|