nuxt-og-image 6.4.2 → 6.4.3
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/chunks/tw4.cjs +1 -1
- package/dist/chunks/tw4.mjs +1 -1
- package/dist/chunks/uno.cjs +1 -1
- package/dist/chunks/uno.mjs +1 -1
- package/dist/devtools/200.html +1 -1
- package/dist/devtools/404.html +1 -1
- package/dist/devtools/_nuxt/{BWm573-p.js → B-y6Zfh-.js} +1 -1
- package/dist/devtools/_nuxt/{w_tq7NMl.js → B5qFn-Gu.js} +1 -1
- package/dist/devtools/_nuxt/{CHeKziWa.js → BVne4GIn.js} +1 -1
- package/dist/devtools/_nuxt/{yBiBpwD7.js → BlCLj09b.js} +6 -6
- package/dist/devtools/_nuxt/CN79P4uE.js +6 -0
- package/dist/devtools/_nuxt/{Cy0omYQh.js → ClxM7Lmy.js} +1 -1
- package/dist/devtools/_nuxt/{DziLl24l.js → CwlJb64V.js} +1 -1
- package/dist/devtools/_nuxt/{BsivBvAU.js → D7u0rku6.js} +1 -1
- package/dist/devtools/_nuxt/DevtoolsSection.C56mUBtZ.css +1 -0
- package/dist/devtools/_nuxt/DevtoolsSnippet.BfzotPc4.css +1 -0
- package/dist/devtools/_nuxt/{BQDcSiMf.js → H3aAOs4d.js} +1 -1
- package/dist/devtools/_nuxt/{B6Dg3dZ6.js → QLqpVsIv.js} +1 -1
- package/dist/devtools/_nuxt/builds/latest.json +1 -1
- package/dist/devtools/_nuxt/builds/meta/cbeb6bb2-84a2-4c9c-a725-a30474eb2d1e.json +1 -0
- package/dist/devtools/_nuxt/{entry.BjD2aghs.css → entry.DTjl7Lr7.css} +1 -1
- package/dist/devtools/_nuxt/{pages.DO0dnDUs.css → pages.DqKYLiSy.css} +1 -1
- package/dist/devtools/_nuxt/renderer-select.Do_uPaOg.css +1 -0
- package/dist/devtools/_nuxt/{BMVWkjCo.js → rybsNufq.js} +1 -1
- package/dist/devtools/debug/index.html +1 -1
- package/dist/devtools/docs/index.html +1 -1
- package/dist/devtools/index.html +1 -1
- package/dist/devtools/templates/index.html +1 -1
- package/dist/module.cjs +1 -1
- package/dist/module.d.cts +11 -0
- package/dist/module.d.mts +11 -0
- package/dist/module.d.ts +11 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/app/client-utils.js +24 -5
- package/dist/runtime/server/og-image/bindings/font-assets/cloudflare.js +9 -18
- package/dist/runtime/server/og-image/bindings/font-assets/dev-prerender.js +8 -4
- package/dist/runtime/server/og-image/bindings/font-assets/node.js +6 -2
- package/dist/runtime/server/og-image/browser/screenshot.d.ts +1 -1
- package/dist/runtime/server/og-image/browser/screenshot.js +6 -4
- package/dist/runtime/server/og-image/context.js +4 -0
- package/dist/runtime/server/og-image/core/plugins/imageSrc.js +106 -99
- package/dist/runtime/server/og-image/core/transforms/emojis/fetch.js +23 -12
- package/dist/runtime/server/og-image/satori/renderer.js +16 -14
- package/dist/runtime/server/og-image/takumi/renderer.js +27 -19
- package/dist/runtime/server/util/cloudflareAssets.d.ts +24 -0
- package/dist/runtime/server/util/cloudflareAssets.js +16 -0
- package/dist/runtime/server/util/eventHandlers.js +28 -5
- package/dist/runtime/server/util/fetchLocalAsset.d.ts +25 -0
- package/dist/runtime/server/util/fetchLocalAsset.js +34 -0
- package/dist/runtime/server/util/fetchTimeout.d.ts +2 -0
- package/dist/runtime/server/util/fetchTimeout.js +7 -0
- package/dist/runtime/server/util/timings.d.ts +17 -0
- package/dist/runtime/server/util/timings.js +75 -0
- package/dist/runtime/types.d.ts +3 -0
- package/dist/shared/{nuxt-og-image.C2oXAHiT.cjs → nuxt-og-image.B55LvX3B.cjs} +2 -1
- package/dist/shared/{nuxt-og-image.BK0-aZom.mjs → nuxt-og-image.BHvLMojr.mjs} +1 -0
- package/package.json +6 -6
- package/dist/devtools/_nuxt/DDRo8-tD.js +0 -6
- package/dist/devtools/_nuxt/DevtoolsSection.C-PGRg5f.css +0 -1
- package/dist/devtools/_nuxt/DevtoolsSnippet.BipAyEUC.css +0 -1
- package/dist/devtools/_nuxt/builds/meta/150c0674-b387-4525-9043-e05dd343db8e.json +0 -1
- package/dist/devtools/_nuxt/renderer-select.J57nTUNW.css +0 -1
|
@@ -3,6 +3,8 @@ import { useStorage } from "nitropack/runtime";
|
|
|
3
3
|
import { withBase, withoutLeadingSlash } from "ufo";
|
|
4
4
|
import { toBase64Image } from "../../../../shared.js";
|
|
5
5
|
import { decodeHtml } from "../../../util/encoding.js";
|
|
6
|
+
import { fetchLocalAsset } from "../../../util/fetchLocalAsset.js";
|
|
7
|
+
import { getFetchTimeout } from "../../../util/fetchTimeout.js";
|
|
6
8
|
import { logger } from "../../../util/logger.js";
|
|
7
9
|
import { getImageDimensions } from "../../utils/image-detector.js";
|
|
8
10
|
import { defineTransformer } from "../plugins.js";
|
|
@@ -58,6 +60,7 @@ function isBlockedUrl(url) {
|
|
|
58
60
|
}
|
|
59
61
|
const RE_URL_LEADING = /^url\(['"]?/;
|
|
60
62
|
const RE_URL_TRAILING = /['"]?\)$/;
|
|
63
|
+
const SUBREQUEST_HEADERS = { "x-nuxt-og-image": "1" };
|
|
61
64
|
async function resolveLocalFilePathImage(publicStoragePath, src) {
|
|
62
65
|
const normalizedSrc = withoutLeadingSlash(
|
|
63
66
|
src.replace("_nuxt/@fs/", "").replace("_nuxt/", "").replace("./", "")
|
|
@@ -66,125 +69,129 @@ async function resolveLocalFilePathImage(publicStoragePath, src) {
|
|
|
66
69
|
if (await useStorage().hasItem(key))
|
|
67
70
|
return await useStorage().getItemRaw(key);
|
|
68
71
|
}
|
|
72
|
+
function toBufferSourceAsBase64(buf) {
|
|
73
|
+
const ab = buf instanceof ArrayBuffer ? buf : ArrayBuffer.isView(buf) ? buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) : buf;
|
|
74
|
+
return toBase64Image(ab);
|
|
75
|
+
}
|
|
76
|
+
const renderCaches = /* @__PURE__ */ new WeakMap();
|
|
77
|
+
function getRenderCache(e) {
|
|
78
|
+
let cache = renderCaches.get(e);
|
|
79
|
+
if (!cache) {
|
|
80
|
+
cache = /* @__PURE__ */ new Map();
|
|
81
|
+
renderCaches.set(e, cache);
|
|
82
|
+
}
|
|
83
|
+
return cache;
|
|
84
|
+
}
|
|
85
|
+
function resolveSrcToBuffer(src, kind, ctx) {
|
|
86
|
+
const cache = getRenderCache(ctx.e);
|
|
87
|
+
const existing = cache.get(src);
|
|
88
|
+
if (existing)
|
|
89
|
+
return existing;
|
|
90
|
+
const promise = doResolveSrcToBuffer(src, kind, ctx);
|
|
91
|
+
cache.set(src, promise);
|
|
92
|
+
return promise;
|
|
93
|
+
}
|
|
94
|
+
async function doResolveSrcToBuffer(src, kind, { e, publicStoragePath, runtimeConfig, timings }) {
|
|
95
|
+
const fetchTimeout = getFetchTimeout(runtimeConfig);
|
|
96
|
+
const logFailure = (url, err) => {
|
|
97
|
+
logger.debug(`[og-image] ${kind} fetch failed (${url}): ${err?.message || err}`);
|
|
98
|
+
};
|
|
99
|
+
if (src.startsWith("/")) {
|
|
100
|
+
let buffer2;
|
|
101
|
+
if (import.meta.prerender || import.meta.dev) {
|
|
102
|
+
const srcWithoutBase = src.replace(runtimeConfig.app.baseURL, "/");
|
|
103
|
+
buffer2 = await resolveLocalFilePathImage(publicStoragePath, srcWithoutBase);
|
|
104
|
+
}
|
|
105
|
+
if (!buffer2 && !import.meta.prerender) {
|
|
106
|
+
const ab = await timings.measure("image-fetch", () => fetchLocalAsset(e, src, {
|
|
107
|
+
fetchTimeout,
|
|
108
|
+
headers: SUBREQUEST_HEADERS,
|
|
109
|
+
includeExternalFallback: true,
|
|
110
|
+
onStepFailure: logFailure
|
|
111
|
+
}));
|
|
112
|
+
if (ab)
|
|
113
|
+
buffer2 = new Uint8Array(ab);
|
|
114
|
+
}
|
|
115
|
+
return buffer2 ? { buffer: buffer2 } : {};
|
|
116
|
+
}
|
|
117
|
+
const decodedSrc = decodeHtml(src);
|
|
118
|
+
if (!import.meta.dev && isBlockedUrl(decodedSrc)) {
|
|
119
|
+
logger.warn(`Blocked internal ${kind} fetch: ${decodedSrc}`);
|
|
120
|
+
return { blocked: true };
|
|
121
|
+
}
|
|
122
|
+
const end = timings.start("image-fetch");
|
|
123
|
+
const buffer = await $fetch(decodedSrc, {
|
|
124
|
+
responseType: "arrayBuffer",
|
|
125
|
+
timeout: fetchTimeout
|
|
126
|
+
}).catch((err) => {
|
|
127
|
+
logFailure(decodedSrc, err);
|
|
128
|
+
}).finally(end);
|
|
129
|
+
return buffer ? { buffer } : {};
|
|
130
|
+
}
|
|
131
|
+
function applyImageDimensions(node, buffer) {
|
|
132
|
+
if (typeof node.props.width === "string")
|
|
133
|
+
node.props.width = Number(node.props.width) || void 0;
|
|
134
|
+
if (typeof node.props.height === "string")
|
|
135
|
+
node.props.height = Number(node.props.height) || void 0;
|
|
136
|
+
if (node.props.width && node.props.height)
|
|
137
|
+
return;
|
|
138
|
+
const view = buffer instanceof Uint8Array ? buffer : ArrayBuffer.isView(buffer) ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) : new Uint8Array(buffer);
|
|
139
|
+
const dimensions = getImageDimensions(view);
|
|
140
|
+
if (!dimensions?.width || !dimensions?.height)
|
|
141
|
+
return;
|
|
142
|
+
const naturalAspectRatio = dimensions.width / dimensions.height;
|
|
143
|
+
if (node.props.width && !node.props.height) {
|
|
144
|
+
node.props.height = Math.round(node.props.width / naturalAspectRatio);
|
|
145
|
+
} else if (node.props.height && !node.props.width) {
|
|
146
|
+
node.props.width = Math.round(node.props.height * naturalAspectRatio);
|
|
147
|
+
} else {
|
|
148
|
+
node.props.width = dimensions.width;
|
|
149
|
+
node.props.height = dimensions.height;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
69
152
|
export default defineTransformer([
|
|
70
153
|
// fix <img src="">
|
|
71
154
|
{
|
|
72
155
|
filter: (node) => node.type === "img" && node.props?.src,
|
|
73
|
-
transform: async (node,
|
|
156
|
+
transform: async (node, ctx) => {
|
|
74
157
|
let src = node.props.src;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
let imageBuffer;
|
|
158
|
+
if (src.startsWith("data:"))
|
|
159
|
+
return;
|
|
78
160
|
if (src.endsWith(".webp")) {
|
|
79
161
|
logger.warn("Using WebP images with Satori is not supported. Please consider switching image format or use the chromium renderer.", src);
|
|
80
162
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
});
|
|
89
|
-
if (!imageBuffer && !import.meta.prerender) {
|
|
90
|
-
imageBuffer = await e.$fetch(src, {
|
|
91
|
-
baseURL: getNitroOrigin(e),
|
|
92
|
-
responseType: "arrayBuffer"
|
|
93
|
-
}).catch(() => {
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (imageBuffer) {
|
|
98
|
-
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer;
|
|
99
|
-
node.props.src = toBase64Image(buffer);
|
|
100
|
-
}
|
|
101
|
-
} else if (!src.startsWith("data:")) {
|
|
102
|
-
src = decodeHtml(src);
|
|
103
|
-
if (!import.meta.dev && isBlockedUrl(src)) {
|
|
104
|
-
logger.warn(`Blocked internal image fetch: ${src}`);
|
|
105
|
-
delete node.props.src;
|
|
106
|
-
} else {
|
|
107
|
-
node.props.src = src;
|
|
108
|
-
imageBuffer = await $fetch(src, {
|
|
109
|
-
responseType: "arrayBuffer"
|
|
110
|
-
}).catch(() => {
|
|
111
|
-
});
|
|
112
|
-
if (imageBuffer) {
|
|
113
|
-
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer;
|
|
114
|
-
node.props.src = toBase64Image(buffer);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (typeof node.props.width === "string")
|
|
119
|
-
node.props.width = Number(node.props.width) || void 0;
|
|
120
|
-
if (typeof node.props.height === "string")
|
|
121
|
-
node.props.height = Number(node.props.height) || void 0;
|
|
122
|
-
if (imageBuffer && (!node.props.width || !node.props.height)) {
|
|
123
|
-
dimensions = getImageDimensions(imageBuffer);
|
|
124
|
-
if (dimensions?.width && dimensions?.height) {
|
|
125
|
-
const naturalAspectRatio = dimensions.width / dimensions.height;
|
|
126
|
-
if (node.props.width && !node.props.height) {
|
|
127
|
-
node.props.height = Math.round(node.props.width / naturalAspectRatio);
|
|
128
|
-
} else if (node.props.height && !node.props.width) {
|
|
129
|
-
node.props.width = Math.round(node.props.height * naturalAspectRatio);
|
|
130
|
-
} else if (!node.props.width && !node.props.height) {
|
|
131
|
-
node.props.width = dimensions.width;
|
|
132
|
-
node.props.height = dimensions.height;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
163
|
+
const isRelative = src.startsWith("/");
|
|
164
|
+
if (!isRelative)
|
|
165
|
+
src = node.props.src = decodeHtml(src);
|
|
166
|
+
const result = await resolveSrcToBuffer(src, "image", ctx);
|
|
167
|
+
if (result.blocked) {
|
|
168
|
+
delete node.props.src;
|
|
169
|
+
return;
|
|
135
170
|
}
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
} else {
|
|
141
|
-
node.props.src = `${withBase(src, `${getNitroOrigin(e)}`)}?${Date.now()}`;
|
|
142
|
-
}
|
|
171
|
+
if (result.buffer) {
|
|
172
|
+
node.props.src = toBufferSourceAsBase64(result.buffer);
|
|
173
|
+
applyImageDimensions(node, result.buffer);
|
|
174
|
+
return;
|
|
143
175
|
}
|
|
176
|
+
if (isRelative)
|
|
177
|
+
node.props.src = withBase(src, `${getNitroOrigin(ctx.e)}`);
|
|
144
178
|
}
|
|
145
179
|
},
|
|
146
180
|
// fix style="background-image: url('')"
|
|
147
181
|
{
|
|
148
182
|
filter: (node) => node.props?.style?.backgroundImage?.includes("url("),
|
|
149
|
-
transform: async (node,
|
|
183
|
+
transform: async (node, ctx) => {
|
|
150
184
|
const backgroundImage = node.props.style.backgroundImage;
|
|
151
185
|
const src = backgroundImage.replace(RE_URL_LEADING, "").replace(RE_URL_TRAILING, "");
|
|
152
186
|
if (src.startsWith("data:"))
|
|
153
187
|
return;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const srcWithoutBase = src.replace(runtimeConfig.app.baseURL, "/");
|
|
159
|
-
imageBuffer = await resolveLocalFilePathImage(publicStoragePath, srcWithoutBase);
|
|
160
|
-
}
|
|
161
|
-
if (!imageBuffer) {
|
|
162
|
-
imageBuffer = await e.$fetch(src, { responseType: "arrayBuffer" }).catch(() => {
|
|
163
|
-
});
|
|
164
|
-
if (!imageBuffer && !import.meta.prerender) {
|
|
165
|
-
imageBuffer = await e.$fetch(src, {
|
|
166
|
-
baseURL: getNitroOrigin(e),
|
|
167
|
-
responseType: "arrayBuffer"
|
|
168
|
-
}).catch(() => {
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
} else {
|
|
173
|
-
const decodedSrc = decodeHtml(src);
|
|
174
|
-
if (!import.meta.dev && isBlockedUrl(decodedSrc)) {
|
|
175
|
-
logger.warn(`Blocked internal background-image fetch: ${decodedSrc}`);
|
|
176
|
-
delete node.props.style.backgroundImage;
|
|
177
|
-
} else {
|
|
178
|
-
imageBuffer = await $fetch(decodedSrc, {
|
|
179
|
-
responseType: "arrayBuffer"
|
|
180
|
-
}).catch(() => {
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
if (imageBuffer) {
|
|
185
|
-
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer;
|
|
186
|
-
node.props.style.backgroundImage = `url(${toBase64Image(buffer)})`;
|
|
188
|
+
const result = await resolveSrcToBuffer(src, "background-image", ctx);
|
|
189
|
+
if (result.blocked) {
|
|
190
|
+
delete node.props.style.backgroundImage;
|
|
191
|
+
return;
|
|
187
192
|
}
|
|
193
|
+
if (result.buffer)
|
|
194
|
+
node.props.style.backgroundImage = `url(${toBufferSourceAsBase64(result.buffer)})`;
|
|
188
195
|
}
|
|
189
196
|
}
|
|
190
197
|
]);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { emojiCache } from "#og-image-cache";
|
|
2
2
|
import { $fetch } from "ofetch";
|
|
3
|
+
import { getFetchTimeout } from "../../../../util/fetchTimeout.js";
|
|
3
4
|
import { getEmojiCodePoint, getEmojiIconNames } from "./emoji-utils.js";
|
|
4
5
|
export async function getEmojiSvg(ctx, emojiChar) {
|
|
5
6
|
const emojiSet = ctx.options.emojis;
|
|
@@ -15,21 +16,31 @@ export async function getEmojiSvg(ctx, emojiChar) {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
if (!svg) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
});
|
|
25
|
-
if (svg && svg !== "404") {
|
|
26
|
-
const key = ["1", emojiSet, iconName].join("|");
|
|
27
|
-
await emojiCache.setItem(key, svg);
|
|
19
|
+
const timeout = getFetchTimeout(ctx.runtimeConfig);
|
|
20
|
+
const deadline = AbortSignal.timeout(timeout);
|
|
21
|
+
const endTiming = ctx.timings.start("emoji-fetch");
|
|
22
|
+
try {
|
|
23
|
+
for (const iconName of possibleNames) {
|
|
24
|
+
if (deadline.aborted)
|
|
28
25
|
break;
|
|
26
|
+
try {
|
|
27
|
+
svg = await $fetch(`https://api.iconify.design/${emojiSet}/${iconName}.svg`, {
|
|
28
|
+
responseType: "text",
|
|
29
|
+
retry: 0,
|
|
30
|
+
signal: deadline,
|
|
31
|
+
timeout
|
|
32
|
+
});
|
|
33
|
+
if (svg && svg !== "404") {
|
|
34
|
+
const key = ["1", emojiSet, iconName].join("|");
|
|
35
|
+
await emojiCache.setItem(key, svg);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
svg = null;
|
|
29
40
|
}
|
|
30
|
-
} catch {
|
|
31
|
-
svg = null;
|
|
32
41
|
}
|
|
42
|
+
} finally {
|
|
43
|
+
endTiming();
|
|
33
44
|
}
|
|
34
45
|
}
|
|
35
46
|
if (svg) {
|
|
@@ -23,7 +23,7 @@ function withWarningCapture(fn) {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
export async function createSvg(event) {
|
|
26
|
-
const { options } = event;
|
|
26
|
+
const { options, timings } = event;
|
|
27
27
|
const { satoriOptions: _satoriOptions } = useOgImageRuntimeConfig();
|
|
28
28
|
const { fontFamilyOverride, defaultFont } = getDefaultFontFamily(options);
|
|
29
29
|
const [satori, vnodes] = await Promise.all([
|
|
@@ -32,13 +32,13 @@ export async function createSvg(event) {
|
|
|
32
32
|
]);
|
|
33
33
|
const codepoints = extractCodepoints(vnodes);
|
|
34
34
|
const hasCustomFonts = Array.isArray(options.fonts) && options.fonts.length > 0;
|
|
35
|
-
const fonts = await loadFontsForRenderer(event, {
|
|
35
|
+
const fonts = await timings.measure("font-load", () => loadFontsForRenderer(event, {
|
|
36
36
|
supportedFormats: /* @__PURE__ */ new Set(["ttf", "otf", "woff"]),
|
|
37
37
|
component: options.component,
|
|
38
38
|
fontFamilyOverride: fontFamilyOverride || defaultFont,
|
|
39
39
|
codepoints,
|
|
40
40
|
fontDefs: options.fonts
|
|
41
|
-
});
|
|
41
|
+
}));
|
|
42
42
|
await event._nitro.hooks.callHook("nuxt-og-image:satori:vnodes", vnodes, event);
|
|
43
43
|
const satoriFonts = !hasCustomFonts && _satoriFontCache.get(fonts) || fonts.map((f) => ({ ...f, name: f.family }));
|
|
44
44
|
if (!hasCustomFonts)
|
|
@@ -80,9 +80,9 @@ export async function createSvg(event) {
|
|
|
80
80
|
width: options.width,
|
|
81
81
|
height: options.height
|
|
82
82
|
});
|
|
83
|
-
const { result, warnings } = await withWarningCapture(
|
|
83
|
+
const { result, warnings } = await timings.measure("render-satori", () => withWarningCapture(
|
|
84
84
|
() => satori(vnodes, satoriOptions)
|
|
85
|
-
);
|
|
85
|
+
));
|
|
86
86
|
return { svg: result, warnings, fonts };
|
|
87
87
|
}
|
|
88
88
|
async function createPng(event) {
|
|
@@ -92,14 +92,16 @@ async function createPng(event) {
|
|
|
92
92
|
throw new Error("Failed to create SVG");
|
|
93
93
|
const options = defu(event.options.resvg, resvgOptions);
|
|
94
94
|
const Resvg = await getResvg();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
pngData.free
|
|
100
|
-
|
|
101
|
-
resvg.free
|
|
102
|
-
|
|
95
|
+
return event.timings.measure("render-resvg", () => {
|
|
96
|
+
const resvg = new Resvg(svg, options);
|
|
97
|
+
const pngData = resvg.render();
|
|
98
|
+
const png = pngData.asPng();
|
|
99
|
+
if (typeof pngData.free === "function")
|
|
100
|
+
pngData.free();
|
|
101
|
+
if (typeof resvg.free === "function")
|
|
102
|
+
resvg.free();
|
|
103
|
+
return png;
|
|
104
|
+
});
|
|
103
105
|
}
|
|
104
106
|
async function createJpeg(event) {
|
|
105
107
|
const { sharpOptions } = useOgImageRuntimeConfig();
|
|
@@ -115,7 +117,7 @@ async function createJpeg(event) {
|
|
|
115
117
|
throw new Error("Sharp dependency could not be loaded. Please check you have it installed and are using a compatible runtime.");
|
|
116
118
|
});
|
|
117
119
|
const options = defu(event.options.sharp, sharpOptions);
|
|
118
|
-
return sharp(svgBuffer, options).jpeg(options).toBuffer();
|
|
120
|
+
return event.timings.measure("render-sharp", () => sharp(svgBuffer, options).jpeg(options).toBuffer());
|
|
119
121
|
}
|
|
120
122
|
function rewriteVNodeFontFamilies(node, subsetChains) {
|
|
121
123
|
const style = node.props?.style;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { getNitroOrigin } from "#site-config/server/composables";
|
|
2
1
|
import { defu } from "defu";
|
|
3
2
|
import { withBase } from "ufo";
|
|
4
3
|
import { logger } from "../../../logger.js";
|
|
4
|
+
import { fetchLocalAsset } from "../../util/fetchLocalAsset.js";
|
|
5
|
+
import { getFetchTimeout } from "../../util/fetchTimeout.js";
|
|
5
6
|
import { buildSubsetFamilyChain, extractCodepoints, getDefaultFontFamily, loadFontsForRenderer, resolveSubsetChain } from "../fonts.js";
|
|
6
7
|
import { getExtractResourceUrls, getTakumi } from "./instances.js";
|
|
7
8
|
import { createTakumiNodes } from "./nodes.js";
|
|
@@ -77,11 +78,11 @@ function rewriteFontFamilies(node, loadedFamilies, subsetChains) {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
async function createImage(event, format) {
|
|
80
|
-
const { options } = event;
|
|
81
|
+
const { options, timings } = event;
|
|
81
82
|
const { fontFamilyOverride, defaultFont } = getDefaultFontFamily(options);
|
|
82
83
|
const nodes = await createTakumiNodes(event);
|
|
83
84
|
const codepoints = extractCodepoints(nodes);
|
|
84
|
-
const fonts = await loadFontsForRenderer(event, { supportedFormats: /* @__PURE__ */ new Set(["ttf", "woff2"]), component: options.component, fontFamilyOverride: fontFamilyOverride || defaultFont, codepoints });
|
|
85
|
+
const fonts = await timings.measure("font-load", () => loadFontsForRenderer(event, { supportedFormats: /* @__PURE__ */ new Set(["ttf", "woff2"]), component: options.component, fontFamilyOverride: fontFamilyOverride || defaultFont, codepoints }));
|
|
85
86
|
await event._nitro.hooks.callHook("nuxt-og-image:takumi:nodes", nodes, event);
|
|
86
87
|
const subsetChains = buildSubsetFamilyChain(fonts);
|
|
87
88
|
const state = await getTakumiState(event);
|
|
@@ -99,25 +100,32 @@ async function createImage(event, format) {
|
|
|
99
100
|
rewriteFontFamilies(nodes, state.loadedFamilies, subsetChains);
|
|
100
101
|
const extractResourceUrls = await getExtractResourceUrls();
|
|
101
102
|
const resourceUrls = await extractResourceUrls(nodes);
|
|
102
|
-
const origin = getNitroOrigin(event.e);
|
|
103
103
|
const baseURL = event.runtimeConfig.app.baseURL;
|
|
104
104
|
const fetchedResources = [];
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
if (resourceUrls.length) {
|
|
106
|
+
const fetchTimeout = getFetchTimeout(event.runtimeConfig);
|
|
107
|
+
const headers = { "x-nuxt-og-image": "1" };
|
|
108
|
+
await timings.measure("resource-fetch", () => Promise.all(resourceUrls.map(async (src) => {
|
|
109
|
+
let data;
|
|
110
|
+
if (src.startsWith("/")) {
|
|
111
|
+
const path = withBase(src, baseURL);
|
|
112
|
+
data = await fetchLocalAsset(event.e, path, {
|
|
113
|
+
fetchTimeout,
|
|
114
|
+
headers,
|
|
115
|
+
includeExternalFallback: true
|
|
116
|
+
});
|
|
117
|
+
} else {
|
|
118
|
+
data = await $fetch(src, {
|
|
119
|
+
responseType: "arrayBuffer",
|
|
120
|
+
signal: AbortSignal.timeout(fetchTimeout),
|
|
121
|
+
timeout: fetchTimeout,
|
|
122
|
+
headers
|
|
123
|
+
}).catch(() => void 0);
|
|
111
124
|
}
|
|
112
|
-
|
|
113
|
-
for (const url of urlsToTry) {
|
|
114
|
-
const data = await $fetch(url, { responseType: "arrayBuffer" }).catch(() => null);
|
|
115
|
-
if (data) {
|
|
125
|
+
if (data)
|
|
116
126
|
fetchedResources.push({ src, data: new Uint8Array(data) });
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
}));
|
|
127
|
+
})));
|
|
128
|
+
}
|
|
121
129
|
const maxDpr = event.runtimeConfig.security?.maxDpr || 2;
|
|
122
130
|
const maxDim = event.runtimeConfig.security?.maxDimension || 2048;
|
|
123
131
|
const dpr = Math.min(Math.max(1, options.takumi?.devicePixelRatio ?? 1), maxDpr);
|
|
@@ -128,7 +136,7 @@ async function createImage(event, format) {
|
|
|
128
136
|
fetchedResources,
|
|
129
137
|
devicePixelRatio: dpr
|
|
130
138
|
});
|
|
131
|
-
return await state.renderer.render(nodes, renderOptions);
|
|
139
|
+
return await timings.measure("render-takumi", () => state.renderer.render(nodes, renderOptions));
|
|
132
140
|
}
|
|
133
141
|
const TakumiRenderer = {
|
|
134
142
|
name: "takumi",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
interface AssetsBinding {
|
|
3
|
+
fetch: (request: string | Request, init?: RequestInit) => Promise<Response>;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Access the Cloudflare Workers ASSETS binding if available.
|
|
7
|
+
*
|
|
8
|
+
* Requires either:
|
|
9
|
+
* - `nitro.cloudflare.deployConfig: true` (generates wrangler.json with ASSETS)
|
|
10
|
+
* - A wrangler.toml/json with `[assets]` configured
|
|
11
|
+
*/
|
|
12
|
+
export declare function getCloudflareAssets(event: H3Event): AssetsBinding | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Fetch a path directly from the CF Workers ASSETS binding.
|
|
15
|
+
*
|
|
16
|
+
* Prefer this over same-origin subrequests on Workers: it hits the static asset
|
|
17
|
+
* handler without billing a subrequest and without re-running the Worker's
|
|
18
|
+
* middleware chain.
|
|
19
|
+
*
|
|
20
|
+
* Returns `undefined` when no ASSETS binding is available or the asset doesn't
|
|
21
|
+
* exist (letting callers fall through to other resolution strategies).
|
|
22
|
+
*/
|
|
23
|
+
export declare function tryCloudflareAssetsFetch(event: H3Event, path: string, signal?: AbortSignal): Promise<ArrayBuffer | undefined>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getRequestHost } from "h3";
|
|
2
|
+
export function getCloudflareAssets(event) {
|
|
3
|
+
const assets = event.context.cloudflare?.env?.ASSETS || event.context.ASSETS;
|
|
4
|
+
return assets && typeof assets.fetch === "function" ? assets : void 0;
|
|
5
|
+
}
|
|
6
|
+
export async function tryCloudflareAssetsFetch(event, path, signal) {
|
|
7
|
+
const assets = getCloudflareAssets(event);
|
|
8
|
+
if (!assets)
|
|
9
|
+
return;
|
|
10
|
+
const origin = event.context.cloudflare?.request?.url || `https://${getRequestHost(event) || "localhost"}`;
|
|
11
|
+
const url = new URL(path, origin).href;
|
|
12
|
+
const res = await assets.fetch(url, signal ? { signal } : void 0).catch(() => null);
|
|
13
|
+
if (!res || !res.ok)
|
|
14
|
+
return;
|
|
15
|
+
return res.arrayBuffer();
|
|
16
|
+
}
|
|
@@ -8,12 +8,25 @@ import { html } from "../og-image/templates/html.js";
|
|
|
8
8
|
import { useOgImageRuntimeConfig } from "../utils.js";
|
|
9
9
|
import { useOgImageBufferCache } from "./cache.js";
|
|
10
10
|
export async function imageEventHandler(e) {
|
|
11
|
+
const reqStart = performance.now();
|
|
11
12
|
const ctx = await resolveContext(e).catch((err) => {
|
|
12
13
|
logger.error(`resolveContext error for ${e.path}:`, err?.message || err);
|
|
13
14
|
throw err;
|
|
14
15
|
});
|
|
15
16
|
if (ctx instanceof H3Error)
|
|
16
17
|
return ctx;
|
|
18
|
+
const timings = ctx.timings;
|
|
19
|
+
try {
|
|
20
|
+
return await renderOgImage(e, ctx);
|
|
21
|
+
} finally {
|
|
22
|
+
timings.record("total", performance.now() - reqStart);
|
|
23
|
+
const header = timings.header();
|
|
24
|
+
if (header)
|
|
25
|
+
setHeader(e, "Server-Timing", header);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function renderOgImage(e, ctx) {
|
|
29
|
+
const timings = ctx.timings;
|
|
17
30
|
const { isDevToolsContextRequest, extension, renderer } = ctx;
|
|
18
31
|
const { debug, baseCacheKey, security } = useOgImageRuntimeConfig();
|
|
19
32
|
if (!import.meta.prerender && !import.meta.dev && security?.restrictRuntimeImagesToOrigin) {
|
|
@@ -99,21 +112,29 @@ export async function imageEventHandler(e) {
|
|
|
99
112
|
}
|
|
100
113
|
const buildCachedImage = import.meta.prerender ? getBuildCachedImage(ctx.options, extension) : null;
|
|
101
114
|
if (buildCachedImage) {
|
|
115
|
+
timings.record("cache-hit", 0);
|
|
102
116
|
return buildCachedImage;
|
|
103
117
|
}
|
|
118
|
+
const endCacheLookup = timings.start("cache-lookup");
|
|
104
119
|
const cacheApi = await useOgImageBufferCache(ctx, {
|
|
105
120
|
cacheMaxAgeSeconds: ctx.options.cacheMaxAgeSeconds,
|
|
106
121
|
baseCacheKey,
|
|
107
122
|
secret: security?.secret
|
|
108
|
-
});
|
|
109
|
-
if (typeof cacheApi === "undefined")
|
|
123
|
+
}).finally(endCacheLookup);
|
|
124
|
+
if (typeof cacheApi === "undefined") {
|
|
110
125
|
return;
|
|
111
|
-
|
|
126
|
+
}
|
|
127
|
+
if (cacheApi instanceof H3Error) {
|
|
112
128
|
return cacheApi;
|
|
129
|
+
}
|
|
113
130
|
let image = cacheApi.cachedItem;
|
|
131
|
+
if (image) {
|
|
132
|
+
timings.record("cache-hit", 0);
|
|
133
|
+
}
|
|
114
134
|
if (!image) {
|
|
115
|
-
const timeout = security?.renderTimeout
|
|
135
|
+
const timeout = security?.renderTimeout ?? 15e3;
|
|
116
136
|
let timer;
|
|
137
|
+
const endRender = timings.start("render-total");
|
|
117
138
|
image = await Promise.race([
|
|
118
139
|
renderer.createImage(ctx),
|
|
119
140
|
new Promise((_, reject) => {
|
|
@@ -128,9 +149,11 @@ export async function imageEventHandler(e) {
|
|
|
128
149
|
throw err;
|
|
129
150
|
}).finally(() => {
|
|
130
151
|
clearTimeout(timer);
|
|
152
|
+
endRender();
|
|
131
153
|
});
|
|
132
|
-
if (image instanceof H3Error)
|
|
154
|
+
if (image instanceof H3Error) {
|
|
133
155
|
return image;
|
|
156
|
+
}
|
|
134
157
|
if (!image) {
|
|
135
158
|
return createError({
|
|
136
159
|
statusCode: 500,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
export interface FetchLocalAssetOptions {
|
|
3
|
+
fetchTimeout: number;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
/**
|
|
6
|
+
* Also attempt an external HTTP fetch to `getNitroOrigin(event) + path` when
|
|
7
|
+
* ASSETS and Nitro localFetch both miss. Enable for platforms where static
|
|
8
|
+
* files are served by edge routing that never reaches the Nitro handler
|
|
9
|
+
* (Vercel, Netlify). Disable for CF Workers where no ASSETS means no assets.
|
|
10
|
+
*/
|
|
11
|
+
includeExternalFallback?: boolean;
|
|
12
|
+
onStepFailure?: (url: string, err: unknown) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a same-origin asset path to bytes, trying (in order):
|
|
16
|
+
* 1. Cloudflare Workers ASSETS binding
|
|
17
|
+
* 2. Nitro localFetch (`event.$fetch`) — resolves dynamic routes too
|
|
18
|
+
* 3. External `$fetch` to the Nitro origin (opt-in)
|
|
19
|
+
*
|
|
20
|
+
* All steps share a single `AbortSignal.timeout(fetchTimeout)` so a broken
|
|
21
|
+
* URL can't burn N× the configured budget.
|
|
22
|
+
*
|
|
23
|
+
* Returns `undefined` when every step fails or times out.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fetchLocalAsset(event: H3Event, path: string, options: FetchLocalAssetOptions): Promise<ArrayBuffer | undefined>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getNitroOrigin } from "#site-config/server/composables";
|
|
2
|
+
import { $fetch } from "ofetch";
|
|
3
|
+
import { tryCloudflareAssetsFetch } from "./cloudflareAssets.js";
|
|
4
|
+
export async function fetchLocalAsset(event, path, options) {
|
|
5
|
+
const { fetchTimeout, headers, includeExternalFallback = false, onStepFailure } = options;
|
|
6
|
+
const deadline = AbortSignal.timeout(fetchTimeout);
|
|
7
|
+
let result = await tryCloudflareAssetsFetch(event, path, deadline).catch((err) => {
|
|
8
|
+
onStepFailure?.(path, err);
|
|
9
|
+
return void 0;
|
|
10
|
+
});
|
|
11
|
+
if (result || deadline.aborted)
|
|
12
|
+
return result;
|
|
13
|
+
result = await event.$fetch(path, {
|
|
14
|
+
responseType: "arrayBuffer",
|
|
15
|
+
signal: deadline,
|
|
16
|
+
timeout: fetchTimeout,
|
|
17
|
+
headers
|
|
18
|
+
}).catch((err) => {
|
|
19
|
+
onStepFailure?.(path, err);
|
|
20
|
+
return void 0;
|
|
21
|
+
});
|
|
22
|
+
if (result || deadline.aborted || !includeExternalFallback)
|
|
23
|
+
return result;
|
|
24
|
+
const absolute = `${getNitroOrigin(event)}${path}`;
|
|
25
|
+
return await $fetch(absolute, {
|
|
26
|
+
responseType: "arrayBuffer",
|
|
27
|
+
signal: deadline,
|
|
28
|
+
timeout: fetchTimeout,
|
|
29
|
+
headers
|
|
30
|
+
}).catch((err) => {
|
|
31
|
+
onStepFailure?.(absolute, err);
|
|
32
|
+
return void 0;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface TimingEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
dur: number;
|
|
4
|
+
count?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface Timings {
|
|
7
|
+
start: (name: string) => () => number;
|
|
8
|
+
record: (name: string, ms: number) => void;
|
|
9
|
+
measure: <T>(name: string, fn: () => Promise<T> | T) => Promise<T>;
|
|
10
|
+
entries: () => TimingEntry[];
|
|
11
|
+
header: () => string;
|
|
12
|
+
}
|
|
13
|
+
export declare function createTimings(): Timings;
|
|
14
|
+
export declare const TIMING_CTX_KEY = "_ogImageTimings";
|
|
15
|
+
export declare function getTimingsFromEvent(e: {
|
|
16
|
+
context: Record<string, any>;
|
|
17
|
+
}): Timings | undefined;
|