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.
Files changed (63) hide show
  1. package/dist/chunks/tw4.cjs +1 -1
  2. package/dist/chunks/tw4.mjs +1 -1
  3. package/dist/chunks/uno.cjs +1 -1
  4. package/dist/chunks/uno.mjs +1 -1
  5. package/dist/devtools/200.html +1 -1
  6. package/dist/devtools/404.html +1 -1
  7. package/dist/devtools/_nuxt/{BWm573-p.js → B-y6Zfh-.js} +1 -1
  8. package/dist/devtools/_nuxt/{w_tq7NMl.js → B5qFn-Gu.js} +1 -1
  9. package/dist/devtools/_nuxt/{CHeKziWa.js → BVne4GIn.js} +1 -1
  10. package/dist/devtools/_nuxt/{yBiBpwD7.js → BlCLj09b.js} +6 -6
  11. package/dist/devtools/_nuxt/CN79P4uE.js +6 -0
  12. package/dist/devtools/_nuxt/{Cy0omYQh.js → ClxM7Lmy.js} +1 -1
  13. package/dist/devtools/_nuxt/{DziLl24l.js → CwlJb64V.js} +1 -1
  14. package/dist/devtools/_nuxt/{BsivBvAU.js → D7u0rku6.js} +1 -1
  15. package/dist/devtools/_nuxt/DevtoolsSection.C56mUBtZ.css +1 -0
  16. package/dist/devtools/_nuxt/DevtoolsSnippet.BfzotPc4.css +1 -0
  17. package/dist/devtools/_nuxt/{BQDcSiMf.js → H3aAOs4d.js} +1 -1
  18. package/dist/devtools/_nuxt/{B6Dg3dZ6.js → QLqpVsIv.js} +1 -1
  19. package/dist/devtools/_nuxt/builds/latest.json +1 -1
  20. package/dist/devtools/_nuxt/builds/meta/cbeb6bb2-84a2-4c9c-a725-a30474eb2d1e.json +1 -0
  21. package/dist/devtools/_nuxt/{entry.BjD2aghs.css → entry.DTjl7Lr7.css} +1 -1
  22. package/dist/devtools/_nuxt/{pages.DO0dnDUs.css → pages.DqKYLiSy.css} +1 -1
  23. package/dist/devtools/_nuxt/renderer-select.Do_uPaOg.css +1 -0
  24. package/dist/devtools/_nuxt/{BMVWkjCo.js → rybsNufq.js} +1 -1
  25. package/dist/devtools/debug/index.html +1 -1
  26. package/dist/devtools/docs/index.html +1 -1
  27. package/dist/devtools/index.html +1 -1
  28. package/dist/devtools/templates/index.html +1 -1
  29. package/dist/module.cjs +1 -1
  30. package/dist/module.d.cts +11 -0
  31. package/dist/module.d.mts +11 -0
  32. package/dist/module.d.ts +11 -0
  33. package/dist/module.json +1 -1
  34. package/dist/module.mjs +1 -1
  35. package/dist/runtime/app/client-utils.js +24 -5
  36. package/dist/runtime/server/og-image/bindings/font-assets/cloudflare.js +9 -18
  37. package/dist/runtime/server/og-image/bindings/font-assets/dev-prerender.js +8 -4
  38. package/dist/runtime/server/og-image/bindings/font-assets/node.js +6 -2
  39. package/dist/runtime/server/og-image/browser/screenshot.d.ts +1 -1
  40. package/dist/runtime/server/og-image/browser/screenshot.js +6 -4
  41. package/dist/runtime/server/og-image/context.js +4 -0
  42. package/dist/runtime/server/og-image/core/plugins/imageSrc.js +106 -99
  43. package/dist/runtime/server/og-image/core/transforms/emojis/fetch.js +23 -12
  44. package/dist/runtime/server/og-image/satori/renderer.js +16 -14
  45. package/dist/runtime/server/og-image/takumi/renderer.js +27 -19
  46. package/dist/runtime/server/util/cloudflareAssets.d.ts +24 -0
  47. package/dist/runtime/server/util/cloudflareAssets.js +16 -0
  48. package/dist/runtime/server/util/eventHandlers.js +28 -5
  49. package/dist/runtime/server/util/fetchLocalAsset.d.ts +25 -0
  50. package/dist/runtime/server/util/fetchLocalAsset.js +34 -0
  51. package/dist/runtime/server/util/fetchTimeout.d.ts +2 -0
  52. package/dist/runtime/server/util/fetchTimeout.js +7 -0
  53. package/dist/runtime/server/util/timings.d.ts +17 -0
  54. package/dist/runtime/server/util/timings.js +75 -0
  55. package/dist/runtime/types.d.ts +3 -0
  56. package/dist/shared/{nuxt-og-image.C2oXAHiT.cjs → nuxt-og-image.B55LvX3B.cjs} +2 -1
  57. package/dist/shared/{nuxt-og-image.BK0-aZom.mjs → nuxt-og-image.BHvLMojr.mjs} +1 -0
  58. package/package.json +6 -6
  59. package/dist/devtools/_nuxt/DDRo8-tD.js +0 -6
  60. package/dist/devtools/_nuxt/DevtoolsSection.C-PGRg5f.css +0 -1
  61. package/dist/devtools/_nuxt/DevtoolsSnippet.BipAyEUC.css +0 -1
  62. package/dist/devtools/_nuxt/builds/meta/150c0674-b387-4525-9043-e05dd343db8e.json +0 -1
  63. 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, { e, publicStoragePath, runtimeConfig }) => {
156
+ transform: async (node, ctx) => {
74
157
  let src = node.props.src;
75
- const isRelative = src.startsWith("/");
76
- let dimensions;
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
- if (isRelative) {
82
- if (import.meta.prerender || import.meta.dev) {
83
- const srcWithoutBase = src.replace(runtimeConfig.app.baseURL, "");
84
- imageBuffer = await resolveLocalFilePathImage(publicStoragePath, srcWithoutBase);
85
- }
86
- if (!imageBuffer) {
87
- imageBuffer = await e.$fetch(src, { responseType: "arrayBuffer" }).catch(() => {
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 (typeof node.props.src === "string" && node.props.src.startsWith("/")) {
137
- if (imageBuffer) {
138
- const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer;
139
- node.props.src = toBase64Image(buffer);
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, { e, publicStoragePath, runtimeConfig }) => {
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 isRelative = src?.startsWith("/");
155
- let imageBuffer;
156
- if (isRelative) {
157
- if (import.meta.prerender || import.meta.dev) {
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
- for (const iconName of possibleNames) {
19
- try {
20
- svg = await $fetch(`https://api.iconify.design/${emojiSet}/${iconName}.svg`, {
21
- responseType: "text",
22
- retry: 2,
23
- retryDelay: 500
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
- const resvg = new Resvg(svg, options);
96
- const pngData = resvg.render();
97
- const png = pngData.asPng();
98
- if (typeof pngData.free === "function")
99
- pngData.free();
100
- if (typeof resvg.free === "function")
101
- resvg.free();
102
- return png;
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
- await Promise.all(resourceUrls.map(async (src) => {
106
- const urlsToTry = [src];
107
- if (src.startsWith("/")) {
108
- urlsToTry.push(withBase(src, origin));
109
- if (baseURL && baseURL !== "/" && !src.startsWith(baseURL)) {
110
- urlsToTry.push(withBase(withBase(src, baseURL), origin));
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
- break;
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
- if (cacheApi instanceof H3Error)
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 || 15e3;
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,2 @@
1
+ import type { OgImageRuntimeConfig } from '../../types.js';
2
+ export declare function getFetchTimeout(runtimeConfig: OgImageRuntimeConfig): number;
@@ -0,0 +1,7 @@
1
+ const MIN_TIMEOUT_MS = 100;
2
+ export function getFetchTimeout(runtimeConfig) {
3
+ const value = runtimeConfig.security?.imageFetchTimeout ?? 3e3;
4
+ if (!Number.isFinite(value) || value < MIN_TIMEOUT_MS)
5
+ return MIN_TIMEOUT_MS;
6
+ return value;
7
+ }
@@ -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;