@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.
Files changed (139) hide show
  1. package/dist/components/image.d.ts +16 -3
  2. package/dist/components/image.d.ts.map +1 -1
  3. package/dist/components/image.js +50 -68
  4. package/dist/components/link.d.ts.map +1 -1
  5. package/dist/components/link.js +6 -2
  6. package/dist/lib/image-optimization/fetch-remote-image.d.ts +17 -0
  7. package/dist/lib/image-optimization/fetch-remote-image.d.ts.map +1 -0
  8. package/dist/lib/image-optimization/fetch-remote-image.js +157 -0
  9. package/dist/lib/image-optimization/get-img-props.d.ts +3 -5
  10. package/dist/lib/image-optimization/get-img-props.d.ts.map +1 -1
  11. package/dist/lib/image-optimization/get-img-props.js +66 -40
  12. package/dist/lib/image-optimization/handler.d.ts +0 -3
  13. package/dist/lib/image-optimization/handler.d.ts.map +1 -1
  14. package/dist/lib/image-optimization/handler.js +339 -124
  15. package/dist/lib/image-optimization/image-config.d.ts +31 -4
  16. package/dist/lib/image-optimization/image-config.d.ts.map +1 -1
  17. package/dist/lib/image-optimization/image-config.js +6 -0
  18. package/dist/lib/image-optimization/image-error.d.ts +8 -0
  19. package/dist/lib/image-optimization/image-error.d.ts.map +1 -0
  20. package/dist/lib/image-optimization/image-error.js +10 -0
  21. package/dist/lib/image-optimization/image-plugin.d.ts.map +1 -1
  22. package/dist/lib/image-optimization/image-plugin.js +27 -14
  23. package/dist/lib/image-optimization/is-private-ip.d.ts +2 -0
  24. package/dist/lib/image-optimization/is-private-ip.d.ts.map +1 -0
  25. package/dist/lib/image-optimization/is-private-ip.js +43 -0
  26. package/dist/lib/image-optimization/match-patterns.d.ts +4 -0
  27. package/dist/lib/image-optimization/match-patterns.d.ts.map +1 -0
  28. package/dist/lib/image-optimization/match-patterns.js +39 -0
  29. package/dist/lib/image-optimization/read-body.d.ts +2 -0
  30. package/dist/lib/image-optimization/read-body.d.ts.map +1 -0
  31. package/dist/lib/image-optimization/read-body.js +27 -0
  32. package/dist/lib/image-optimization/static-image-loader.d.ts +2 -0
  33. package/dist/lib/image-optimization/static-image-loader.d.ts.map +1 -0
  34. package/dist/lib/image-optimization/static-image-loader.js +60 -0
  35. package/dist/lib/server-router/build-entries.d.ts.map +1 -1
  36. package/dist/lib/server-router/route-entries.js +1 -1
  37. package/dist/vite.d.ts.map +1 -1
  38. package/dist/vite.js +12 -4
  39. package/package.json +2 -1
  40. package/dist/__type_probe__.d.ts +0 -2
  41. package/dist/__type_probe__.d.ts.map +0 -1
  42. package/dist/__type_probe__.js +0 -4
  43. package/dist/components/error-boundary-client.d.ts +0 -49
  44. package/dist/components/error-boundary-client.d.ts.map +0 -1
  45. package/dist/components/error-boundary-client.js +0 -71
  46. package/dist/components/error-boundary.browser.d.ts +0 -8
  47. package/dist/components/error-boundary.browser.d.ts.map +0 -1
  48. package/dist/components/error-boundary.browser.js +0 -6
  49. package/dist/components/error.d.ts +0 -2
  50. package/dist/components/error.d.ts.map +0 -1
  51. package/dist/components/error.js +0 -5
  52. package/dist/components/not-found-activator.d.ts +0 -11
  53. package/dist/components/not-found-activator.d.ts.map +0 -1
  54. package/dist/components/not-found-activator.js +0 -26
  55. package/dist/components/not-found-boundary-client.d.ts +0 -3
  56. package/dist/components/not-found-boundary-client.d.ts.map +0 -1
  57. package/dist/components/not-found-boundary-client.js +0 -20
  58. package/dist/components/not-found-boundary.browser.d.ts +0 -8
  59. package/dist/components/not-found-boundary.browser.d.ts.map +0 -1
  60. package/dist/components/not-found-boundary.browser.js +0 -4
  61. package/dist/components/not-found-boundary.d.ts +0 -8
  62. package/dist/components/not-found-boundary.d.ts.map +0 -1
  63. package/dist/components/not-found-boundary.js +0 -9
  64. package/dist/components/not-found-thrower.d.ts +0 -2
  65. package/dist/components/not-found-thrower.d.ts.map +0 -1
  66. package/dist/components/not-found-thrower.js +0 -7
  67. package/dist/components/not-found.d.ts +0 -2
  68. package/dist/components/not-found.d.ts.map +0 -1
  69. package/dist/components/not-found.js +0 -5
  70. package/dist/components/status-page.d.ts +0 -6
  71. package/dist/components/status-page.d.ts.map +0 -1
  72. package/dist/components/status-page.js +0 -61
  73. package/dist/handler.d.ts +0 -12
  74. package/dist/handler.d.ts.map +0 -1
  75. package/dist/handler.js +0 -21
  76. package/dist/handlers/rsc/constants.d.ts +0 -3
  77. package/dist/handlers/rsc/constants.d.ts.map +0 -1
  78. package/dist/handlers/rsc/constants.js +0 -2
  79. package/dist/handlers/rsc/create-response.d.ts +0 -3
  80. package/dist/handlers/rsc/create-response.d.ts.map +0 -1
  81. package/dist/handlers/rsc/create-response.js +0 -378
  82. package/dist/handlers/rsc/render-with-retries.d.ts +0 -4
  83. package/dist/handlers/rsc/render-with-retries.d.ts.map +0 -1
  84. package/dist/handlers/rsc/render-with-retries.js +0 -154
  85. package/dist/handlers/rsc/retry-loop.d.ts +0 -3
  86. package/dist/handlers/rsc/retry-loop.d.ts.map +0 -1
  87. package/dist/handlers/rsc/retry-loop.js +0 -145
  88. package/dist/handlers/rsc/stream-rsc-payload.d.ts +0 -9
  89. package/dist/handlers/rsc/stream-rsc-payload.d.ts.map +0 -1
  90. package/dist/handlers/rsc/stream-rsc-payload.js +0 -20
  91. package/dist/handlers/rsc/types.d.ts +0 -26
  92. package/dist/handlers/rsc/types.d.ts.map +0 -1
  93. package/dist/handlers/rsc/types.js +0 -7
  94. package/dist/handlers/rsc.d.ts +0 -20
  95. package/dist/handlers/rsc.d.ts.map +0 -1
  96. package/dist/handlers/rsc.js +0 -872
  97. package/dist/integration.d.ts +0 -14
  98. package/dist/integration.d.ts.map +0 -1
  99. package/dist/integration.js +0 -9
  100. package/dist/lib/dev-module-load-race.d.ts +0 -8
  101. package/dist/lib/dev-module-load-race.d.ts.map +0 -1
  102. package/dist/lib/dev-module-load-race.js +0 -31
  103. package/dist/lib/dev-page-exports.d.ts +0 -5
  104. package/dist/lib/dev-page-exports.d.ts.map +0 -1
  105. package/dist/lib/dev-page-exports.js +0 -14
  106. package/dist/lib/environment-names.d.ts +0 -11
  107. package/dist/lib/environment-names.d.ts.map +0 -1
  108. package/dist/lib/environment-names.js +0 -10
  109. package/dist/lib/initial-hash-scroll.d.ts.map +0 -1
  110. package/dist/lib/not-found.d.ts +0 -5
  111. package/dist/lib/not-found.d.ts.map +0 -1
  112. package/dist/lib/not-found.js +0 -7
  113. package/dist/lib/page-exports.d.ts +0 -9
  114. package/dist/lib/page-exports.d.ts.map +0 -1
  115. package/dist/lib/page-exports.js +0 -47
  116. package/dist/lib/pattern-segments.d.ts +0 -8
  117. package/dist/lib/pattern-segments.d.ts.map +0 -1
  118. package/dist/lib/pattern-segments.js +0 -22
  119. package/dist/lib/ppr/cache-status.d.ts +0 -3
  120. package/dist/lib/ppr/cache-status.d.ts.map +0 -1
  121. package/dist/lib/ppr/cache-status.js +0 -5
  122. package/dist/lib/server-router-contract.d.ts +0 -42
  123. package/dist/lib/server-router-contract.d.ts.map +0 -1
  124. package/dist/lib/server-router-contract.js +0 -6
  125. package/dist/lib/server-router-loader.d.ts +0 -10
  126. package/dist/lib/server-router-loader.d.ts.map +0 -1
  127. package/dist/lib/server-router-loader.js +0 -180
  128. package/dist/lib/server-router.d.ts +0 -8
  129. package/dist/lib/server-router.d.ts.map +0 -1
  130. package/dist/lib/server-router.js +0 -173
  131. package/dist/plugins/ppr-upgrade.d.ts +0 -36
  132. package/dist/plugins/ppr-upgrade.d.ts.map +0 -1
  133. package/dist/plugins/ppr-upgrade.js +0 -32
  134. package/dist/plugins/server-router-fallback.d.ts +0 -15
  135. package/dist/plugins/server-router-fallback.d.ts.map +0 -1
  136. package/dist/plugins/server-router-fallback.js +0 -30
  137. package/dist/server.d.ts +0 -10
  138. package/dist/server.d.ts.map +0 -1
  139. 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 { join } from "node:path";
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
- async function fetchLocalImage(href, publicDir) {
6
- // Remove leading slash
7
- const filePath = href.startsWith("/") ? href.slice(1) : href;
8
- const fullPath = join(publicDir, filePath);
9
- return await readFile(fullPath);
10
- }
11
- async function fetchRemoteImage(href) {
12
- const response = await fetch(href);
13
- if (!response.ok) {
14
- throw new Error(`Failed to fetch remote image: ${response.statusText}`);
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
- const arrayBuffer = await response.arrayBuffer();
17
- return Buffer.from(arrayBuffer);
36
+ return { path: fullPath, pathname: `/${relativePath.split(sep).join("/")}`, search: url.search };
18
37
  }
19
- async function optimizeImage({ buffer, contentType, quality, width, height, sharp, }) {
20
- const transformer = sharp(buffer).rotate();
21
- if (height) {
22
- transformer.resize(width, height);
23
- }
24
- else {
25
- transformer.resize(width, undefined, {
26
- withoutEnlargement: true,
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
- if (contentType === "image/avif") {
30
- transformer.avif({
31
- quality: Math.max(quality - 20, 1),
32
- effort: 3,
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
- else if (contentType === "image/webp") {
36
- transformer.webp({ quality });
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
- else if (contentType === "image/png") {
39
- transformer.png({ quality });
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
- else if (contentType === "image/jpeg" || contentType === "image/jpg") {
42
- transformer.jpeg({ quality, mozjpeg: true });
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
- return await transformer.toBuffer();
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
- if (buffer[0] === 0x47 && buffer[1] === 0x49) {
123
+ const magic = buffer.toString("ascii", 0, 4);
124
+ if (magic === "GIF8") {
55
125
  return "image/gif";
56
126
  }
57
- if (buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42) {
127
+ if (magic === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
58
128
  return "image/webp";
59
129
  }
60
- // Default to jpeg
61
- return "image/jpeg";
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
- if (!imageConfig) {
226
+ const config = imageConfig;
227
+ if (!config) {
68
228
  return new Response("Image optimization is not configured", { status: 404 });
69
229
  }
70
- const mergedConfig = {
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
- const heightStr = params.get("h");
80
- if (!imageUrl || !widthStr) {
81
- return new Response("Missing required parameters: url, w", {
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
- const quality = qualityStr ? parseInt(qualityStr, 10) : 75;
87
- const height = heightStr ? parseInt(heightStr, 10) : undefined;
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
- if (quality && (Number.isNaN(quality) || quality < 1 || quality > 100)) {
94
- return new Response("Invalid quality parameter", {
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
- // Decode the URL
100
- const decodedUrl = decodeURIComponent(imageUrl);
101
- const isLocal = decodedUrl.startsWith("/");
102
- if (isLocal && !publicDir) {
103
- const message = import.meta.env.DEV
104
- ? "Local image optimization requires a public directory. Please configure `publicDir` in your Vite config."
105
- : "";
106
- return new Response(message, {
107
- status: 404,
108
- });
109
- }
110
- const imageBuffer = isLocal
111
- ? await fetchLocalImage(decodedUrl, publicDir)
112
- : await fetchRemoteImage(decodedUrl);
113
- // Detect content type
114
- let contentType = detectContentType(imageBuffer);
115
- // SVG images are not optimized - they should be served directly
116
- if (contentType === "image/svg+xml") {
117
- return new Response("SVG images are not supported", {
118
- status: 400,
119
- });
120
- }
121
- // Check Accept header for preferred format
122
- const acceptHeader = request.headers.get("accept") || "";
123
- if (acceptHeader.includes("image/avif")) {
124
- contentType = "image/avif";
125
- }
126
- else if (acceptHeader.includes("image/webp")) {
127
- contentType = "image/webp";
128
- }
129
- // Optimize the image
130
- const optimizedBuffer = await optimizeImage({
131
- buffer: imageBuffer,
132
- contentType,
133
- quality,
134
- width,
135
- height,
136
- sharp,
137
- });
138
- // Generate ETag for caching
139
- const etag = `"${Buffer.from(optimizedBuffer).toString("base64").slice(0, 16)}"`;
140
- // Check if client has cached version
141
- const ifNoneMatch = request.headers.get("if-none-match");
142
- if (ifNoneMatch === etag) {
143
- return new Response(null, {
144
- status: 304,
145
- headers: {
146
- ETag: etag,
147
- },
148
- });
149
- }
150
- return new Response(new Uint8Array(optimizedBuffer), {
151
- status: 200,
152
- headers: {
153
- "Content-Type": contentType,
154
- "Cache-Control": `public, max-age=${mergedConfig.minimumCacheTTL}, immutable`,
155
- ETag: etag,
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(error.message || "Image optimization failed", {
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 LocalPattern = {
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
- /** Local image path patterns (undefined = allow all local images) */
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,KAAK,WAAW,GAAG,YAAY,GAAG,YAAY,CAAA;AAE9C,KAAK,YAAY,GAAG;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG;IACjC,yCAAyC;IACzC,WAAW,EAAE,MAAM,EAAE,CAAA;IAErB,wCAAwC;IACxC,UAAU,EAAE,MAAM,EAAE,CAAA;IAEpB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAA;IAEZ,mCAAmC;IACnC,eAAe,EAAE,MAAM,CAAA;IAEvB,yBAAyB;IACzB,OAAO,EAAE,WAAW,EAAE,CAAA;IAEtB,8BAA8B;IAC9B,qBAAqB,EAAE,MAAM,CAAA;IAE7B,+BAA+B;IAC/B,sBAAsB,EAAE,QAAQ,GAAG,YAAY,CAAA;IAE/C,qBAAqB;IACrB,SAAS,EAAE,MAAM,EAAE,GAAG,SAAS,CAAA;IAE/B,uBAAuB;IACvB,WAAW,EAAE,OAAO,CAAA;IAEpB,qEAAqE;IACrE,aAAa,CAAC,EAAE,YAAY,EAAE,CAAA;IAE9B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAA;AAEtD,eAAO,MAAM,kBAAkB,EAAE,mBAUhC,CAAA"}
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;AAIlG,qCAAqC;AACrC,eAAO,MAAM,wBAAwB,iBAAiB,CAAA;AA0BtD,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,CA+EzD"}
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"}