nuxt-og-image 6.8.0 → 6.9.1

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 (30) hide show
  1. package/dist/chunks/tw4.mjs +1 -1
  2. package/dist/chunks/uno.mjs +2 -2
  3. package/dist/module.d.mts +1 -1
  4. package/dist/module.d.ts +1 -1
  5. package/dist/module.json +1 -1
  6. package/dist/module.mjs +1 -1
  7. package/dist/runtime/app/composables/defineOgImageScreenshot.js +4 -1
  8. package/dist/runtime/server/nitro.d.ts +9 -0
  9. package/dist/runtime/server/nitro.js +10 -0
  10. package/dist/runtime/server/og-image/bindings/fetch/node.d.ts +2 -0
  11. package/dist/runtime/server/og-image/bindings/fetch/node.js +42 -0
  12. package/dist/runtime/server/og-image/bindings/fetch/web.d.ts +7 -0
  13. package/dist/runtime/server/og-image/bindings/fetch/web.js +3 -0
  14. package/dist/runtime/server/og-image/bindings/font-assets/external-url.d.ts +0 -11
  15. package/dist/runtime/server/og-image/bindings/font-assets/external-url.js +2 -19
  16. package/dist/runtime/server/og-image/browser/screenshot.js +4 -1
  17. package/dist/runtime/server/og-image/context.js +9 -1
  18. package/dist/runtime/server/plugins/prerender.js +4 -3
  19. package/dist/runtime/server/routes/resolve.js +2 -2
  20. package/dist/runtime/server/util/cloudflare.js +2 -2
  21. package/dist/runtime/server/util/ssrf.d.ts +31 -0
  22. package/dist/runtime/server/util/ssrf.js +64 -5
  23. package/dist/runtime/server/utils.d.ts +1 -1
  24. package/dist/runtime/server/utils.js +3 -3
  25. package/dist/runtime/shared/urlEncoding.d.ts +9 -3
  26. package/dist/runtime/shared/urlEncoding.js +27 -10
  27. package/dist/runtime/types.d.ts +5 -2
  28. package/dist/shared/{nuxt-og-image.D936Fp6O.mjs → nuxt-og-image.CNS1IVAi.mjs} +82 -37
  29. package/package.json +38 -41
  30. package/types/virtual.d.ts +8 -0
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { resolveModulePath } from 'exsolve';
4
4
  import { dirname, join } from 'pathe';
5
- import { e as extractVariantBaseClasses, r as resolveVariantPrefixes, h as resolveExtractedVars, l as resolveVarsDeep, s as simplifyCss, p as postProcessStyles, m as loadLightningCss, a as extractCssVars, c as extractUniversalVars, d as extractPerClassVars, g as extractVarsFromCss, f as extractClassStyles } from '../shared/nuxt-og-image.D936Fp6O.mjs';
5
+ import { e as extractVariantBaseClasses, r as resolveVariantPrefixes, h as resolveExtractedVars, l as resolveVarsDeep, s as simplifyCss, p as postProcessStyles, m as loadLightningCss, a as extractCssVars, c as extractUniversalVars, d as extractPerClassVars, g as extractVarsFromCss, f as extractClassStyles } from '../shared/nuxt-og-image.CNS1IVAi.mjs';
6
6
  import 'node:crypto';
7
7
  import 'node:fs';
8
8
  import '@nuxt/kit';
@@ -1,8 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { defu } from 'defu';
3
3
  import { logger } from '../../dist/runtime/logger.js';
4
- import { e as extractVariantBaseClasses, s as simplifyCss, a as extractCssVars, b as extractPropertyInitialValues, c as extractUniversalVars, d as extractPerClassVars, f as extractClassStyles, p as postProcessStyles, r as resolveVariantPrefixes, i as importOptionalPeer, g as extractVarsFromCss, h as resolveExtractedVars } from '../shared/nuxt-og-image.D936Fp6O.mjs';
5
- export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.D936Fp6O.mjs';
4
+ import { e as extractVariantBaseClasses, s as simplifyCss, a as extractCssVars, b as extractPropertyInitialValues, c as extractUniversalVars, d as extractPerClassVars, f as extractClassStyles, p as postProcessStyles, r as resolveVariantPrefixes, i as importOptionalPeer, g as extractVarsFromCss, h as resolveExtractedVars } from '../shared/nuxt-og-image.CNS1IVAi.mjs';
5
+ export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.CNS1IVAi.mjs';
6
6
  import 'node:crypto';
7
7
  import 'node:fs';
8
8
  import 'node:url';
package/dist/module.d.mts CHANGED
@@ -142,7 +142,7 @@ interface ModuleOptions {
142
142
  /**
143
143
  * Browser renderer configuration.
144
144
  *
145
- * When using browser-based rendering (screenshots), configure the browser provider.
145
+ * Set to true to enable defineOgImageScreenshot(), or configure a browser provider.
146
146
  * For Cloudflare deployments, specify the browser binding name.
147
147
  *
148
148
  * @example { provider: 'cloudflare', binding: 'BROWSER' }
package/dist/module.d.ts CHANGED
@@ -142,7 +142,7 @@ interface ModuleOptions {
142
142
  /**
143
143
  * Browser renderer configuration.
144
144
  *
145
- * When using browser-based rendering (screenshots), configure the browser provider.
145
+ * Set to true to enable defineOgImageScreenshot(), or configure a browser provider.
146
146
  * For Cloudflare deployments, specify the browser binding name.
147
147
  *
148
148
  * @example { provider: 'cloudflare', binding: 'BROWSER' }
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=3.16.0"
5
5
  },
6
6
  "configKey": "ogImage",
7
- "version": "6.8.0",
7
+ "version": "6.9.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -10,7 +10,7 @@ import 'nuxtseo-shared/kit';
10
10
  import 'pathe';
11
11
  import 'pkg-types';
12
12
  import 'std-env';
13
- export { n as default } from './shared/nuxt-og-image.D936Fp6O.mjs';
13
+ export { n as default } from './shared/nuxt-og-image.CNS1IVAi.mjs';
14
14
  import '../dist/runtime/logger.js';
15
15
  import 'magic-string';
16
16
  import 'oxc-walker';
@@ -1,6 +1,9 @@
1
- import { useRouter } from "#app";
1
+ import { createError, useRouter } from "#app";
2
+ import { useOgImageRuntimeConfig } from "../utils.js";
2
3
  import { defineOgImageRaw } from "./_defineOgImageRaw.js";
3
4
  export function defineOgImageScreenshot(options = {}) {
5
+ if (!useOgImageRuntimeConfig().browserEnabled)
6
+ throw createError({ statusCode: 500, statusMessage: "[nuxt-og-image] Screenshots require browser opt-in. Set ogImage.browser to true or a browser provider in nuxt.config.ts." });
4
7
  const router = useRouter();
5
8
  const route = router.currentRoute.value?.path || "/";
6
9
  const { delay, mask, selector, colorScheme, ...rest } = options;
@@ -0,0 +1,9 @@
1
+ import type { H3Event } from '#nuxtseo/h3';
2
+ import type { OgImageOptionsInternal } from '../types.js';
3
+ type Options = Partial<OgImageOptionsInternal>;
4
+ /**
5
+ * Build the absolute OG image URL for a page. Without a page path, uses the current request path.
6
+ */
7
+ export declare function getOgImageUrl(event: H3Event, options?: Options): string;
8
+ export declare function getOgImageUrl(event: H3Event, pagePath: string, options?: Options): string;
9
+ export {};
@@ -0,0 +1,10 @@
1
+ import { parseURL, withoutBase } from "ufo";
2
+ import { useRuntimeConfig } from "#nuxtseo/nitro";
3
+ import { withSiteUrl } from "#site-config/server/composables/utils";
4
+ import { getOgImagePath } from "./utils.js";
5
+ export function getOgImageUrl(event, pathOrOptions, maybeOptions) {
6
+ const pagePath = typeof pathOrOptions === "string" ? pathOrOptions : withoutBase(parseURL(event.path).pathname, useRuntimeConfig(event).app.baseURL) || "/";
7
+ const options = typeof pathOrOptions === "string" ? maybeOptions : pathOrOptions;
8
+ const { path } = getOgImagePath(event, pagePath, options);
9
+ return withSiteUrl(event, path, { canonical: !import.meta.dev });
10
+ }
@@ -0,0 +1,2 @@
1
+ import type { GuardedFetchInit } from './web.js';
2
+ export declare function guardedFetch(url: string, { headers, signal, isBlockedAddress }: GuardedFetchInit): Promise<Response>;
@@ -0,0 +1,42 @@
1
+ import { lookup as dnsLookup } from "node:dns";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { Readable } from "node:stream";
5
+ function guardedLookup(isBlocked) {
6
+ return (hostname, options, callback) => {
7
+ dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
8
+ if (err)
9
+ return callback(err, "", 0);
10
+ const blocked = addresses.find((a) => isBlocked(a.address));
11
+ if (blocked)
12
+ return callback(new Error(`[Nuxt OG Image] ${hostname} resolves to blocked address ${blocked.address}.`), "", 0);
13
+ if (options.all)
14
+ return callback(null, addresses);
15
+ callback(null, addresses[0].address, addresses[0].family);
16
+ });
17
+ };
18
+ }
19
+ export function guardedFetch(url, { headers, signal, isBlockedAddress }) {
20
+ const request = url.startsWith("https:") ? httpsRequest : httpRequest;
21
+ return new Promise((resolve, reject) => {
22
+ const req = request(url, {
23
+ // Match the defaults that `fetch` sent before this transport existed.
24
+ headers: { "accept": "*/*", "user-agent": "node", ...headers },
25
+ signal,
26
+ lookup: guardedLookup(isBlockedAddress ?? (() => false)),
27
+ // A fresh socket per hop: never reuse a connection nobody checked.
28
+ agent: false
29
+ }, (res) => {
30
+ const status = res.statusCode ?? 0;
31
+ const responseHeaders = new Headers();
32
+ for (const [name, value] of Object.entries(res.headers)) {
33
+ for (const v of [value ?? []].flat())
34
+ responseHeaders.append(name, v);
35
+ }
36
+ const body = status === 204 || status === 304 ? null : Readable.toWeb(res);
37
+ resolve(new Response(body, { status, headers: responseHeaders }));
38
+ });
39
+ req.on("error", reject);
40
+ req.end();
41
+ });
42
+ }
@@ -0,0 +1,7 @@
1
+ export interface GuardedFetchInit {
2
+ headers?: Record<string, string>;
3
+ signal: AbortSignal;
4
+ /** Rejects the connection when a resolved address matches. Node only. */
5
+ isBlockedAddress?: (address: string) => boolean;
6
+ }
7
+ export declare function guardedFetch(url: string, { headers, signal }: GuardedFetchInit): Promise<Response>;
@@ -0,0 +1,3 @@
1
+ export function guardedFetch(url, { headers, signal }) {
2
+ return fetch(url, { redirect: "manual", headers, signal });
3
+ }
@@ -12,17 +12,6 @@ export declare function isDataFontUrl(path: string): boolean;
12
12
  * Check `isDataFontUrl` first — `data:` is non-http and would report true here.
13
13
  */
14
14
  export declare function isExternalFontUrl(path: string): boolean;
15
- /**
16
- * Resolve an authority-bearing font URL to the href to fetch, but only when it
17
- * is same-origin with the configured site URL. Returns null otherwise.
18
- *
19
- * Runtime external font URLs are unsupported — `@nuxt/fonts` is the only
20
- * supported way to load custom fonts, and it serves them same-origin. The single
21
- * allowed exception is the site's own origin, gated on an explicitly configured
22
- * site URL. Resolving against that origin also collapses canonicalization
23
- * bypasses (a crafted " //127.0.0.1" resolves cross-origin → rejected).
24
- */
25
- export declare function resolveSameOriginFontUrl(path: string, siteUrl: string | undefined): string | null;
26
15
  /**
27
16
  * Fetch a `data:` or authority-bearing font URL. `data:` is decoded directly
28
17
  * (no network). An external URL is fetched only when same-origin with the site
@@ -1,4 +1,4 @@
1
- import { fetchWithRedirectValidation } from "../../../util/ssrf.js";
1
+ import { fetchWithRedirectValidation, resolveSameOriginUrl } from "../../../util/ssrf.js";
2
2
  const SENTINEL_ORIGIN = "http://font-asset.invalid/";
3
3
  const SENTINEL_HOST = "font-asset.invalid";
4
4
  export function isDataFontUrl(path) {
@@ -13,23 +13,6 @@ export function isExternalFontUrl(path) {
13
13
  }
14
14
  return resolved.protocol !== "http:" || resolved.host !== SENTINEL_HOST;
15
15
  }
16
- export function resolveSameOriginFontUrl(path, siteUrl) {
17
- if (!siteUrl)
18
- return null;
19
- let siteOrigin;
20
- try {
21
- siteOrigin = new URL(siteUrl).origin;
22
- } catch {
23
- return null;
24
- }
25
- let target;
26
- try {
27
- target = new URL(path, siteOrigin);
28
- } catch {
29
- return null;
30
- }
31
- return target.origin === siteOrigin ? target.href : null;
32
- }
33
16
  export async function fetchSpecialFontUrl(path, siteUrl, timeout) {
34
17
  if (isDataFontUrl(path)) {
35
18
  const res = await fetch(path.trimStart(), { signal: AbortSignal.timeout(timeout) }).catch(() => {
@@ -39,7 +22,7 @@ export async function fetchSpecialFontUrl(path, siteUrl, timeout) {
39
22
  return Buffer.from(await res.arrayBuffer());
40
23
  throw new Error("[Nuxt OG Image] Invalid data: font URL.");
41
24
  }
42
- const href = resolveSameOriginFontUrl(path, siteUrl);
25
+ const href = resolveSameOriginUrl(path, siteUrl);
43
26
  if (!href)
44
27
  throw new Error("[Nuxt OG Image] External font URLs are not supported. Load custom fonts via @nuxt/fonts.");
45
28
  const ab = await fetchWithRedirectValidation(href, { timeout });
@@ -5,6 +5,7 @@ import { getNitroOrigin } from "#site-config/server/composables";
5
5
  import { buildOgImageUrl } from "../../../shared.js";
6
6
  import { getFetchTimeout } from "../../util/fetchTimeout.js";
7
7
  import { logger } from "../../util/logger.js";
8
+ import { resolveSameOriginUrl } from "../../util/ssrf.js";
8
9
  import { useOgImageRuntimeConfig } from "../../utils.js";
9
10
  function isPlaywrightPage(page) {
10
11
  return typeof page.setViewportSize === "function";
@@ -101,7 +102,9 @@ export async function createScreenshot({ basePath, e, options, extension, timing
101
102
  await page.setContent(html, { waitUntil: "networkidle0" });
102
103
  }
103
104
  } else {
104
- const url = isPlaywrightPage(page) ? withQuery(path, options.props || {}) : `${getNitroOrigin(e)}${withQuery(path, options.props || {})}`;
105
+ const url = resolveSameOriginUrl(withQuery(path, options.props || {}), getNitroOrigin(e));
106
+ if (!url)
107
+ throw new Error("[Nuxt OG Image] The browser renderer can only navigate to a same-origin path.");
105
108
  await gotoWithIdle(page, url, 1e4);
106
109
  }
107
110
  const screenshotOptions = {
@@ -13,6 +13,7 @@ import { autoEjectCommunityTemplate } from "../util/auto-eject.js";
13
13
  import { createNitroRouteRuleMatcher } from "../util/kit.js";
14
14
  import { normaliseOptions } from "../util/options.js";
15
15
  import { getEventQuery } from "../util/query.js";
16
+ import { parseSameOriginPath } from "../util/ssrf.js";
16
17
  import { createTimings, TIMING_CTX_KEY } from "../util/timings.js";
17
18
  import { withTimeout } from "../util/withTimeout.js";
18
19
  import { useOgImageRuntimeConfig } from "../utils.js";
@@ -120,7 +121,14 @@ export async function resolveContext(e) {
120
121
  delete queryParams.html;
121
122
  queryParams = separateProps(queryParams);
122
123
  }
123
- const basePath = withoutTrailingSlash(urlOptions._path || "/");
124
+ const rawPath = urlOptions._path ?? "/";
125
+ const basePath = parseSameOriginPath(typeof rawPath === "string" ? withoutTrailingSlash(rawPath) : rawPath);
126
+ if (!basePath) {
127
+ return createError({
128
+ statusCode: 400,
129
+ statusMessage: "[Nuxt OG Image] The _path option must be a same-origin path."
130
+ });
131
+ }
124
132
  const componentHash = urlOptions._componentHash || "";
125
133
  delete urlOptions._path;
126
134
  delete urlOptions._hash;
@@ -1,7 +1,7 @@
1
1
  import { parse } from "devalue";
2
- import { parseURL } from "ufo";
2
+ import { parseURL, withoutBase } from "ufo";
3
3
  import { appendResponseHeader } from "#nuxtseo/h3";
4
- import { defineNitroPlugin } from "#nuxtseo/nitro";
4
+ import { defineNitroPlugin, useRuntimeConfig } from "#nuxtseo/nitro";
5
5
  import { prerenderOptionsCache } from "#og-image-cache";
6
6
  import { createSitePathResolver } from "#site-config/server/composables/utils";
7
7
  import { isInternalRoute } from "../../shared.js";
@@ -37,9 +37,10 @@ export default defineNitroPlugin(async (nitro) => {
37
37
  });
38
38
  const key = resolvePathCacheKey(ctx.event, resolvePathWithBase(path));
39
39
  await prerenderOptionsCache.setItem(key, payloads);
40
+ const pagePath = withoutBase(path, useRuntimeConfig().app.baseURL);
40
41
  for (const [_ogKey, opt] of payloads) {
41
42
  if (opt._hash) {
42
- await prerenderOptionsCache.setItem(`hash:${opt._hash}`, opt);
43
+ await prerenderOptionsCache.setItem(`hash:${opt._hash}`, { ...opt, _path: pagePath });
43
44
  }
44
45
  }
45
46
  const prerenderPaths = ctx.event.context._ogImagePrerenderPaths;
@@ -4,6 +4,7 @@ import { fetchWithEvent } from "#nuxtseo/nitro";
4
4
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
5
5
  import { isInternalRoute } from "../../shared.js";
6
6
  import { getEventQuery } from "../util/query.js";
7
+ import { parseSameOriginPath } from "../util/ssrf.js";
7
8
  import { useOgImageRuntimeConfig } from "../utils.js";
8
9
  const RE_META_TAG = /<meta\b[^>]*>/gi;
9
10
  const RE_META_KEY = /\b(?:property|name)\s*=\s*(?:"([^"]+)"|'([^']+)')/i;
@@ -11,7 +12,6 @@ const RE_META_CONTENT = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
11
12
  const MAX_RESOLVE_PATH_LENGTH = 2048;
12
13
  const RE_STRIP_PREFIX = /^.*?\/_og\/r/;
13
14
  const RE_IMAGE_EXT = /\.(?:png|jpe?g|webp|svg)$/i;
14
- const RE_DOUBLE_LEADING_SLASH = /^\/{2,}/;
15
15
  function extractMeta(html, key) {
16
16
  for (const tagMatch of html.matchAll(RE_META_TAG)) {
17
17
  const tag = tagMatch[0];
@@ -78,7 +78,7 @@ export default defineEventHandler(async (event) => {
78
78
  statusMessage: `[Nuxt OG Image] Target path exceeds ${MAX_RESOLVE_PATH_LENGTH} characters.`
79
79
  });
80
80
  }
81
- if (targetPath.includes("://") || RE_DOUBLE_LEADING_SLASH.test(targetPath)) {
81
+ if (targetPath.includes("://") || !parseSameOriginPath(targetPath)) {
82
82
  throw createError({
83
83
  statusCode: 400,
84
84
  statusMessage: "[Nuxt OG Image] Target path must be a same-origin path."
@@ -7,11 +7,11 @@ export function getCloudflareEnv(event) {
7
7
  const runtimeEnv = toRecord(runtimeCloudflare?.env);
8
8
  if (runtimeEnv)
9
9
  return runtimeEnv;
10
- const contextCloudflare = toRecord(event?.context.cloudflare);
10
+ const contextCloudflare = toRecord(event?.context?.cloudflare);
11
11
  const contextEnv = toRecord(contextCloudflare?.env);
12
12
  if (contextEnv)
13
13
  return contextEnv;
14
- const platform = toRecord(event?.context._platform);
14
+ const platform = toRecord(event?.context?._platform);
15
15
  const platformCloudflare = toRecord(platform?.cloudflare);
16
16
  return toRecord(platformCloudflare?.env);
17
17
  }
@@ -10,6 +10,36 @@ export declare function expandIPv6(addr: string): number[] | null;
10
10
  * blocked outright.
11
11
  */
12
12
  export declare function isBlockedUrl(url: string): boolean;
13
+ /** True when a resolved IP address is internal, reserved, or unparseable. */
14
+ export declare function isBlockedAddress(address: string): boolean;
15
+ declare const sameOriginPathBrand: unique symbol;
16
+ /**
17
+ * A path that stays on the current origin when resolved against any base URL.
18
+ * Only `parseSameOriginPath` creates one, so a value of this type is safe to
19
+ * pass to a same-origin fetch or a headless browser navigation.
20
+ */
21
+ export type SameOriginPath = string & {
22
+ readonly [sameOriginPathBrand]: true;
23
+ };
24
+ /**
25
+ * Parse an untrusted path (for example the `_path` URL option) into a
26
+ * same-origin path. Returns `undefined` for anything that could leave the
27
+ * origin: absolute URLs, other schemes, protocol-relative `//host`,
28
+ * backslash variants, control characters, and paths without a leading `/`
29
+ * (which a string concatenation with the origin turns into `origin@host`).
30
+ *
31
+ * GHSA-h5qf-97cw-2h86: an absolute `_path` navigated the browser renderer to
32
+ * an internal host and reflected the response into the image.
33
+ */
34
+ export declare function parseSameOriginPath(input: unknown): SameOriginPath | undefined;
35
+ /**
36
+ * Resolve `path` against `origin` and return the href, but only when the result
37
+ * stays on that origin. Returns null for a missing or unparseable origin, and for
38
+ * anything that resolves elsewhere. The WHATWG parser normalizes leading
39
+ * controls, tabs, and backslashes first, so " //127.0.0.1" resolves cross-origin
40
+ * and is rejected (GHSA-q8hw-4fvp-9rwv).
41
+ */
42
+ export declare function resolveSameOriginUrl(path: string, origin: string | undefined): string | null;
13
43
  export interface SafeFetchOptions {
14
44
  timeout: number;
15
45
  headers?: Record<string, string>;
@@ -35,3 +65,4 @@ export interface SafeFetchOptions {
35
65
  * usual missing-asset behaviour.
36
66
  */
37
67
  export declare function fetchWithRedirectValidation(initialUrl: string, opts: SafeFetchOptions): Promise<ArrayBuffer | null>;
68
+ export {};
@@ -1,4 +1,6 @@
1
+ import { guardedFetch } from "#og-image/bindings/fetch";
1
2
  const RE_IPV6_BRACKETS = /^\[|\]$/g;
3
+ const RE_TRAILING_DOTS = /\.+$/;
2
4
  const RE_DIGIT_ONLY = /^\d+$/;
3
5
  const RE_INT_IP = /^(?:0x[\da-f]+|\d+)$/i;
4
6
  const RE_HEX_GROUP = /^[0-9a-f]{1,4}$/i;
@@ -18,6 +20,12 @@ function isPrivateIPv4(a, b) {
18
20
  return true;
19
21
  if (a === 0)
20
22
  return true;
23
+ if (a === 100 && b >= 64 && b <= 127)
24
+ return true;
25
+ if (a === 198 && (b === 18 || b === 19))
26
+ return true;
27
+ if (a >= 224)
28
+ return true;
21
29
  return false;
22
30
  }
23
31
  export function expandIPv6(addr) {
@@ -88,6 +96,10 @@ function isPrivateIPv6(groups) {
88
96
  }
89
97
  if (g0 === 100 && g1 === 65435 && g2 === 1)
90
98
  return true;
99
+ if (g0 === 256 && g1 === 0 && g2 === 0 && g3 === 0)
100
+ return true;
101
+ if (g0 === 8193 && g1 === 0)
102
+ return true;
91
103
  if ((g0 & 65024) === 64512)
92
104
  return true;
93
105
  if ((g0 & 65472) === 65152)
@@ -114,7 +126,7 @@ export function isBlockedUrl(url) {
114
126
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
115
127
  return true;
116
128
  const hostname = parsed.hostname.toLowerCase();
117
- const bare = hostname.replace(RE_IPV6_BRACKETS, "");
129
+ const bare = hostname.replace(RE_IPV6_BRACKETS, "").replace(RE_TRAILING_DOTS, "");
118
130
  if (bare === "localhost" || bare.endsWith(".localhost"))
119
131
  return true;
120
132
  const dottedParts = bare.split(".");
@@ -137,6 +149,50 @@ export function isBlockedUrl(url) {
137
149
  }
138
150
  return false;
139
151
  }
152
+ export function isBlockedAddress(address) {
153
+ return isBlockedUrl(`http://${address.includes(":") ? `[${address}]` : address}/`);
154
+ }
155
+ const PROBE_ORIGIN = "http://og-image.invalid";
156
+ function hasUnsafePathChar(input) {
157
+ for (let i = 0; i < input.length; i++) {
158
+ const code = input.charCodeAt(i);
159
+ if (code <= 31 || code === 127 || code === 92)
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ export function parseSameOriginPath(input) {
165
+ if (typeof input !== "string" || input[0] !== "/" || input[1] === "/")
166
+ return void 0;
167
+ if (hasUnsafePathChar(input))
168
+ return void 0;
169
+ let resolved;
170
+ try {
171
+ resolved = new URL(input, PROBE_ORIGIN);
172
+ } catch {
173
+ return void 0;
174
+ }
175
+ if (resolved.origin !== PROBE_ORIGIN)
176
+ return void 0;
177
+ return input;
178
+ }
179
+ export function resolveSameOriginUrl(path, origin) {
180
+ if (!origin)
181
+ return null;
182
+ let base;
183
+ try {
184
+ base = new URL(origin).origin;
185
+ } catch {
186
+ return null;
187
+ }
188
+ let target;
189
+ try {
190
+ target = new URL(path, base);
191
+ } catch {
192
+ return null;
193
+ }
194
+ return target.origin === base ? target.href : null;
195
+ }
140
196
  export async function fetchWithRedirectValidation(initialUrl, opts) {
141
197
  const controller = new AbortController();
142
198
  const externalAbort = opts.signal;
@@ -161,20 +217,23 @@ export async function fetchWithRedirectValidation(initialUrl, opts) {
161
217
  }
162
218
  if (!isTrusted && isBlockedUrl(url))
163
219
  return null;
164
- const res = await fetch(url, {
165
- redirect: "manual",
220
+ const res = await guardedFetch(url, {
166
221
  signal: controller.signal,
167
- headers: opts.headers
222
+ headers: opts.headers,
223
+ isBlockedAddress: isTrusted ? void 0 : isBlockedAddress
168
224
  });
169
225
  if (res.status >= 300 && res.status < 400) {
226
+ await res.body?.cancel();
170
227
  const loc = res.headers.get("location");
171
228
  if (!loc)
172
229
  return null;
173
230
  url = new URL(loc, url).toString();
174
231
  continue;
175
232
  }
176
- if (!res.ok)
233
+ if (!res.ok) {
234
+ await res.body?.cancel();
177
235
  return null;
236
+ }
178
237
  return await res.arrayBuffer();
179
238
  }
180
239
  return null;
@@ -4,5 +4,5 @@ export interface GetOgImagePathResult {
4
4
  path: string;
5
5
  hash?: string;
6
6
  }
7
- export declare function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOptionsInternal>): GetOgImagePathResult;
7
+ export declare function getOgImagePath(event: H3Event, _pagePath: string, _options?: Partial<OgImageOptionsInternal>): GetOgImagePathResult;
8
8
  export declare function useOgImageRuntimeConfig(e?: H3Event): OgImageRuntimeConfig;
@@ -3,9 +3,9 @@ import { useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { componentNames } from "#og-image-virtual/component-names.mjs";
4
4
  import { buildOgImageUrl } from "../shared.js";
5
5
  import { getCloudflareEnv } from "./util/cloudflare.js";
6
- export function getOgImagePath(_pagePath, _options) {
7
- const baseURL = useRuntimeConfig().app.baseURL;
8
- const { defaults, security } = useOgImageRuntimeConfig();
6
+ export function getOgImagePath(event, _pagePath, _options) {
7
+ const baseURL = useRuntimeConfig(event).app.baseURL;
8
+ const { defaults, security } = useOgImageRuntimeConfig(event);
9
9
  const extension = _options?.extension || defaults.extension;
10
10
  const isStatic = import.meta.prerender && !(security?.secret && security?.strict);
11
11
  const options = { ..._options, _path: _pagePath };
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * Generate a deterministic hash from options object
3
- * Excludes _path so images with same options can be cached across pages
3
+ * Excludes _path so images with same options can be cached across pages,
4
+ * except for PageScreenshot, which renders the page itself.
4
5
  * Optionally includes componentHash and version for cache busting
6
+ *
7
+ * Uses the first 64 bits of a SHA-256 digest over a canonical (key-sorted)
8
+ * serialization. The previous 32-bit rolling hash collided on large sites,
9
+ * silently overwriting other pages' prerendered images.
5
10
  */
6
11
  export declare function hashOgImageOptions(options: Record<string, any>, componentHash?: string, version?: string): string;
7
12
  /**
@@ -25,7 +30,8 @@ export interface BuildOgImageUrlResult {
25
30
  /**
26
31
  * Build full OG image URL
27
32
  *
28
- * When encoded params exceed MAX_PATH_LENGTH, falls back to hash mode:
33
+ * Static URLs fall back to hash mode when encoded params exceed MAX_PATH_LENGTH
34
+ * or hold a character outside [A-Za-z0-9_.~-]:
29
35
  * - Returns short path: /_og/s/o_<hash>.png
30
36
  * - Returns hash in result for cache storage
31
37
  *
@@ -34,7 +40,7 @@ export interface BuildOgImageUrlResult {
34
40
  * @param isStatic - Whether this is a static/prerendered image
35
41
  * @param defaults - Optional defaults to skip from URL (keeps URLs shorter)
36
42
  * @example buildOgImageUrl({ width: 1200, props: { title: 'Hello' } }, 'png', true)
37
- * // Returns: { url: "/_og/s/w_1200,title_Hello.png" }
43
+ * // Returns: { url: "/_og/s/o_<hash>.png", hash: "<hash>" }
38
44
  */
39
45
  export declare function buildOgImageUrl(options: Record<string, any>, extension?: string, isStatic?: boolean, defaults?: Record<string, any>, secret?: string): BuildOgImageUrlResult;
40
46
  /**
@@ -19,6 +19,7 @@ const RE_COMMA_PARAM_SEPARATOR = /,(?=\w+_)/;
19
19
  const RE_SIGNATURE_SUFFIX = /,s_[^,]+$/;
20
20
  const RE_NON_ASCII = /[^\u0000-\u007F]/;
21
21
  const RE_WINDOWS_RESERVED_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/;
22
+ const RE_NOT_UNRESERVED = /[^\w.~-]/;
22
23
  const PARAM_ALIASES = {
23
24
  w: "width",
24
25
  h: "height",
@@ -85,19 +86,35 @@ function b64Decode(str) {
85
86
  }
86
87
  return Buffer.from(padded, "base64").toString("utf8");
87
88
  }
88
- function simpleHash(str) {
89
- let hash = 0;
90
- for (let i = 0; i < str.length; i++) {
91
- const char = str.charCodeAt(i);
92
- hash = (hash << 5) - hash + char;
93
- hash = hash & hash;
89
+ function canonicalJson(value) {
90
+ if (value === null || typeof value !== "object")
91
+ return JSON.stringify(value) ?? "null";
92
+ if (Array.isArray(value))
93
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
94
+ const record = value;
95
+ const entries = Object.keys(record).sort().filter((key) => record[key] !== void 0).map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
96
+ return `{${entries.join(",")}}`;
97
+ }
98
+ const B64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
99
+ function base64UrlToHex(input) {
100
+ let hex = "";
101
+ let buffer = 0;
102
+ let bits = 0;
103
+ for (const char of input) {
104
+ buffer = buffer << 6 | B64URL_ALPHABET.indexOf(char);
105
+ bits += 6;
106
+ if (bits >= 8) {
107
+ bits -= 8;
108
+ hex += (buffer >> bits & 255).toString(16).padStart(2, "0");
109
+ }
94
110
  }
95
- return Math.abs(hash).toString(36);
111
+ return hex;
96
112
  }
97
113
  export function hashOgImageOptions(options, componentHash, version) {
98
- const { _path, _hash, ...hashableOptions } = options;
114
+ const { _path, _hash, ...rest } = options;
115
+ const hashableOptions = rest.component === "PageScreenshot" ? { ...rest, _path } : rest;
99
116
  const hashInput = componentHash || version ? [hashableOptions, componentHash || "", version || ""] : hashableOptions;
100
- return simpleHash(JSON.stringify(hashInput));
117
+ return base64UrlToHex(digest(canonicalJson(hashInput))).slice(0, 16);
101
118
  }
102
119
  export function encodeOgImageParams(options, defaults) {
103
120
  const parts = [];
@@ -233,7 +250,7 @@ export function decodeOgImageParams(encoded) {
233
250
  export function buildOgImageUrl(options, extension = "png", isStatic = false, defaults, secret) {
234
251
  const encoded = encodeOgImageParams(options, defaults);
235
252
  const prefix = isStatic ? "/_og/s" : "/_og/d";
236
- if (isStatic && (encoded.length > MAX_PATH_LENGTH || encoded.includes("%"))) {
253
+ if (isStatic && (encoded.length > MAX_PATH_LENGTH || RE_NOT_UNRESERVED.test(encoded))) {
237
254
  const hash = hashOgImageOptions(options);
238
255
  return {
239
256
  url: `${prefix}/o_${hash}.${extension}`,
@@ -4,12 +4,13 @@ import type { SatoriOptions } from 'satori';
4
4
  import type { JpegOptions, SharpOptions } from 'sharp';
5
5
  import type { MaybeRefOrGetter, Ref } from 'vue';
6
6
  import type { H3Error, H3Event } from '#nuxtseo/h3';
7
+ import type { SameOriginPath } from './server/util/ssrf.js';
7
8
  import type { Timings } from './server/util/timings.js';
8
9
  export interface OgImageRenderEventContext {
9
10
  e: H3Event;
10
11
  extension: 'png' | 'jpeg' | 'jpg' | 'webp' | 'svg' | 'html' | 'json';
11
12
  key: string;
12
- basePath: string;
13
+ basePath: SameOriginPath;
13
14
  renderer: Renderer;
14
15
  options: OgImageOptionsInternal;
15
16
  isDevToolsContextRequest: boolean;
@@ -30,6 +31,7 @@ export interface OgImageIconsData {
30
31
  height: number;
31
32
  }
32
33
  export interface OgImageRuntimeConfig {
34
+ browserEnabled: boolean;
33
35
  version: string;
34
36
  satoriOptions: SatoriOptions;
35
37
  resvgOptions: ResvgRenderOptions;
@@ -76,7 +78,7 @@ export interface OgImageRuntimeConfig {
76
78
  }
77
79
  export type RendererType = 'satori' | 'browser' | 'takumi';
78
80
  export type BrowserProvider = 'playwright' | 'chrome-launcher' | 'on-demand' | 'cloudflare';
79
- export type BrowserConfig = false | {
81
+ export type BrowserConfig = boolean | {
80
82
  provider: BrowserProvider;
81
83
  /** Required when provider is 'cloudflare' - the wrangler binding name */
82
84
  binding?: string;
@@ -241,6 +243,7 @@ export interface RuntimeCompatibilitySchema {
241
243
  takumi: 'node' | 'node-dev' | 'wasm' | false;
242
244
  sharp: 'node' | false;
243
245
  emoji?: 'local' | 'fetch';
246
+ fetch?: 'node' | 'web';
244
247
  wasm?: any;
245
248
  }
246
249
  export interface RuntimeCompatibilityMeta {
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, rmSync } from 'node:fs';
4
4
  import { readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { resolvePath, useNuxt, getNuxtVersion, addTemplate, updateTemplates, directoryToURL, tryResolveModule, addTypeTemplate, defineNuxtModule, createResolver, addImports, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion, addVitePlugin, addBuildPlugin, addServerHandler, addPlugin, addComponentsDir, addServerPlugin } from '@nuxt/kit';
6
+ import { resolvePath, useNuxt, getNuxtVersion, addTemplate, updateTemplates, directoryToURL, tryResolveModule, addTypeTemplate, defineNuxtModule, createResolver, addImports, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion, addVitePlugin, addBuildPlugin, addServerHandler, addServerImports, addPlugin, addComponentsDir, addServerPlugin } from '@nuxt/kit';
7
7
  import { defu } from 'defu';
8
8
  import { fnv1a64Base36 } from 'fnv1a-64';
9
9
  import { installNuxtSiteConfig } from 'nuxt-site-config/kit';
@@ -119,6 +119,16 @@ async function hasResolvableDependency(dep) {
119
119
  return null;
120
120
  }).then((r) => r && r !== dep);
121
121
  }
122
+ const MIN_SATORI_VERSION = "0.33.5";
123
+ function isSupportedSatoriVersion(version) {
124
+ const current = (version || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
125
+ const minimum = MIN_SATORI_VERSION.split(".").map(Number);
126
+ for (let i = 0; i < minimum.length; i++) {
127
+ if ((current[i] ?? 0) !== minimum[i])
128
+ return (current[i] ?? 0) > minimum[i];
129
+ }
130
+ return true;
131
+ }
122
132
  const VALID_RENDERER_SUFFIXES = ["satori", "browser", "takumi"];
123
133
  const RE_RENDERER_SUFFIX_CI = /\.?(satori|browser|takumi)$/i;
124
134
  const RE_RENDERER_SUFFIX = /(Satori|Browser|Takumi)$/;
@@ -192,6 +202,8 @@ const cloudflare = {
192
202
  sharp: false,
193
203
  emoji: "fetch",
194
204
  // edge size limits - use API instead of bundling 24MB icons
205
+ fetch: "web",
206
+ // no DNS API
195
207
  wasm: {
196
208
  esmImport: true
197
209
  // Do NOT set lazy here — Nitro's cloudflare preset uses lazy: false because
@@ -214,8 +226,9 @@ const WebContainer = {
214
226
  satori: "wasm-fs",
215
227
  takumi: "wasm",
216
228
  sharp: false,
217
- emoji: "fetch"
229
+ emoji: "fetch",
218
230
  // webcontainer has size constraints
231
+ fetch: "web"
219
232
  };
220
233
  const RuntimeCompatibility = {
221
234
  "nitro-dev": NodeDevRuntime,
@@ -256,6 +269,7 @@ const RuntimeCompatibility = {
256
269
  sharp: false,
257
270
  emoji: "fetch",
258
271
  // edge size limits
272
+ fetch: "web",
259
273
  wasm: {
260
274
  rollup: {
261
275
  targetEnv: "auto-inline",
@@ -276,6 +290,7 @@ const RuntimeCompatibility = {
276
290
  sharp: false,
277
291
  emoji: "fetch",
278
292
  // edge size limits - bundling 24MB icons not viable
293
+ fetch: "web",
279
294
  wasm: {
280
295
  // lowers workers kb size
281
296
  esmImport: true,
@@ -351,32 +366,32 @@ async function applyNitroPresetCompatibility(nitroConfig, options) {
351
366
  );
352
367
  if (resolvedCompatibility.satori) {
353
368
  const satoriPackage = await readPackageJSON(`${satoriPkgDir}/package.json`);
354
- if (satoriPackage.dependencies?.harfbuzzjs) {
355
- const harfbuzzWasmPath = resolveModulePath("harfbuzzjs/hb.wasm", { from: `${satoriPkgDir}/package.json` });
356
- if (resolvedCompatibility.satori === "wasm" || target.replace(RE_LEGACY_SUFFIX, "") === "netlify-edge") {
357
- const harfbuzzDir = harfbuzzWasmPath.slice(0, -"hb.wasm".length);
358
- nitroConfig.virtual["#og-image/harfbuzz-callbacks"] = resolvedCompatibility.satori === "wasm" ? await prepareHarfBuzzCallbacks(`${harfbuzzDir}hbjs.js`, join(useNuxt().options.buildDir, "cache/og-image/harfbuzz")) : "export default undefined";
359
- Object.assign(nitroConfig.alias, {
360
- "harfbuzzjs": await resolve.resolvePath("./runtime/server/og-image/bindings/satori/harfbuzz"),
361
- "#og-image/harfbuzz-factory": `${harfbuzzDir}hb.js`,
362
- "#og-image/harfbuzz-adapter": `${harfbuzzDir}hbjs.js`
363
- });
364
- if (resolvedCompatibility.satori === "wasm") {
365
- nitroConfig.alias["#og-image/harfbuzz-wasm"] = `${harfbuzzWasmPath}?module`;
366
- } else {
367
- const base64 = (await readFile(harfbuzzWasmPath)).toString("base64");
368
- nitroConfig.virtual["#og-image/harfbuzz-wasm"] = `export default imports => WebAssembly.instantiate(Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0)), imports)`;
369
- }
370
- } else if (Number.parseInt(getNuxtVersion(useNuxt()), 10) >= 5) {
371
- const config = nitroConfig;
372
- config.traceDeps = [.../* @__PURE__ */ new Set([...config.traceDeps || [], "harfbuzzjs*"])];
369
+ if (!isSupportedSatoriVersion(satoriPackage.version))
370
+ throw new Error(`[Nuxt OG Image] Satori ${satoriPackage.version} is not supported. Satori ${MIN_SATORI_VERSION} or later is required (GHSA-wx4j-mvgx-mqwp). Run: npm install satori@latest`);
371
+ const harfbuzzWasmPath = resolveModulePath("harfbuzzjs/hb.wasm", { from: `${satoriPkgDir}/package.json` });
372
+ if (resolvedCompatibility.satori === "wasm" || target.replace(RE_LEGACY_SUFFIX, "") === "netlify-edge") {
373
+ const harfbuzzDir = harfbuzzWasmPath.slice(0, -"hb.wasm".length);
374
+ nitroConfig.virtual["#og-image/harfbuzz-callbacks"] = resolvedCompatibility.satori === "wasm" ? await prepareHarfBuzzCallbacks(`${harfbuzzDir}hbjs.js`, join(useNuxt().options.buildDir, "cache/og-image/harfbuzz")) : "export default undefined";
375
+ Object.assign(nitroConfig.alias, {
376
+ "harfbuzzjs": await resolve.resolvePath("./runtime/server/og-image/bindings/satori/harfbuzz"),
377
+ "#og-image/harfbuzz-factory": `${harfbuzzDir}hb.js`,
378
+ "#og-image/harfbuzz-adapter": `${harfbuzzDir}hbjs.js`
379
+ });
380
+ if (resolvedCompatibility.satori === "wasm") {
381
+ nitroConfig.alias["#og-image/harfbuzz-wasm"] = `${harfbuzzWasmPath}?module`;
373
382
  } else {
374
- nitroConfig.externals = nitroConfig.externals || {};
375
- nitroConfig.externals.traceInclude = [.../* @__PURE__ */ new Set([
376
- ...nitroConfig.externals.traceInclude || [],
377
- harfbuzzWasmPath
378
- ])];
383
+ const base64 = (await readFile(harfbuzzWasmPath)).toString("base64");
384
+ nitroConfig.virtual["#og-image/harfbuzz-wasm"] = `export default imports => WebAssembly.instantiate(Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0)), imports)`;
379
385
  }
386
+ } else if (Number.parseInt(getNuxtVersion(useNuxt()), 10) >= 5) {
387
+ const config = nitroConfig;
388
+ config.traceDeps = [.../* @__PURE__ */ new Set([...config.traceDeps || [], "harfbuzzjs*"])];
389
+ } else {
390
+ nitroConfig.externals = nitroConfig.externals || {};
391
+ nitroConfig.externals.traceInclude = [.../* @__PURE__ */ new Set([
392
+ ...nitroConfig.externals.traceInclude || [],
393
+ harfbuzzWasmPath
394
+ ])];
380
395
  }
381
396
  }
382
397
  if (Object.values(resolvedCompatibility).includes("wasm")) {
@@ -431,6 +446,12 @@ async function setupBuildHandler(config, resolve, getDetectedRenderers, getCompa
431
446
  metadata: getCompatibilityMeta()
432
447
  });
433
448
  nuxt.hooks.hook("nitro:init", async (nitro) => {
449
+ nitro.hooks.hook("prerender:config", (prerenderConfig) => {
450
+ if (prerenderConfig.virtual?.["playwright-core"]) {
451
+ const { "playwright-core": _, ...virtual } = prerenderConfig.virtual;
452
+ prerenderConfig.virtual = virtual;
453
+ }
454
+ });
434
455
  await applyNitroPresetCompatibility(nitro.options, {
435
456
  compatibility: config.compatibility?.runtime,
436
457
  resolve,
@@ -636,6 +657,14 @@ const PROVIDER_DEPENDENCIES = [
636
657
  }
637
658
  }
638
659
  ];
660
+ function resolveAutoDetectedProvider(input) {
661
+ if (input.hasUserComponents)
662
+ return { preferred: null, fallbackToDefault: false };
663
+ const preferred = input.installedProviders.find((p) => p === "takumi") ?? input.installedProviders[0] ?? null;
664
+ if (preferred)
665
+ return { preferred, fallbackToDefault: false };
666
+ return { preferred: null, fallbackToDefault: true };
667
+ }
639
668
  async function getInstalledProviders() {
640
669
  const installed = [];
641
670
  for (const provider of PROVIDER_DEPENDENCIES) {
@@ -4928,6 +4957,10 @@ declare module '#og-image/renderers/takumi' {
4928
4957
  export default _default
4929
4958
  }
4930
4959
 
4960
+ declare module '#og-image/bindings/fetch' {
4961
+ export { guardedFetch } from '${typesPath.replace("/types", "")}/server/og-image/bindings/fetch/web'
4962
+ }
4963
+
4931
4964
  declare module '#og-image/emoji-transform' {
4932
4965
  import type { OgImageRenderEventContext } from '${typesPath}'
4933
4966
  export function getEmojiSvg(ctx: OgImageRenderEventContext, emoji: string): Promise<string | null>
@@ -5172,6 +5205,7 @@ Or pin to a specific version in package.json:
5172
5205
  setRuntimeAlias("#og-image-cache", resolve("./runtime/server/og-image/cache/lru"));
5173
5206
  const preset = resolveOgImagePreset(nuxt.options.nitro);
5174
5207
  const targetCompatibility = getPresetNitroPresetCompatibility(preset);
5208
+ setRuntimeAlias("#og-image/bindings/fetch", resolve(`./runtime/server/og-image/bindings/fetch/${targetCompatibility.fetch ?? "node"}`));
5175
5209
  const runtimeCompatibilityMeta = {};
5176
5210
  const normalizedPreset = preset.replace(RE_LEGACY_SUFFIX, "");
5177
5211
  const isCloudflareWorkers = ["cloudflare", "cloudflare-module", "cloudflare-durable"].includes(normalizedPreset);
@@ -5587,6 +5621,9 @@ ${familyInfo}`);
5587
5621
  name: "getOgImagePath",
5588
5622
  from: resolve("./runtime/app/utils")
5589
5623
  });
5624
+ addServerImports([
5625
+ { name: "getOgImageUrl", from: resolve("./runtime/server/nitro") }
5626
+ ]);
5590
5627
  const basePluginPath = `./runtime/app/plugins${config.zeroRuntime ? "/__zero-runtime" : ""}`;
5591
5628
  addPlugin({ mode: "server", src: resolve(`${basePluginPath}/route-rule-og-image.server`) });
5592
5629
  addPlugin({ mode: "server", src: resolve(`${basePluginPath}/og-image-canonical-urls.server`) });
@@ -5634,19 +5671,25 @@ ${familyInfo}`);
5634
5671
  }
5635
5672
  }
5636
5673
  }
5674
+ if (config.browser)
5675
+ ogImageComponentCtx.detectedRenderers.add("browser");
5637
5676
  if (!nuxt.options._prepare && !hasUserComponents) {
5638
- const installedProviders = await getInstalledProviders();
5639
- const preferred = installedProviders.find((p) => p.provider === "takumi") || installedProviders[0];
5677
+ const { preferred, fallbackToDefault } = resolveAutoDetectedProvider({
5678
+ hasUserComponents,
5679
+ installedProviders: (await getInstalledProviders()).map((p) => p.provider)
5680
+ });
5640
5681
  if (preferred) {
5641
- ogImageComponentCtx.detectedRenderers.add(preferred.provider);
5642
- logger.debug(`Using ${preferred.provider} renderer`);
5643
- } else if (nuxt.options.dev && !nuxt.options._prepare && canPromptInteractively()) {
5644
- const renderer = await promptForRendererSelection();
5645
- ogImageComponentCtx.detectedRenderers.add(renderer);
5646
- logger.debug(`Using ${renderer} renderer`);
5647
- } else {
5648
- ogImageComponentCtx.detectedRenderers.add("takumi");
5649
- logger.warn(`No OG image renderer dependency detected. Defaulting to \`takumi\` (non-interactive environment). Install \`${TAKUMI_CORE_PACKAGE}\`, or add a renderer component (e.g. components/OgImage/Default.satori.vue) to choose explicitly.`);
5682
+ ogImageComponentCtx.detectedRenderers.add(preferred);
5683
+ logger.debug(`Using ${preferred} renderer`);
5684
+ } else if (fallbackToDefault) {
5685
+ if (nuxt.options.dev && !nuxt.options._prepare && canPromptInteractively()) {
5686
+ const renderer = await promptForRendererSelection();
5687
+ ogImageComponentCtx.detectedRenderers.add(renderer);
5688
+ logger.debug(`Using ${renderer} renderer`);
5689
+ } else {
5690
+ ogImageComponentCtx.detectedRenderers.add("takumi");
5691
+ logger.warn(`No OG image renderer dependency detected. Defaulting to \`takumi\` (non-interactive environment). Install \`${TAKUMI_CORE_PACKAGE}\`, or add a renderer component (e.g. components/OgImage/Default.satori.vue) to choose explicitly.`);
5692
+ }
5650
5693
  }
5651
5694
  }
5652
5695
  const availableRenderers = /* @__PURE__ */ new Set();
@@ -6052,6 +6095,7 @@ export const staticFontCacheDir = ${JSON.stringify(getStaticFontCacheDir(nuxt.op
6052
6095
  // @ts-expect-error runtime type
6053
6096
  isNuxtContentDocumentDriven: !!nuxt.options.content?.documentDriven,
6054
6097
  cssFramework: cssFramework || "none",
6098
+ browserEnabled: !!config.browser,
6055
6099
  // Browser renderer config for cloudflare binding access
6056
6100
  browser: typeof config.browser === "object" ? {
6057
6101
  provider: config.browser.provider,
@@ -6086,6 +6130,7 @@ export const staticFontCacheDir = ${JSON.stringify(getStaticFontCacheDir(nuxt.op
6086
6130
  "nuxt-og-image": {
6087
6131
  defaults: runtimeConfig.defaults,
6088
6132
  includeTwitter: runtimeConfig.includeTwitter,
6133
+ browserEnabled: runtimeConfig.browserEnabled,
6089
6134
  hasServerRuntime: !nuxt.options._generate && !nuxt.options.nitro?.static
6090
6135
  }
6091
6136
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-og-image",
3
3
  "type": "module",
4
- "version": "6.8.0",
4
+ "version": "6.9.1",
5
5
  "description": "Enlightened OG Image generation for Nuxt.",
6
6
  "author": {
7
7
  "website": "https://harlanzw.com",
@@ -53,13 +53,13 @@
53
53
  "@takumi-rs/core": "^1.0.0-beta.3 || ^2.0.0",
54
54
  "@takumi-rs/wasm": "^1.0.0-beta.3 || ^2.0.0",
55
55
  "@unhead/vue": "^2.0.5 || ^3.0.0",
56
- "@unocss/config": "^66.10.2",
57
- "@unocss/core": "^66.10.2",
58
- "fontless": "^0.2.0 || ^0.4.3",
56
+ "@unocss/config": "^66.10.5",
57
+ "@unocss/core": "^66.10.5",
58
+ "fontless": "^0.2.0 || ^0.4.3 || ^1.0.0",
59
59
  "nitropack": "^2.13.4",
60
60
  "playwright-core": "^1.50.0",
61
- "satori": ">=0.19.2",
62
- "sharp": "^0.34.0 || ^0.35.4",
61
+ "satori": ">=0.33.5",
62
+ "sharp": "^0.35.4",
63
63
  "tailwindcss": "^4.0.0",
64
64
  "unifont": "^0.7.0 || ^0.8.3",
65
65
  "unstorage": "^1.15.0",
@@ -110,9 +110,9 @@
110
110
  }
111
111
  },
112
112
  "dependencies": {
113
- "@clack/prompts": "^1.8.0",
113
+ "@clack/prompts": "^1.8.1",
114
114
  "@nuxt/kit": "^4.5.2",
115
- "@vue/compiler-sfc": "^3.5.42",
115
+ "@vue/compiler-sfc": "^3.5.43",
116
116
  "chrome-launcher": "^1.2.1",
117
117
  "consola": "^3.4.2",
118
118
  "defu": "^6.1.7",
@@ -120,7 +120,7 @@
120
120
  "exsolve": "^1.1.1",
121
121
  "fnv1a-64": "^0.1.2",
122
122
  "lightningcss": "^1.33.0",
123
- "magic-string": "^1.3.1",
123
+ "magic-string": "^1.4.1",
124
124
  "magicast": "^0.5.5",
125
125
  "mocked-exports": "^0.1.1",
126
126
  "nuxt-site-config": "^4.2.3",
@@ -129,7 +129,7 @@
129
129
  "object-identity": "^0.2.3",
130
130
  "ofetch": "^1.5.1",
131
131
  "ohash": "^2.0.12",
132
- "oxc-parser": "^0.149.0",
132
+ "oxc-parser": "^0.151.0",
133
133
  "oxc-walker": "^1.1.1",
134
134
  "pathe": "^2.0.3",
135
135
  "pkg-types": "^2.3.3",
@@ -139,21 +139,21 @@
139
139
  "tinyexec": "^1.3.1",
140
140
  "ufo": "^1.6.4",
141
141
  "ultrahtml": "^1.7.0",
142
- "unplugin": "^3.3.0"
142
+ "unplugin": "^3.4.0"
143
143
  },
144
144
  "devDependencies": {
145
145
  "@antfu/eslint-config": "^9.5.1",
146
146
  "@fontsource/noto-sans-sc": "^5.3.0",
147
147
  "@iconify-json/carbon": "^1.2.27",
148
148
  "@iconify-json/logos": "^1.2.14",
149
- "@iconify-json/noto": "^1.2.7",
149
+ "@iconify-json/noto": "^1.2.8",
150
150
  "@iconify-json/ri": "^1.2.10",
151
- "@iconify-json/tabler": "^1.2.38",
151
+ "@iconify-json/tabler": "^1.2.39",
152
152
  "@iconify/types": "^2.0.0",
153
153
  "@img/sharp-linux-x64": "^0.35.4",
154
- "@nuxt/content": "^3.16.0",
155
- "@nuxt/devtools": "4.0.0-alpha.18",
156
- "@nuxt/devtools-kit": "4.0.0-alpha.18",
154
+ "@nuxt/content": "^3.16.1",
155
+ "@nuxt/devtools": "4.0.0-beta.1",
156
+ "@nuxt/devtools-kit": "4.0.0-beta.1",
157
157
  "@nuxt/fonts": "^0.14.0",
158
158
  "@nuxt/icon": "^2.5.1",
159
159
  "@nuxt/image": "^2.1.0",
@@ -167,30 +167,30 @@
167
167
  "@resvg/resvg-js": "^2.6.2",
168
168
  "@resvg/resvg-wasm": "^2.6.2",
169
169
  "@tailwindcss/vite": "^4.3.3",
170
- "@takumi-rs/core": "^2.13.7",
170
+ "@takumi-rs/core": "^2.14.0",
171
171
  "@takumi-rs/core-v1": "npm:@takumi-rs/core@1.8.7",
172
- "@takumi-rs/helpers": "^2.13.7",
172
+ "@takumi-rs/helpers": "^2.14.0",
173
173
  "@takumi-rs/helpers-v1": "npm:@takumi-rs/helpers@1.8.7",
174
- "@takumi-rs/wasm": "^2.13.7",
174
+ "@takumi-rs/wasm": "^2.14.0",
175
175
  "@takumi-rs/wasm-v1": "npm:@takumi-rs/wasm@1.8.7",
176
- "@unocss/config": "^66.10.2",
177
- "@unocss/core": "^66.10.2",
178
- "@unocss/nuxt": "^66.10.2",
179
- "@unocss/preset-icons": "^66.10.2",
180
- "@unocss/preset-wind": "^66.10.2",
181
- "@unocss/preset-wind4": "^66.10.2",
182
- "@vitejs/plugin-vue": "^6.0.8",
183
- "@vue/compiler-core": "^3.5.42",
184
- "@vueuse/nuxt": "^14.4.0",
176
+ "@unocss/config": "^66.10.5",
177
+ "@unocss/core": "^66.10.5",
178
+ "@unocss/nuxt": "^66.10.5",
179
+ "@unocss/preset-icons": "^66.10.5",
180
+ "@unocss/preset-wind": "^66.10.5",
181
+ "@unocss/preset-wind4": "^66.10.5",
182
+ "@vitejs/plugin-vue": "^6.0.9",
183
+ "@vue/compiler-core": "^3.5.43",
184
+ "@vueuse/nuxt": "^15.0.0",
185
185
  "birpc": "^4.2.0",
186
186
  "bumpp": "^12.3.0",
187
- "eslint": "^10.10.0",
188
- "eslint-plugin-harlanzw": "^0.21.1",
189
- "fontless": "^0.4.3",
187
+ "eslint": "^10.11.0",
188
+ "eslint-plugin-harlanzw": "^0.22.0",
189
+ "fontless": "^1.0.0",
190
190
  "fontless-v0-2": "npm:fontless@0.2.0",
191
191
  "globby": "^16.2.4",
192
192
  "happy-dom": "^20.14.5",
193
- "hookable": "^6.1.1",
193
+ "hookable": "^6.1.2",
194
194
  "jest-image-snapshot": "^6.5.2",
195
195
  "lightningcss": "^1.33.0",
196
196
  "nitropack": "^2.13.4",
@@ -199,24 +199,21 @@
199
199
  "playwright": "^1.63.0",
200
200
  "playwright-core": "^1.63.0",
201
201
  "sass": "^1.104.1",
202
- "satori": "^0.33.4",
203
- "satori-v0-19": "npm:satori@0.19.2",
204
- "satori-v0-32": "npm:satori@0.32.0",
202
+ "satori": "^0.33.5",
205
203
  "sharp": "^0.35.4",
206
- "sharp-v0-34": "npm:sharp@0.34.0",
207
204
  "sirv": "^3.0.2",
208
205
  "tailwindcss": "^4.3.3",
209
206
  "typescript": "6.0.3",
210
207
  "unbuild": "^3.6.1",
211
208
  "unifont": "^0.8.3",
212
209
  "unifont-v0-7": "npm:unifont@0.7.0",
213
- "unocss": "^66.10.2",
214
- "vitest": "^5.0.0",
215
- "vue": "^3.5.42",
210
+ "unocss": "^66.10.5",
211
+ "vitest": "^5.0.1",
212
+ "vue": "^3.5.43",
216
213
  "vue-tsc": "^3.3.11",
217
- "wrangler": "^4.131.1",
214
+ "wrangler": "^4.136.1",
218
215
  "yoga-wasm-web": "^0.3.3",
219
- "zod": "^4.6.4"
216
+ "zod": "^4.6.5"
220
217
  },
221
218
  "scripts": {
222
219
  "bench:edge-startup": "nuxt build bench/fixtures/edge-startup && node bench/edge-startup.mjs bench/fixtures/edge-startup/.netlify/edge-functions/server/server.js",
@@ -29,6 +29,14 @@ declare module '#og-image/renderers/browser' {
29
29
  export default renderer
30
30
  }
31
31
 
32
+ declare module '#og-image/bindings/fetch' {
33
+ export function guardedFetch(url: string, init: {
34
+ headers?: Record<string, string>
35
+ signal: AbortSignal
36
+ isBlockedAddress?: (address: string) => boolean
37
+ }): Promise<Response>
38
+ }
39
+
32
40
  declare module '#og-image/bindings/satori' {
33
41
  const satori: typeof import('satori').satori
34
42
  export default satori