diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index 4e36188d2..5210c8ec6 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -116,6 +116,22 @@ async function run(command, args, cwd, env = environment) { } } +const packedImageRoute = `import { ImageResponse } from 'void/og'; +export const GET = () => new ImageResponse('
Packed
'); +`; + +/** Fetches the void/og route and checks for a 1200x630 PNG. */ +async function assertPackedImage(url, failure) { + const response = await fetch(url); + assert.equal(response.status, 200, failure()); + assert.equal(response.headers.get('content-type'), 'image/png', failure()); + // A render error arrives after the status, as a failed body. + const bytes = new Uint8Array(await response.arrayBuffer()); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + assert.deepEqual([...bytes.subarray(1, 4)], [80, 78, 71], failure()); + assert.deepEqual([view.getUint32(16), view.getUint32(20)], [1200, 630], failure()); +} + const packages = []; for (const entry of readdirSync(join(root, 'packages'), { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -143,7 +159,8 @@ writeFileSync( environment.npm_config_registry = registry.origin; environment.NPM_CONFIG_REGISTRY = registry.origin; environment.PNPM_CONFIG_REGISTRY = registry.origin; -const version = packages.find((pkg) => pkg.manifest.name === 'void').manifest.version; +const voidManifest = packages.find((pkg) => pkg.manifest.name === 'void').manifest; +const version = voidManifest.version; const managers = process.env.VOID_PACKED_PM ? [process.env.VOID_PACKED_PM] : ['npm', 'pnpm']; assert(managers.every((manager) => ['npm', 'pnpm'].includes(manager))); const frameworks = process.env.VOID_PACKED_FRAMEWORK @@ -329,6 +346,8 @@ try { join(fixture, 'routes/api/packed-email.ts'), `import { defineHandler } from 'void';\nimport { sendEmail } from 'void/email';\nexport const GET = defineHandler(async () => { return sendEmail({ from: 'packed@example.com', to: 'inbox@example.com', subject: 'Packed', text: 'ok' }); });\n`, ); + // void/og imports its renderer's Wasm, and React only when the app has it. + writeFileSync(join(fixture, 'routes/api/packed-og.ts'), packedImageRoute); mkdirSync(join(fixture, 'public'), { recursive: true }); writeFileSync(join(fixture, 'public/packed.txt'), label); const vite = join( @@ -421,6 +440,7 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E } else { assert.equal(email.error?.code, 'BINDING_MISSING', emailBody); } + await assertPackedImage(`${base}/api/packed-og`, task.failure); if (full && framework === 'react') { const markdown = await fetch(`${base}/markdown`); assert.equal(markdown.status, 200, task.failure()); @@ -463,9 +483,11 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E if (framework === 'react') { // The Node target bundles the installed void into its build, so a ValidationError from // void/handler is the class that page actions check, as in dev. + // void/og renders with the app's own takumi-js on Node.js, as its guide says. + const takumi = `takumi-js@${voidManifest.dependencies['takumi-js']}`; await run( manager, - manager === 'npm' ? ['install', '@hono/node-server'] : ['add', '@hono/node-server'], + [manager === 'npm' ? 'install' : 'add', '@hono/node-server', takumi], fixture, ); writeFileSync( @@ -474,6 +496,8 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E ); // KV and email are Cloudflare bindings. rmSync(join(fixture, 'routes'), { recursive: true }); + mkdirSync(join(fixture, 'routes/api'), { recursive: true }); + writeFileSync(join(fixture, 'routes/api/packed-og.ts'), packedImageRoute); writeFileSync( join(fixture, 'pages/form.tsx'), 'export default function Form() {\n return

Form

;\n}\n', @@ -507,12 +531,13 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E const body = await response.text(); assert.equal(response.status, 200, `${body}\n${task.failure()}`); assert(body.includes('Email already taken'), body); + await assertPackedImage(`${base}/api/packed-og`, task.failure); } finally { await task.stop(); } } console.log( - `Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`, + `Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, image, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`, ); } catch (error) { failures.push({ fixture: label, error: String(error.stack ?? error) }); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 3139a0f97..09433c619 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -335,6 +335,7 @@ export default extendConfig( }, { text: 'Custom SSR', link: '/guide/ssr' }, { text: 'SSG', link: '/guide/ssg' }, + { text: 'Open Graph Images', link: '/guide/og-images' }, ], }, { diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md new file mode 100644 index 000000000..9ec11b410 --- /dev/null +++ b/docs/guide/og-images.md @@ -0,0 +1,196 @@ +--- +outline: deep +--- + +# Open Graph Images + +Use `ImageResponse` from `void/og` to generate Open Graph and social preview images in a route handler. It draws JSX or HTML to a PNG with [Takumi](https://takumi.kane.tw/docs/), without a headless browser. + +## Create an image route + +Return an `ImageResponse` from a [`.tsx` route](./server-routing.md#jsx-handlers): + +```tsx +// routes/og/[slug].tsx +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { getPost } from '../../src/posts'; + +export const GET = defineHandler(async (c) => { + const post = await getPost(c.req.param('slug')); + if (!post) { + return c.notFound(); + } + return new ImageResponse( +
+
{post.title}
+
{post.author}
+
, + ); +}); +``` + +`GET /og/hello-world` now returns a 1200 × 630 PNG. + +JSX works in React apps. In Vue, Svelte, and Solid apps, pass an HTML string instead, as shown in [Without React](#without-react). Elements also accept Tailwind classes in a `tw` prop, such as `
`. + +### Without React + +Pass an HTML string instead of JSX, and escape the values you insert: + +```ts +// routes/og/[slug].ts +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { getPost } from '../../src/posts'; + +const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); + +export const GET = defineHandler(async (c) => { + const post = await getPost(c.req.param('slug')); + if (!post) { + return c.notFound(); + } + return new ImageResponse( + `
+ ${escapeHtml(post.title)} +
`, + ); +}); +``` + +## Options + +The second argument sets the image and the response: + +| Option | Default | Description | +| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `width`, `height` | `1200`, `630` | Image size in pixels. | +| `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). | +| `fallbackFonts` | `true` | Set to `false` to download no fonts from Google Fonts. See [Control font downloads](#control-font-downloads). | +| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, or `fluentFlat`. | +| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. | +| `debug` | `false` | Draws a border around every box. | +| `images` | | Limits for remote images and emoji: `allowUrl`, `maxBytes`, `timeout`, and `throwOnError`. Set them when the markup includes request values. | +| `status`, `statusText`, `headers` | | The response status and headers. | + +Responses have these headers unless you set them in `headers`: + +```http +Content-Type: image/png +Cache-Control: public, max-age=0, must-revalidate +``` + +Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy. + +Emoji and remote images are downloaded while the image renders. An emoji that the chosen style does not have, or an image that fails to download, fails the image unless you pass `images: { throwOnError: false }`, which leaves it out; `noto` has no flags. + +## Fonts + +Text uses Geist unless you pass `fonts`. Your fonts draw every character they have, and Geist draws only the characters they lack. + +When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads a font that has them from Google Fonts while rendering: the matching [Noto](https://fonts.google.com/noto) font, such as Noto Sans JP for Japanese, or Geist's own Cyrillic and accented letters. It asks for only the characters the image uses, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. + +Chinese characters are drawn in the Japanese style unless the `lang` option or the root element's `lang` attribute names the language, or the text has Korean around them. A `lang` wins over the text, so a Chinese title with a katakana word keeps the Chinese style: + +```ts +return new ImageResponse(`
${title}
`); +``` + +One style applies to all Chinese characters in an image, so an image cannot mix Japanese and Chinese styles through `lang` on inner elements. + +To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data: + +```ts +return new ImageResponse(postCard(post), { + fonts: [ + 'https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hiA.woff2', + { name: 'Inter', data: interRegular, weight: 400, style: 'normal' }, + ], +}); +``` + +A `weight` from 1 to 1000 makes the font draw only that weight. Leave it out for a variable font, so each element's `fontWeight` sets the weight, including values between the usual steps such as `550`. + +Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too. A font entry's `lang` is accepted but does not choose the font for a language; use the `lang` option or attribute for Chinese characters instead. + +### Control font downloads + +Each server keeps the fonts it downloads in memory until it restarts. Every image with new text downloads a small font made for that text, such as a 26 KB file for a Japanese title, so the memory of a long-running Node.js, Bun, or Deno server grows with the number of different titles it renders. On Cloudflare Workers, the downloads also count toward the [subrequest limit](https://developers.cloudflare.com/workers/platform/limits/#subrequests) and add latency to the first render. + +To decide which fonts load and when, pass the fonts your images need in `fonts` and set `fallbackFonts: false`. Void then downloads no fonts, and characters that neither your fonts nor Geist can draw are drawn as boxes: + +```ts +return new ImageResponse(postCard(post), { + fonts: [{ name: 'Noto Sans JP', data: notoSansJp }], + fallbackFonts: false, +}); +``` + +A font passed as data loads once per server, however many images use it. + +## Add the image to a page + +Point an `og:image` tag at the route from your page's [`head()`](./pages-routing/head.md): + +```ts +// pages/posts/[slug].server.ts +import { defineHead } from 'void'; + +export interface Props { + post: { title: string; slug: string }; +} + +export const head = defineHead((c, props) => { + return { + title: props.post.title, + meta: [ + { property: 'og:image', content: new URL(`/og/${props.post.slug}`, c.req.url).href }, + { name: 'twitter:card', content: 'summary_large_image' }, + ], + }; +}); +``` + +## Cache generated images + +Each request renders the image again. A simple 1200 × 630 card takes around 10 ms of CPU time, one with gradients, bold text, or emoji 30 to 80 ms, and the first render in a new Worker instance more than 100 ms. [Workers Free allows 10 ms per request](../reference/config.md#worker) and fails requests from a Worker that keeps exceeding it, so on Workers Free, cache image routes with [revalidation](./edge/revalidation.md): + +```json +{ + "routing": { + "revalidate": { + "/og/*": 86400 + } + } +} +``` + +Void never caches paths under `/api`, so serve images from another path, such as `/og/...`. The cache ignores query strings, so put values in the path, or list the query parameters in [`routing.revalidateQueryAllowlist`](../reference/config.md#routing-revalidatequeryallowlist). + +## Worker size + +On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4.3 MB, or 1.9 MB compressed. A larger Worker takes longer to upload on each deploy and to start in a new instance. Apps that do not import `void/og` do not include the renderer. + +## Node.js, Bun, and Deno + +On the [Node.js, Bun, and Deno targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's `dependencies`, not `devDependencies`, so production installs include it: + +```sh +npm install takumi-js@~2.20.0 +``` + +If the build cannot find a version of `takumi-js` that Void supports, it fails and prints the command to install one. + +The server keeps every font it downloads for text that Geist cannot draw until it restarts, so its memory grows with each image that has new text. If your images show many different titles in other scripts, pass your own fonts with `fallbackFonts: false`; see [Control font downloads](#control-font-downloads). diff --git a/docs/guide/pages-routing/head.md b/docs/guide/pages-routing/head.md index d53f44ef2..455b433b3 100644 --- a/docs/guide/pages-routing/head.md +++ b/docs/guide/pages-routing/head.md @@ -38,7 +38,7 @@ export const head = defineHead((c, props) => { }); ``` -`head()` runs server-side only, after the loader resolves. +`head()` runs server-side only, after the loader resolves. To generate the image an `og:image` tag points to, see [Open Graph Images](../og-images.md). ## Site defaults diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 13952b812..818650eb6 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -103,6 +103,10 @@ Supply server values through your host's environment and read them with `process For browser code, use Vite's `import.meta.env.VITE_*` values. These are embedded at build time and must not contain secrets. +### Open Graph images + +[`void/og`](../guide/og-images.md) needs `takumi-js` in your app's `dependencies`. See [Node.js, Bun, and Deno](../guide/og-images.md#node-js-bun-and-deno). + ### No Cloudflare bindings The following imports are **not available** with a non-CF target and produce a compile-time error: diff --git a/docs/reference/api.md b/docs/reference/api.md index b5460f654..29e097bf6 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -49,6 +49,7 @@ Import Void's app-facing APIs from these paths: | `void/live` | `defineLiveStream`, SSE topic fanout runtime types. Server-only. | | `void/live/client` | `connectLiveStream`, browser helper for one SSE connection plus POST subscribe/unsubscribe control. | | `void/response` | `convertReturnValue` | +| `void/og` | `ImageResponse` and `ImageResponseOptions`. Renders JSX or HTML to a 1200 × 630 PNG `Response`. See [Open Graph Images](../guide/og-images.md). | | `void/validator` | `runValidation`, `ValidatorSlots`, `HandlerInput` | | `void/drizzle-zod` | Re-exports [`drizzle-zod`](https://orm.drizzle.team/docs/zod) for schema-derived Zod validators for Drizzle tables | | `void/drizzle-valibot` | Re-exports [`drizzle-valibot`](https://orm.drizzle.team/docs/valibot) for schema-derived Valibot validators for Drizzle tables | diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 3842f09dc..8225ded4a 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -157,6 +157,18 @@ Vite/CLI dependency graph. `void/remote` stays exported only for semver. Generated code imports `#self/remote`. Remove the subpath in the next major version. +`void/og` is a thin `ImageResponse` layer over `takumi-js`. Keep +`ImageResponseOptions` declared in `og.ts` and map each option to Takumi: +passing Takumi's options through would let a Takumi upgrade change the API. +On Node.js, Bun and Deno, Takumi loads its renderer from files next to its +code, which a bundle cannot carry, so `nodeTargetPlugin` fails a build unless +the app resolves a `takumi-js` in void's range. takumi-js is on `~2.20`; skip +2.19.0, which draws a word as boxes when the first font covering a character +lacks its glyph. Before raising the range, render Japanese next to "Łódź" in +a fresh process and check that Geist still draws `BUILT_IN` in `og-fonts.ts`, +then update the range, the install command and the Worker size in +`docs/guide/og-images.md`. + Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). ### Route Filename Parsing Rules diff --git a/packages/void/package.json b/packages/void/package.json index a4d147d78..8fbb26270 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -337,6 +337,11 @@ "import": "./dist/runtime/limits.mjs", "require": "./dist/runtime/limits.mjs" }, + "./og": { + "types": "./dist/runtime/og.d.mts", + "import": "./dist/runtime/og.mjs", + "require": "./dist/runtime/og.mjs" + }, "./deploy": { "types": "./dist/deploy.d.mts", "import": "./dist/deploy.mjs" @@ -386,6 +391,7 @@ "postal-mime": "catalog:", "proper-lockfile": "^4.1.2", "smol-toml": "catalog:", + "takumi-js": "~2.20.0", "terminal-link": "^5.0.0", "yaml": "^2.9.1" }, diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index edc6e4134..d346ee524 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -51,6 +51,7 @@ Use `void deploy --skip-migrations` when database migrations run separately; it | Platform AI logging policy | `docs/reference/cli/platform-users.md#ai-gateway-logging` | | Durable State and WebSockets | `docs/guide/durable-state.md`, `docs/guide/websockets.md` | | Server-sent events and live streams | `docs/guide/sse.md`, `docs/guide/live.md` | +| Open Graph and social images | `docs/guide/og-images.md`, `docs/guide/edge/revalidation.md` | | Sandboxes | `docs/guide/sandboxes.md` | | Email | `docs/guide/email.md`, then the matching guide under `docs/guide/email/` | | KV / storage / queues / cron jobs | `docs/guide/kv.md`, `docs/guide/storage.md`, `docs/guide/queues.md`, `docs/guide/jobs.md` | diff --git a/packages/void/src/installed-packages.ts b/packages/void/src/installed-packages.ts index bf9f41f96..656ff5c39 100644 --- a/packages/void/src/installed-packages.ts +++ b/packages/void/src/installed-packages.ts @@ -5,6 +5,8 @@ import { parseSync } from 'vite'; import { staticModuleReferences } from './shared/module-references.ts'; type PackageManifest = { + readonly name?: string; + readonly version?: string; readonly type?: string; readonly module?: unknown; readonly main?: unknown; @@ -15,7 +17,7 @@ type PackageManifest = { readonly peerDependencies?: Record; }; -function readManifest(dir: string): PackageManifest | undefined { +export function readManifest(dir: string): PackageManifest | undefined { try { return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as PackageManifest; } catch { @@ -26,7 +28,7 @@ function readManifest(dir: string): PackageManifest | undefined { type PnpApi = { resolveToUnqualified(request: string, issuer: string): string | null }; /** The directory Node loads `name` from for code in `fromDir`. Ignores `exports`. */ -function findInstalledPackage(name: string, fromDir: string): string | undefined { +export function findInstalledPackage(name: string, fromDir: string): string | undefined { // A Yarn Plug'n'Play install has no node_modules. Under `yarn`, its runtime adds // `findPnpApi` to `node:module` and serves the packages' files from its archives. const pnp = (module as { findPnpApi?: (path: string) => PnpApi | null }).findPnpApi?.(fromDir); diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index 70abc4075..734c51738 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -1,11 +1,15 @@ -import { existsSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import mime from 'mime'; -import { dirname, isAbsolute, join, relative, resolve } from 'pathslash'; +import { dirname, isAbsolute, join, relative, resolve, toSlash } from 'pathslash'; import { isFileServingAllowed, type Plugin } from 'vite'; -import { findVoidDependentPackages } from './installed-packages.ts'; +import { + findInstalledPackage, + findVoidDependentPackages, + readManifest, +} from './installed-packages.ts'; import { readProjectPaths } from './project-paths.ts'; /** Imports that require CF bindings and are not available in Node.js mode. */ @@ -322,5 +326,114 @@ export function nodeTargetPlugin( }, }; - return [guardPlugin, cloudflareModulesPlugin]; + // void/og renders with takumi-js, which loads its renderer from files next to its own code (a + // native addon on Node.js and Bun) that a bundle cannot carry. A build leaves a package that + // resolves from the app root to the runtime, so the app must install a version that void/og + // supports. With pnpm, void's own copy does not resolve from the root and would be bundled. + // The dev server resolves it from void, so there the check only warns. + let warned = false; + const imageRendererPlugin: Plugin = { + name: 'void:node-target-image-renderer', + enforce: 'pre', + resolveId: { + filter: { id: /^takumi-js(?:\/|$)/ }, + handler(_id, importer) { + // An app may also use takumi-js itself, at any version. + if (!importer || !realPath(importer).startsWith(`${getVoidPackageDir()}/`)) { + return; + } + const range = readTakumiRange(); + const installed = findInstalledPackage('takumi-js', this.environment.config.root); + const version = installed && readManifest(installed)?.version; + if (version && satisfiesRange(version, range)) { + return; + } + const found = version ? ` The app has takumi-js ${version}.` : ''; + const message = + `void/og needs takumi-js@${range} in your app's dependencies with target '${target}', so ` + + `the build leaves it to ${RUNTIME_NAMES[target]}.${found} Install it, for example with ` + + `\`npm install takumi-js@${range}\`.`; + if (this.environment.config.command === 'build') { + throw new Error(message); + } + if (!warned) { + warned = true; + this.warn(message); + } + }, + }, + }; + + return [guardPlugin, cloudflareModulesPlugin, imageRendererPlugin]; +} + +const RUNTIME_NAMES: Record = { + node: 'Node.js', + bun: 'Bun', + deno: 'Deno', +}; + +let voidPackageDir: string | undefined; + +function getVoidPackageDir(): string { + return (voidPackageDir ??= findVoidPackageDir(toSlash(fileURLToPath(import.meta.url)))); +} + +/** + * The `void` package root above `file`. Walks up rather than assuming a depth, so the + * lookup holds wherever the bundler places this module in `dist/` (Bun's `node:module` + * has no `findPackageJSON`). + */ +export function findVoidPackageDir(file: string): string { + for (let dir = dirname(file); ; dir = dirname(dir)) { + if (readManifest(dir)?.name === 'void') { + return dir; + } + if (dirname(dir) === dir) { + throw new Error(`void/og: could not find void's package.json above ${file}.`); + } + } +} + +/** Vite resolves symlinks in importer paths unless `resolve.preserveSymlinks` is set. */ +function realPath(path: string): string { + try { + return toSlash(realpathSync(path)); + } catch { + return path; + } +} + +/** The takumi-js range void/og is built and tested against, from void's package.json. */ +function readTakumiRange(): string { + const range = readManifest(getVoidPackageDir())?.dependencies?.['takumi-js']; + if (!range || !/^[~^]\d+\.\d+\.\d+$/.test(range)) { + throw new Error( + `void/og: expected a ^ or ~ takumi-js range in void's package.json, got ${range}.`, + ); + } + return range; +} + +/** Whether `version` is in the `^` or `~` `range` (a major version above 0, as takumi-js uses). */ +function satisfiesRange(version: string, range: string): boolean { + const parse = (value: string) => /^[~^]?(\d+)\.(\d+)\.(\d+)$/.exec(value)?.slice(1).map(Number); + // A prerelease is outside the range. + const actual = parse(version); + const minimum = parse(range); + // `~` also fixes the minor version. + const fixed = range.startsWith('~') ? 2 : 1; + if ( + !actual || + !minimum || + actual.slice(0, fixed).some((part, index) => part !== minimum[index]) + ) { + return false; + } + for (const part of [1, 2]) { + if (actual[part] !== minimum[part]) { + return actual[part]! > minimum[part]!; + } + } + return true; } diff --git a/packages/void/src/runtime/og-fonts.ts b/packages/void/src/runtime/og-fonts.ts new file mode 100644 index 000000000..5a164d06e --- /dev/null +++ b/packages/void/src/runtime/og-fonts.ts @@ -0,0 +1,240 @@ +/** + * Fonts for `void/og`: the app's own fonts, keyed so each registers with + * Takumi once, and Google Fonts fallbacks for text that Takumi's built-in Geist + * cannot draw. + */ +import type { FontLoader, Node } from 'takumi-js'; +import { collectCodepoints, googleFonts, subsetFonts, type FontSubset } from 'takumi-js/helpers'; + +/** A font file's URL, or a font's bytes with its name, weight and style. */ +export type Font = + | string + | { + name: string; + data: ArrayBuffer | Uint8Array; + /** + * The weight the font draws, from 1 to 1000. Leave it out for a variable + * font, so each element's `fontWeight` sets the weight; with it, the font + * draws only this weight. + */ + weight?: number; + style?: 'normal' | 'italic'; + /** + * Accepted and ignored: text uses the first font that has the glyph. The + * `lang` option, or attribute on the root element, chooses the font for + * Chinese characters instead. + */ + lang?: string; + }; + +/** A fallback font file that failed to download. */ +export class FallbackFontError extends Error {} + +// Google Fonts families for text the default font cannot draw: each script's +// Noto family. Takumi has no hook for characters that no font has, so these +// families are matched by script before the render. `cjkFamily` settles the +// first one; `Noto Sans` takes any other text. +const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [ + // Script extensions, so marks such as ・ and ー, which Unicode files under the + // Common script, stay with the family that draws the kana around them. + [/[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana} -〿＀-￯]/u, 'Noto Sans JP'], + [/\p{sc=Hangul}/u, 'Noto Sans KR'], + [/\p{sc=Thai}/u, 'Noto Sans Thai'], + [/\p{sc=Arabic}/u, 'Noto Sans Arabic'], + [/\p{sc=Hebrew}/u, 'Noto Sans Hebrew'], + [/\p{sc=Devanagari}/u, 'Noto Sans Devanagari'], + [/\p{sc=Bengali}/u, 'Noto Sans Bengali'], + [/\p{sc=Tamil}/u, 'Noto Sans Tamil'], + [/\p{sc=Telugu}/u, 'Noto Sans Telugu'], + [/\p{sc=Kannada}/u, 'Noto Sans Kannada'], + [/\p{sc=Malayalam}/u, 'Noto Sans Malayalam'], + // The default font's own subsets, so a word stays in one typeface. + [/[\p{sc=Cyrillic}µĀ-ɏḀ-ỿ]/u, 'Geist'], + [/\p{Sm}/u, 'Noto Sans Math'], + [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols'], + [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols 2'], +]; + +// The fallback families Google Fonts serves only at weight 400. Every other +// family above is a variable font with a 100–900 weight axis, and is asked +// for as that range: Google then serves `font-weight: 100 900`, Takumi leaves +// the face without a fixed weight, and `font-weight: 700` drives the axis. +// Asked for at a single 400, a variable face is pinned to 400 and bold text is +// only thickened, so 700 and 900 look the same. Asking for a range from these +// two fails the whole request with HTTP 400, which would lose the family. +// Found by asking css2 for each family with `wght@100..900`. +const SINGLE_WEIGHT_FAMILIES = new Set(['Noto Sans Math', 'Noto Sans Symbols 2']); + +// Google Fonts answers a `text` parameter with one file holding just those +// characters, with the whole weight axis. Without it, a family comes as files +// split by character range: a Japanese title needs about 20 of Noto Sans JP's +// 124, 480 KB in all, against one 26 KB file. Past a query of about 7,500 +// characters (some 800 kanji), Google returns every range file instead, and +// longer queries fail, so longer text asks for the range files. +const GOOGLE_FONTS_CSS = 'https://fonts.googleapis.com/css2'; +const MAX_TEXT_QUERY = 6000; + +function cssUrl(characters: Array): string | undefined { + const text = encodeURIComponent([...new Set(characters)].sort().join('')); + return text.length <= MAX_TEXT_QUERY ? `${GOOGLE_FONTS_CSS}?text=${text}` : undefined; +} + +// What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found +// by rendering each code point with takumi-js 2.18 (2.20 draws the same): some +// accented letters and combining marks, spaces, format characters and variation +// selectors, quotes, dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing: +// no served subset has them. +/* oxlint-disable no-misleading-character-class -- the marks and selectors are listed on their own */ +const BUILT_IN = + /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{304}\u{308}\u{34F}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FE00}-\u{FE0F}\u{FEFF}]/u; +/* oxlint-enable no-misleading-character-class */ + +function hasBytes(font: Font): font is Exclude { + return ( + typeof font === 'object' && + (font.data instanceof ArrayBuffer || font.data instanceof Uint8Array) + ); +} + +const fontHashes = new WeakMap>(); + +// Takumi keeps every registered font for the life of the process and tells +// them apart by buffer identity, so a font read anew for each request would be +// registered again every time. Key it by content. +export async function keyedFont(font: Font): Promise { + if (!hasBytes(font)) { + return font; + } + const { lang: _lang, data, ...details } = font; + let hash = fontHashes.get(data); + if (!hash) { + hash = crypto.subtle + .digest('SHA-256', data instanceof ArrayBuffer ? data : new Uint8Array(data)) + .then((digest) => + Array.from(new Uint8Array(digest, 0, 8), (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ), + ); + fontHashes.set(data, hash); + } + return { + ...details, + key: `void/og ${details.name ?? ''}:${details.weight ?? ''}:${details.style ?? ''}:${await hash}`, + data: () => data, + }; +} + +// Han text is drawn as Japanese unless a language says otherwise. Takumi takes +// the first font with a glyph and ignores `lang`, so one family draws all Han +// text in an image, chosen from the `lang` option or the root element's `lang`. +function cjkFamily(text: string, lang = ''): string { + // An explicit language wins over the scripts in the text: a Traditional + // Chinese title with a katakana loanword (タワー) is still Chinese. Google's + // Noto Sans SC, TC and KR all include kana, so no character is left without + // a glyph. + const tag = lang.toLowerCase(); + if (tag.startsWith('ja')) { + return 'Noto Sans JP'; + } + if (tag.startsWith('ko')) { + return 'Noto Sans KR'; + } + if (tag.startsWith('zh')) { + return /hant|-tw|-hk|-mo/.test(tag) ? 'Noto Sans TC' : 'Noto Sans SC'; + } + // Without one, kana marks Japanese and Hangul marks Korean. + if (/[\p{sc=Hiragana}\p{sc=Katakana}]/u.test(text)) { + return 'Noto Sans JP'; + } + if (/\p{sc=Hangul}/u.test(text)) { + return 'Noto Sans KR'; + } + return 'Noto Sans JP'; +} + +/** + * The Google Fonts subsets that draw the characters in `node` that the + * built-in Geist cannot. A file that fails to download adds its key to + * `failed` and rejects with a `FallbackFontError`. + */ +export async function fallbackFonts( + node: Node, + lang: string | undefined, + timeout: number | undefined, + failed: Set, +): Promise> { + const missing = Array.from(collectCodepoints(node)) + .filter((codepoint) => codepoint === 0xb5 || codepoint > 0xff) + .map((codepoint) => String.fromCodePoint(codepoint)) + .filter((character) => !BUILT_IN.test(character)); + // The characters each family is asked to draw. + const families = new Map>(); + for (const [script, name] of FALLBACK_FONTS) { + const characters = missing.filter((character) => script.test(character)); + if (characters.length > 0) { + // Han text can go to the same family as Hangul, so add to it. + const family = name === 'Noto Sans JP' ? cjkFamily(missing.join(''), lang) : name; + families.set(family, [...(families.get(family) ?? []), ...characters]); + } + } + const unmatched = missing.filter( + (character) => !FALLBACK_FONTS.some(([script]) => script.test(character)), + ); + if (unmatched.length > 0) { + families.set('Noto Sans', unmatched); + } + if (families.size === 0) { + return []; + } + // One request per family: takumi caches the CSS by URL. + const subsets = await Promise.all( + [...families].map(async ([family, characters]) => { + try { + return await googleFonts({ + families: [ + SINGLE_WEIGHT_FAMILIES.has(family) ? family : { name: family, weight: '100..900' }, + ], + baseUrl: cssUrl(characters), + timeout, + }); + } catch (error) { + console.warn(`void/og: could not load ${family} from Google Fonts.`, error); + return []; + } + }), + ); + // Latin text should keep the default font, but Takumi draws each character + // with the first font that has it and puts registered fonts before its + // built-in Geist, so take ASCII and Latin-1 but µ out of every subset. A + // subset without ranges is a whole font. + const stripped = subsets.flat().flatMap((subset) => { + const unicodeRange = ( + subset.unicodeRange.length > 0 ? subset.unicodeRange : [[0x100, 0x10ffff] as const] + ).flatMap(([start, end]): Array<[number, number]> => [ + ...(start <= 0xb5 && 0xb5 <= end ? [[0xb5, 0xb5] as [number, number]] : []), + ...(end > 0xff ? [[Math.max(start, 0x100), end] as [number, number]] : []), + ]); + if (unicodeRange.length === 0) { + return []; + } + return { + ...subset, + unicodeRange, + // Private names: Takumi keeps fonts per process, so the app's own copy + // of the family, Latin glyphs included, must not join this one. + name: `void/og ${subset.name}`, + key: `void/og ${subset.key}`, + data: () => + subset.data().catch((cause: unknown) => { + // Record and report each failed file here: when several fail at + // once, Takumi's rejection carries only the first. + failed.add(`void/og ${subset.key}`); + console.warn(`void/og: could not download ${subset.name}. Rendering without it.`, cause); + throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause }); + }), + }; + }); + // Takumi downloads the subsets of its list-marker sample as well, so keep + // only those the text uses. + return subsetFonts({ fonts: stripped, source: node }); +} diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts new file mode 100644 index 000000000..008d33762 --- /dev/null +++ b/packages/void/src/runtime/og.ts @@ -0,0 +1,192 @@ +/** + * `void/og` — an `ImageResponse` that renders JSX or HTML to an image with + * Takumi. + * + * Takumi does the rendering (Wasm on workerd, its native addon on Node). This + * module adds what a route handler needs around it: a 1200×630 default size, + * the response headers, `debug`, and Google Fonts fallbacks for text that + * Takumi's built-in Geist cannot draw. + */ +import { render, type FontLoader, type Node, type RenderInput } from 'takumi-js'; +import type { ImageFetchCache } from 'takumi-js/helpers'; +import { extractEmojis } from 'takumi-js/helpers/emoji'; +import { fromHtml } from 'takumi-js/helpers/html'; +import { fromJsx } from 'takumi-js/helpers/jsx'; +import { FallbackFontError, fallbackFonts, keyedFont, type Font } from './og-fonts.ts'; + +/** + * {@link renderImage} function options. + */ +export interface RenderImageOptions { + /** Image width in pixels. Defaults to 1200. */ + width?: number; + /** Image height in pixels. Defaults to 630. */ + height?: number; + /** Draws a border around every box. */ + debug?: boolean; + /** Fonts to draw text with, before the built-in Geist. */ + fonts?: Array; + /** + * Set to false to download no fonts from Google Fonts for characters that + * the built-in Geist cannot draw, leaving them to `fonts`. Defaults to true. + */ + fallbackFonts?: boolean; + /** The emoji images to draw. Defaults to `twemoji`. */ + emoji?: 'twemoji' | 'blobmoji' | 'noto' | 'openmoji' | 'fluent' | 'fluentFlat'; + /** + * The language of the text, such as `ja` or `zh-TW`, which chooses the style + * of Chinese characters. Defaults to the root element's `lang` attribute. + */ + lang?: string; + /** Limits for downloading the images and emoji in the markup. */ + images?: { + /** Returns false for a URL the image must not download. */ + allowUrl?: (url: string) => boolean; + /** The largest image to download, in bytes. */ + maxBytes?: number; + /** Milliseconds to wait for each download. */ + timeout?: number; + /** Set to false to leave out an image or emoji that fails to download. */ + throwOnError?: boolean; + }; +} +/** + * Void declares these options rather than passing Takumi's render options + * through, so a Takumi upgrade does not change this API. + */ +export interface ImageResponseOptions extends ResponseInit, RenderImageOptions {} + +// Emoji come from jsDelivr by code point and never change, so keep them for the +// life of the isolate; other images are fetched for each render. +const EMOJI_ORIGIN = 'https://cdn.jsdelivr.net/'; +const emojiCache = new Map>(); + +/** + * A `Response` whose body is the image rendered from `element`: React JSX, an + * HTML string, or a Takumi node tree. It renders a 1200×630 PNG by default, + * which clients revalidate on each use. + */ +export class ImageResponse extends Response { + constructor(element: RenderInput, options: ImageResponseOptions = {}) { + const { status, statusText, headers, ...imageOptions } = options; + // Set these two headers unless the caller's `headers` has them. A failed + // render still ends as a complete response, so clients revalidate the + // image rather than cache it. With no validator, revalidating fetches it + // again, so the dev server needs no header of its own. + const responseHeaders = new Headers(headers); + if (!responseHeaders.has('content-type')) { + responseHeaders.set('content-type', 'image/png'); + } + if (!responseHeaders.has('cache-control')) { + responseHeaders.set('cache-control', 'public, max-age=0, must-revalidate'); + } + // Render while the body streams, so a failure errors the body. + const body = new ReadableStream({ + async start(controller) { + try { + const image = await renderImage(element, imageOptions); + controller.enqueue(image); + controller.close(); + } catch (error) { + console.error('void/og: failed to render the image.', error); + controller.error(error); + } + }, + }); + super(body, { status, statusText, headers: responseHeaders }); + } +} + +async function renderImage(element: RenderInput, options: RenderImageOptions): Promise { + // Convert once, here: the fallback fonts depend on the final text, and + // function components must not run twice. Emoji become images first, as in + // the render, so their code points ask for no symbol font. + const { node: tree, css } = await toNode(element); + const emoji = options.emoji ?? 'twemoji'; + const node = extractEmojis(tree, emoji); + // Only the documented limits reach Takumi, which takes more image options. + const { allowUrl, maxBytes, timeout, throwOnError } = options.images ?? {}; + // One cache for both attempts below, so a retry fetches no image again. + const fetchCache = imageCache(); + const renderNode = (fonts: Array) => + render(node, { + width: options.width ?? 1200, + height: options.height ?? 630, + drawDebugBorder: options.debug, + lang: options.lang, + emoji, + css, + images: { fetchCache, allowUrl, maxBytes, timeout, throwOnError }, + // Takumi uses every font registered in the process when it gets no list, + // which includes earlier images' fallback fonts: + // https://github.com/kane50613/takumi/blob/takumi@2.20.0/takumi-helpers/src/renderer.ts#L384-L412 + fonts, + }); + // Keys of the fallback files that failed to download during this image. + const failed = new Set(); + const [fonts, fallback] = await Promise.all([ + Promise.all((options.fonts ?? []).map(keyedFont)), + options.fallbackFonts === false + ? [] + : fallbackFonts(node, options.lang ?? node.lang, timeout, failed), + ]); + if (fallback.length === 0) { + return renderNode(fonts); + } + // A font that fails to download leaves its glyphs missing rather than + // failing the image. + // Takumi downloads the files together and rejects the render on the first + // failure, so retry without the files that failed, and only those: a flaky + // symbol font must not also drop the Japanese one. Files that did download + // stay registered in Takumi, so a retry fetches none of them again. + let remaining = fallback; + for (;;) { + try { + return await renderNode([...fonts, ...remaining]); + } catch (error) { + if (!(error instanceof FallbackFontError)) { + throw error; + } + const rest = remaining.filter((subset) => !failed.has(subset.key)); + // Each retry must drop a file, so the loop ends. A failure this image + // did not record would retry forever; drop every fallback font instead. + remaining = rest.length < remaining.length ? rest : []; + } + } +} + +function toNode(element: RenderInput) { + if (typeof element === 'string') { + return fromHtml(element); + } + if (isTakumiNode(element)) { + return { node: element, css: [] }; + } + return fromJsx(element); +} + +function isTakumiNode(element: unknown): element is Node { + const type = (element as { type?: unknown } | null)?.type; + return type === 'container' || type === 'text' || type === 'image'; +} + +/** Images fetched once per render, and emoji once per isolate. */ +function imageCache(): ImageFetchCache { + const cache = new Map>(); + return { + get: (url) => cache.get(url) ?? emojiCache.get(url), + set(url, data) { + cache.set(url, data); + if (url.startsWith(EMOJI_ORIGIN)) { + if (emojiCache.size >= 256) { + emojiCache.delete(emojiCache.keys().next().value!); + } + emojiCache.set(url, data); + } + }, + delete(url) { + cache.delete(url); + emojiCache.delete(url); + }, + }; +} diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts new file mode 100644 index 000000000..c21ced4b9 --- /dev/null +++ b/packages/void/test/integration/og-image.test.ts @@ -0,0 +1,251 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import spawn from 'cross-spawn'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { killChild, startServer, waitForServer } from '../../../../test/integration-helpers.ts'; +import { collectPrerenderWorkerModules } from '../../src/prerender.ts'; + +const repository = join(import.meta.dirname, '..', '..', '..', '..'); +const CACHE_HEADER = 'public, max-age=0, must-revalidate'; +// Vitest sets NODE_ENV=test, which would make Vite build in development mode. +const { NODE_ENV: _nodeEnv, ...userEnv } = process.env; + +const OG_ROUTE = `import { ImageResponse } from 'void/og'; +export const GET = () => new ImageResponse('
Hello
'); +`; +// A .tsx route, as the guide shows. +const CARD_ROUTE = `import { ImageResponse } from 'void/og'; +function Card({ title }: { title: string }) { + return
{title}
; +} +export const GET = () => new ImageResponse(); +`; + +// Miniflare types its own Response, without Cloudflare's `cf`. +type Runtime = { + dispatchFetch(input: string): Promise; + dispose(): Promise; +}; + +const fixtures: Array = []; + +/** An app inside the workspace, so Vite serves the linked packages, outside its package globs. */ +function createApp( + files: Record, + links: Record, + config = `{ cloudflare: { name: 'og-fixture', compatibility_date: '2026-07-01' } }`, +): string { + const parent = join(repository, 'playground-local', '.test-fixtures'); + mkdirSync(parent, { recursive: true }); + const root = mkdtempSync(join(parent, 'test-og-')); + fixtures.push(root); + const packages = { 'vite-plus': 'node_modules/vite-plus', void: 'packages/void', ...links }; + for (const [name, source] of Object.entries(packages)) { + mkdirSync(dirname(join(root, 'node_modules', name)), { recursive: true }); + symlinkSync( + realpathSync(join(repository, source)), + join(root, 'node_modules', name), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } + const all: Record = { + 'package.json': JSON.stringify({ name: `og-${root.slice(-6)}`, private: true, type: 'module' }), + 'vite.config.ts': `import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; +export default defineConfig({ plugins: [voidPlugin()] }); +`, + 'void.config.ts': `export default ${config};\n`, + ...files, + }; + for (const [path, source] of Object.entries(all)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), source); + } + return root; +} + +function runBuild(root: string) { + return spawn.sync('vp', ['build'], { + cwd: root, + encoding: 'utf8', + timeout: 60_000, + maxBuffer: 10 * 1024 * 1024, + env: { ...userEnv, VOID_INSPECTOR_PORT: 'false' }, + }); +} + +function build(root: string) { + const result = runBuild(root); + expect(result.status, `${result.stdout ?? ''}${result.stderr ?? ''}`).toBe(0); +} + +async function renderBuilt(root: string, path: string, cacheControl: string) { + const output = join(root, 'dist/ssr'); + const config = JSON.parse(readFileSync(join(output, 'wrangler.json'), 'utf8')); + const modules = collectPrerenderWorkerModules(output, config.main); + expect(modules.filter((module) => module.type === 'CompiledWasm')).toHaveLength(1); + const runtime = new Miniflare( + convertV4MiniflareOptions({ + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + modules, + modulesRoot: output, + }), + ) as unknown as Runtime; + try { + await expectImage(await runtime.dispatchFetch(`http://localhost${path}`), cacheControl); + } finally { + await runtime.dispose(); + } +} + +async function expectImage(response: Response, cacheControl: string) { + expect(response.status, response.ok ? undefined : await response.text()).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(response.headers.get('cache-control')).toBe(cacheControl); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect([...bytes.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + expect({ width: view.getUint32(16), height: view.getUint32(20) }).toEqual({ + width: 1200, + height: 630, + }); +} + +function builtFiles(root: string): Array { + return readdirSync(join(root, 'dist/ssr'), { recursive: true }).map(String); +} + +afterAll(() => { + for (const root of fixtures) { + rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + } +}); + +describe('void/og in a Cloudflare app', () => { + let root: string; + beforeAll(() => { + root = createApp( + { + 'routes/api/og.ts': OG_ROUTE, + 'routes/api/card.tsx': CARD_ROUTE, + }, + { react: 'playground/react-pages/node_modules/react' }, + ); + }); + + describe('dev server', () => { + let child: ReturnType | undefined; + afterAll(async () => { + if (child) { + await killChild(child); + } + }); + + it('renders HTML and JSX in workerd', async () => { + const server = await startServer((port) => { + child = spawn( + 'vp', + ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...userEnv, VOID_INSPECTOR_PORT: 'false' }, + }, + ); + return child; + }, 30_000); + // The first import of takumi-js reloads the optimized dependencies once. + await waitForServer(`${server.base}/api/og`, 30_000, { + expectStatus: 200, + diagnostics: server.output, + }); + await expectImage(await fetch(`${server.base}/api/og`), CACHE_HEADER); + await expectImage(await fetch(`${server.base}/api/card`), CACHE_HEADER); + }); + }); + + describe('build', () => { + it('renders HTML and JSX in the built Worker', async () => { + build(root); + await renderBuilt(root, '/api/og', CACHE_HEADER); + await renderBuilt(root, '/api/card', CACHE_HEADER); + }); + }); +}); + +describe('void/og in a Cloudflare app without React', () => { + it('renders HTML in the built Worker', async () => { + // Takumi's JSX helper imports React as an optional peer; the HTML route needs none. + const root = createApp({ 'routes/api/og.ts': OG_ROUTE }, {}); + build(root); + await renderBuilt(root, '/api/og', CACHE_HEADER); + }); +}); + +describe('a Cloudflare app without void/og', () => { + it('bundles no image renderer', () => { + const root = createApp({ 'routes/api/hello.ts': "export const GET = () => 'hello';\n" }, {}); + build(root); + const files = builtFiles(root); + expect(files.filter((file) => file.endsWith('.wasm'))).toEqual([]); + const code = files + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(join(root, 'dist/ssr', file), 'utf8')) + .join('\n'); + expect(code).not.toContain('takumi'); + }); +}); + +describe('void/og on the Node.js target', () => { + let child: ReturnType | undefined; + afterAll(async () => { + if (child) { + await killChild(child); + } + }); + + it("renders with the app's takumi-js", async () => { + const root = createApp( + { 'routes/api/og.ts': OG_ROUTE }, + { + 'takumi-js': 'packages/void/node_modules/takumi-js', + '@hono/node-server': 'playground/node-app/node_modules/@hono/node-server', + }, + `{ target: 'node' }`, + ); + build(root); + expect(builtFiles(root).filter((file) => file.endsWith('.node'))).toEqual([]); + const server = await startServer((port) => { + child = spawn('node', ['dist/ssr/index.js'], { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...userEnv, PORT: String(port) }, + }); + return child; + }, 20_000); + await expectImage(await fetch(`${server.base}/api/og`), CACHE_HEADER); + }); + + it('fails the build without takumi-js', () => { + const root = createApp( + { 'routes/api/og.ts': OG_ROUTE }, + { '@hono/node-server': 'playground/node-app/node_modules/@hono/node-server' }, + `{ target: 'node' }`, + ); + const result = runBuild(root); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('void/og needs takumi-js@'); + expect(result.stderr).toContain('npm install takumi-js@'); + }); +}); diff --git a/packages/void/test/unit/og.test.ts b/packages/void/test/unit/og.test.ts new file mode 100644 index 000000000..d041b1d63 --- /dev/null +++ b/packages/void/test/unit/og.test.ts @@ -0,0 +1,633 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { toSlash } from 'pathslash'; +import { render } from 'takumi-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import voidManifest from '../../package.json' with { type: 'json' }; +import { findVoidPackageDir, nodeTargetPlugin } from '../../src/plugin-node-target.ts'; +import { ImageResponse } from '../../src/runtime/og.ts'; + +vi.mock('takumi-js', async (importOriginal) => { + const takumi = await importOriginal(); + return { ...takumi, render: vi.fn(takumi.render) }; +}); + +const card = '
Hello
'; + +async function pngSize(response: Response) { + const bytes = new Uint8Array(await response.arrayBuffer()); + expect([...bytes.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; +} + +// A css2 answer shaped like Google Fonts': a numbered subset can mix ASCII with +// common CJK characters, and the Latin subset covers Latin-1. +const NOTO_SANS_JP_CSS = `/* [0] */ +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/noto-sans-jp/kana.woff2) format('woff2'); + unicode-range: U+3040-309F, U+4E16, U+754C; +} +/* [5] */ +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/noto-sans-jp/unused.woff2) format('woff2'); + unicode-range: U+4E00-4E0F; +} +/* [118] */ +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/noto-sans-jp/common.woff2) format('woff2'); + unicode-range: U+20-22, U+27-2A, U+2C-38, U+41-47, U+61-7B, U+3001, U+65E5; +} +/* latin */ +@font-face { + font-family: 'Noto Sans JP'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/noto-sans-jp/latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+2000-206F; +} +`; + +function stubFetch(handler: (url: string) => Response | Promise) { + const requests: Array = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + requests.push(url); + return handler(url); + }), + ); + return requests; +} + +// Only the cases below that stub fetch may reach the network. +beforeEach(() => { + stubFetch(() => { + throw new Error('unexpected fetch'); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('ImageResponse', () => { + it('renders a 1200x630 PNG by default', async () => { + const response = new ImageResponse(card); + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + }); + + it('renders React-style elements and runs each function component once', async () => { + const Title = vi.fn(({ text }: { text: string }) => ({ + type: 'span', + props: { children: text }, + })); + const response = new ImageResponse( + { + type: 'div', + props: { style: { display: 'flex' }, children: { type: Title, props: { text: 'Hello' } } }, + }, + { width: 600, height: 315 }, + ); + expect(await pngSize(response)).toEqual({ width: 600, height: 315 }); + expect(Title).toHaveBeenCalledOnce(); + }); + + it('tells clients to revalidate the image on each use', () => { + expect(new ImageResponse(card).headers.get('cache-control')).toBe( + 'public, max-age=0, must-revalidate', + ); + }); + + it('keeps the status and headers it is given', () => { + const response = new ImageResponse(card, { + status: 201, + statusText: 'Created', + headers: { 'cache-control': 'public, max-age=60', 'x-image': 'card' }, + }); + expect(response.status).toBe(201); + expect(response.statusText).toBe('Created'); + expect(response.headers.get('cache-control')).toBe('public, max-age=60'); + expect(response.headers.get('x-image')).toBe('card'); + expect(response.headers.get('content-type')).toBe('image/png'); + }); + + it('passes Takumi none of its options that ImageResponseOptions leaves out', async () => { + const response = new ImageResponse(card, { + emoji: 'noto', + lang: 'ja', + // Takumi takes these, but void/og does not. + format: 'jpeg', + devicePixelRatio: 2, + drawDebugBorder: true, + images: { maxBytes: 1024, throwOnError: false, fetch: () => {} }, + } as never); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + const options = vi.mocked(render).mock.lastCall![1]!; + expect(Object.keys(options).sort()).toEqual([ + 'css', + 'drawDebugBorder', + 'emoji', + 'fonts', + 'height', + 'images', + 'lang', + 'width', + ]); + expect(options).toMatchObject({ drawDebugBorder: undefined, emoji: 'noto', lang: 'ja' }); + expect(Object.keys(options.images!).sort()).toEqual([ + 'allowUrl', + 'fetchCache', + 'maxBytes', + 'throwOnError', + 'timeout', + ]); + }); + + it('keeps the default size for an undefined width or height', async () => { + const response = new ImageResponse(card, { width: undefined, height: undefined }); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + }); + + it('draws box borders with debug', async () => { + const plain = new Uint8Array(await new ImageResponse(card).arrayBuffer()); + const debug = new Uint8Array(await new ImageResponse(card, { debug: true }).arrayBuffer()); + expect(debug).not.toEqual(plain); + }); + + it('applies a style element in the markup', async () => { + const response = new ImageResponse( + '
Hi
', + ); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + expect(vi.mocked(render).mock.lastCall![1]!.css).toEqual(['.card { background: #000 }']); + }); + + it('keys a font by its bytes, so a font read for each request registers once', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const fontKey = async (data: Uint8Array) => { + await new ImageResponse(card, { fonts: [{ name: 'Broken', data }] }) + .arrayBuffer() + .catch(() => {}); + const fonts = vi.mocked(render).mock.lastCall?.[1]?.fonts as Array<{ key: string }>; + return fonts[0]!.key; + }; + const key = await fontKey(new Uint8Array([1, 2, 3])); + expect(key).toMatch(/^void\/og Broken:::[0-9a-f]{16}$/); + expect(await fontKey(new Uint8Array([1, 2, 3]))).toBe(key); + expect(await fontKey(new Uint8Array([1, 2, 4]))).not.toBe(key); + }); + + it('passes any font weight to Takumi, and none when it is left out', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const data = new Uint8Array([1, 2, 3]); + await new ImageResponse(card, { + fonts: [ + { name: 'Medium', data, weight: 550 }, + { name: 'Variable', data }, + ], + }) + .arrayBuffer() + .catch(() => {}); + const fonts = vi.mocked(render).mock.lastCall?.[1]?.fonts as Array<{ + name: string; + weight?: number; + }>; + expect(fonts.map(({ name, weight }) => [name, weight])).toEqual([ + ['Medium', 550], + ['Variable', undefined], + ]); + }); + + it('errors the body when the image cannot render', async () => { + const data = new Uint8Array([1, 2, 3]); + // A font entry may carry `lang`, which takumi does not take. + const response = new ImageResponse(card, { + fonts: [{ name: 'Broken', data, weight: 400, lang: 'ja-JP' }], + }); + await expect(response.arrayBuffer()).rejects.toThrow(); + const fonts = vi.mocked(render).mock.lastCall?.[1]?.fonts as Array; + expect(fonts[0]).not.toHaveProperty('lang'); + }); +}); + +describe('ImageResponse fallback fonts', () => { + it('requests no font for text the built-in font draws', async () => { + const requests = stubFetch(() => new Response(null, { status: 404 })); + await new ImageResponse(card).arrayBuffer(); + await new ImageResponse( + '
C++ © 2026 “quotes” — … • ™ €
', + ).arrayBuffer(); + expect(requests).toEqual([]); + }); + + it('renders each image with only its own fonts', async () => { + vi.mocked(render).mockClear(); + await new ImageResponse(card).arrayBuffer(); + // Without a font list, takumi uses every font registered in the process, + // such as the fallback fonts of an earlier image. + expect(vi.mocked(render).mock.calls.map(([, options]) => options?.fonts)).toEqual([[]]); + }); + + it('keeps Latin text off the Japanese family', async () => { + const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch((url) => + url.startsWith('https://fonts.googleapis.com/') + ? new Response(NOTO_SANS_JP_CSS, { headers: { 'content-type': 'text/css' } }) + : new Response(null, { status: 404 }), + ); + const response = new ImageResponse('
Hello こんにちは日本
'); + // The font files are missing, so the image renders without them. + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + const css = new URL(requests[0]!); + expect(css.origin + css.pathname).toBe('https://fonts.googleapis.com/css2'); + expect(css.searchParams.getAll('family')).toEqual(['Noto Sans JP:wght@100..900']); + // The subset that mixes ASCII with 日 still serves 日; the Latin subset and + // the subset the text does not use are never fetched. + expect(requests.slice(1).sort()).toEqual([ + 'https://fonts.gstatic.com/noto-sans-jp/common.woff2', + 'https://fonts.gstatic.com/noto-sans-jp/kana.woff2', + ]); + expect(warned).toHaveBeenCalledWith( + 'void/og: could not download Noto Sans JP. Rendering without it.', + expect.anything(), + ); + }); + + it("uses Geist's own subsets for letters the built-in Geist lacks", async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + await new ImageResponse('
Łódź 5µs
').arrayBuffer(); + expect(new URL(requests[0]!).searchParams.getAll('family')).toEqual(['Geist:wght@100..900']); + }); + + it('downloads no fallback font when fallbackFonts is false', async () => { + const requests = stubFetch(() => new Response(null, { status: 404 })); + vi.mocked(render).mockClear(); + const data = new Uint8Array([1, 2, 3]); + await new ImageResponse('
日本語 ★ Łódź
', { + fonts: [{ name: 'App', data }], + fallbackFonts: false, + }) + .arrayBuffer() + .catch(() => {}); + expect(requests).toEqual([]); + const fonts = vi.mocked(render).mock.lastCall?.[1]?.fonts as Array<{ name: string }>; + expect(fonts.map(({ name }) => name)).toEqual(['App']); + }); + + it('asks each family for only the characters it draws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + await new ImageResponse('
本日 ★ 日本語 ★
').arrayBuffer(); + const texts = Object.fromEntries( + requests.map((url) => { + const { searchParams } = new URL(url); + return [searchParams.get('family'), searchParams.get('text')]; + }), + ); + expect(texts).toEqual({ + 'Noto Sans JP:wght@100..900': '日本語', + 'Noto Sans Symbols:wght@100..900': '★', + 'Noto Sans Symbols 2:wght@400': '★', + }); + }); + + it('asks the Korean family for both Han and Hangul in Korean text', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + await new ImageResponse('
中文・한국어
').arrayBuffer(); + expect( + requests.map((url) => { + const { searchParams } = new URL(url); + return [searchParams.get('family'), searchParams.get('text')]; + }), + ).toEqual([['Noto Sans KR:wght@100..900', [...'中文・한국어'].sort().join('')]]); + }); + + it('asks for the whole family when the text is too long for one request', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + const kanji = Array.from({ length: 700 }, (_, i) => String.fromCodePoint(0x4e00 + i)).join(''); + await new ImageResponse(`
${kanji}
`).arrayBuffer(); + const css = new URL(requests[0]!); + expect(css.searchParams.getAll('family')).toEqual(['Noto Sans JP:wght@100..900']); + expect(css.searchParams.has('text')).toBe(false); + }); + + it('draws the marks ・ and ー with the Japanese family', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + await new ImageResponse('
東京・ラーメン
').arrayBuffer(); + expect(requests.flatMap((url) => new URL(url).searchParams.getAll('family'))).toEqual([ + 'Noto Sans JP:wght@100..900', + ]); + }); + + it('leaves variable fallback faces free to draw bold', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Google answers a weight range with `font-weight: 100 900`, and a single + // weight with that weight. + stubFetch((url) => { + if (!url.startsWith('https://fonts.googleapis.com/')) { + return new Response(null, { status: 404 }); + } + const [family, weight] = new URL(url).searchParams.get('family')!.split(':wght@'); + const range = family === 'Noto Sans JP' ? 'U+65E5' : 'U+2192'; + return new Response( + `@font-face { font-family: '${family}'; font-weight: ${weight!.replace('..', ' ')}; src: url(https://fonts.gstatic.com/${weight}.woff2) format('woff2'); unicode-range: ${range}; }`, + { headers: { 'content-type': 'text/css' } }, + ); + }); + vi.mocked(render).mockClear(); + await new ImageResponse('
日 → B
').arrayBuffer(); + const [, options] = vi.mocked(render).mock.calls[0]!; + const weights = Object.fromEntries( + ((options?.fonts ?? []) as Array<{ name: string; weight?: number }>).map((font) => [ + font.name, + font.weight, + ]), + ); + // A face without a fixed weight follows `font-weight` along its axis; one + // pinned to 400 is only thickened for bold text. + expect(weights).toEqual({ 'void/og Noto Sans JP': undefined, 'void/og Noto Sans Math': 400 }); + }); + + it('takes a whole font for a subset without ranges, beyond Latin-1', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch((url) => + url.startsWith('https://fonts.googleapis.com/') + ? new Response( + `@font-face { font-family: 'Noto Sans Math'; src: url(https://fonts.gstatic.com/math.woff2) format('woff2'); }`, + { headers: { 'content-type': 'text/css' } }, + ) + : new Response(null, { status: 404 }), + ); + await new ImageResponse('
A → B
').arrayBuffer(); + expect(requests.slice(1)).toEqual(['https://fonts.gstatic.com/math.woff2']); + const [, options] = vi.mocked(render).mock.calls.at(-2)!; + expect(options?.fonts).toMatchObject([ + { name: 'void/og Noto Sans Math', unicodeRange: [[0x100, 0x10ffff]] }, + ]); + }); + + it('draws Han text as Japanese unless lang says otherwise', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch(() => new Response(null, { status: 404 })); + const family = async (html: string, lang?: string) => { + requests.length = 0; + await new ImageResponse(html, { lang }).arrayBuffer(); + return new URL(requests[0]!).searchParams.getAll('family'); + }; + expect(await family('
東京都
')).toEqual([ + 'Noto Sans JP:wght@100..900', + ]); + expect(await family('
東京都
', 'zh-TW')).toEqual([ + 'Noto Sans TC:wght@100..900', + ]); + expect(await family('
东京都
')).toEqual([ + 'Noto Sans SC:wght@100..900', + ]); + expect(await family('
漢字 한국어
')).toEqual([ + 'Noto Sans KR:wght@100..900', + ]); + // Kana alone marks Japanese only when no `lang` names the language. + expect(await family('
東京タワー
')).toEqual([ + 'Noto Sans JP:wght@100..900', + ]); + expect(await family('
東京タワー
')).toEqual([ + 'Noto Sans TC:wght@100..900', + ]); + expect(await family('
咖啡コーヒー
', 'zh-CN')).toEqual([ + 'Noto Sans SC:wght@100..900', + ]); + }); + + it('draws emoji as images, fetched once per isolate, without a font', async () => { + const requests = stubFetch(() => new Response('')); + const emoji = '
🪩
'; + expect(await pngSize(new ImageResponse(emoji))).toEqual({ width: 1200, height: 630 }); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatch(/^https:\/\/cdn\.jsdelivr\.net\/.*\.svg$/); + await new ImageResponse(emoji).arrayBuffer(); + expect(requests).toHaveLength(1); + }); + + it('fails on an emoji that cannot download, unless told to leave it out', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + stubFetch(() => new Response(null, { status: 404 })); + const emoji = '
🪅
'; + await expect(new ImageResponse(emoji).arrayBuffer()).rejects.toThrow(); + const response = new ImageResponse(emoji, { images: { throwOnError: false } }); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + }); + + it('uses the symbol and math families for characters Geist lacks', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const requests = stubFetch((url) => + url.endsWith('.svg') + ? new Response('', { + headers: { 'content-type': 'image/svg+xml' }, + }) + : new Response(null, { status: 404 }), + ); + await new ImageResponse('
A → B ★ ₹ 🎉
').arrayBuffer(); + // The emoji is an image, so it asks for no font. ₹ has no row and goes to Noto Sans. + const families = requests + .filter((url) => url.startsWith('https://fonts.googleapis.com/')) + .flatMap((url) => new URL(url).searchParams.getAll('family')) + .sort(); + expect(families).toEqual([ + 'Noto Sans Math:wght@400', + 'Noto Sans Symbols 2:wght@400', + 'Noto Sans Symbols:wght@100..900', + 'Noto Sans:wght@100..900', + ]); + }); + + it('leaves out only the fallback files that fail to download', async () => { + const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // One subset per family. The Japanese file downloads; both symbol files are missing. + const requests = stubFetch((url) => { + if (url.startsWith('https://fonts.googleapis.com/')) { + const family = new URL(url).searchParams.get('family')!.split(':')[0]!; + const range = family === 'Noto Sans JP' ? 'U+65E5, U+672C' : 'U+2605'; + const file = `https://fonts.gstatic.com/${family.replaceAll(' ', '-')}.woff2`; + return new Response( + `@font-face { font-family: '${family}'; src: url(${file}) format('woff2'); unicode-range: ${range}; }`, + { headers: { 'content-type': 'text/css' } }, + ); + } + return url.endsWith('Noto-Sans-JP.woff2') + ? new Response(new Uint8Array([0, 1, 0, 0])) + : new Response(null, { status: 404 }); + }); + // Stand in for Takumi's font loading, one file at a time, so the order of + // failures is fixed; the bytes above are not a real font to draw with. + const original = vi.mocked(render).getMockImplementation(); + vi.mocked(render).mockClear(); + vi.mocked(render).mockImplementation(async (_node, options) => { + for (const font of (await options?.fonts) ?? []) { + if (typeof font === 'object' && 'data' in font && typeof font.data === 'function') { + await font.data(); + } + } + return new Uint8Array(); + }); + try { + await new ImageResponse('
日本 ★
').arrayBuffer(); + } finally { + vi.mocked(render).mockImplementation(original!); + } + // Noto Sans JP, Symbols and Symbols 2. Each failure drops that file only, + // so the Japanese font stays, where the image used to lose every fallback. + const attempts = vi + .mocked(render) + .mock.calls.map(([, options]) => + ((options?.fonts ?? []) as Array<{ name?: string }>).map((font) => font.name), + ); + expect(attempts.map((fonts) => fonts.length)).toEqual([3, 2, 1]); + expect(attempts.at(-1)).toEqual(['void/og Noto Sans JP']); + // A file that failed is not requested again. + expect( + requests.filter((url) => url.includes('Symbols')).filter((url) => url.endsWith('.woff2')), + ).toHaveLength(2); + expect(warned).toHaveBeenCalledTimes(2); + }); + + it('renders without fallback fonts when Google Fonts cannot be reached', async () => { + const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + const response = new ImageResponse('
안녕하세요
'); + expect(await pngSize(response)).toEqual({ width: 1200, height: 630 }); + expect(warned).toHaveBeenCalledWith( + 'void/og: could not load Noto Sans KR from Google Fonts.', + expect.anything(), + ); + }); +}); + +describe('void/og on Node.js targets', () => { + let root: string | undefined; + afterEach(() => { + if (root) { + rmSync(root, { recursive: true, force: true }); + root = undefined; + } + }); + + function rendererPlugin() { + return nodeTargetPlugin('node').find( + (candidate) => candidate.name === 'void:node-target-image-renderer', + )!; + } + + function resolveRenderer( + importer = toSlash(fileURLToPath(new URL('../../src/runtime/og.ts', import.meta.url))), + plugin = rendererPlugin(), + ) { + const resolveId = plugin.resolveId as { + handler: (this: unknown, id: string, importer: string) => unknown; + }; + return resolveId.handler.call( + { environment: { config: { root, command } }, warn }, + 'takumi-js/helpers', + importer, + ); + } + let command = 'build'; + const warn = vi.fn(); + + // The range void/og declares; the app must install a version in it. + const range = voidManifest.dependencies['takumi-js']; + + function installTakumi(version: string) { + root ??= mkdtempSync(join(tmpdir(), 'void-og-node-')); + mkdirSync(join(root, 'node_modules', 'takumi-js'), { recursive: true }); + writeFileSync( + join(root, 'node_modules', 'takumi-js', 'package.json'), + JSON.stringify({ name: 'takumi-js', version }), + ); + } + + it('fails the build when the app cannot load takumi-js itself', () => { + root = mkdtempSync(join(tmpdir(), 'void-og-node-')); + expect(resolveRenderer).toThrow( + `void/og needs takumi-js@${range} in your app's dependencies with target 'node'`, + ); + }); + + it('fails the build when the app has a takumi-js that void/og does not support', () => { + installTakumi('2.15.2'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.15.2.'); + installTakumi('3.0.0'); + expect(resolveRenderer).toThrow('The app has takumi-js 3.0.0.'); + installTakumi('2.18.2'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.18.2.'); + installTakumi('2.20.1-beta.1'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.20.1-beta.1.'); + // takumi-js 2.19.0 draws boxes for text its first matching font lacks. + installTakumi('2.19.0'); + expect(resolveRenderer).toThrow('The app has takumi-js 2.19.0.'); + }); + + it('warns once in the dev server instead', () => { + root = mkdtempSync(join(tmpdir(), 'void-og-node-')); + const plugin = rendererPlugin(); + command = 'serve'; + warn.mockClear(); + try { + expect(resolveRenderer(undefined, plugin)).toBeUndefined(); + expect(resolveRenderer(undefined, plugin)).toBeUndefined(); + } finally { + command = 'build'; + } + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]![0]).toContain(`npm install takumi-js@${range}`); + }); + + it("leaves a supported takumi-js in the app's dependencies to Node.js", () => { + installTakumi(range.replace(/^[~^]/, '')); + expect(resolveRenderer()).toBeUndefined(); + installTakumi('2.20.9'); + expect(resolveRenderer()).toBeUndefined(); + }); + + it('leaves takumi-js that the app imports itself alone', () => { + installTakumi('3.0.0'); + expect(resolveRenderer(toSlash(join(root!, 'src', 'image.ts')))).toBeUndefined(); + }); + + it("finds void's package.json wherever the bundler places the plugin", () => { + root = toSlash(mkdtempSync(join(tmpdir(), 'void-og-node-'))); + const voidDir = `${root}/node_modules/void`; + mkdirSync(`${voidDir}/dist/chunks`, { recursive: true }); + writeFileSync(`${voidDir}/package.json`, JSON.stringify({ name: 'void' })); + // A package.json without void's name, such as a nested `type` marker, is skipped. + writeFileSync(`${voidDir}/dist/package.json`, JSON.stringify({ type: 'module' })); + expect(findVoidPackageDir(`${voidDir}/dist/index.mjs`)).toBe(voidDir); + expect(findVoidPackageDir(`${voidDir}/dist/chunks/plugin-abc.mjs`)).toBe(voidDir); + expect(() => findVoidPackageDir(`${root}/elsewhere/index.mjs`)).toThrow( + "could not find void's package.json", + ); + }); +}); diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index 71c0c6b37..78e8ba60b 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -115,6 +115,7 @@ export default defineConfig({ 'src/runtime/log.ts', 'src/runtime/ai.ts', 'src/runtime/limits.ts', + 'src/runtime/og.ts', 'src/runtime/email.ts', 'src/runtime/email-protocol.ts', 'src/runtime/email/testing.ts', diff --git a/playground/image-response/README.md b/playground/image-response/README.md new file mode 100644 index 000000000..8043c92af --- /dev/null +++ b/playground/image-response/README.md @@ -0,0 +1,9 @@ +# ImageResponse Playground + +Demo of `ImageResponse` from `void/og`. + +To start dev server, run the repository root: + +```sh +vp run --filter ./playground/image-response dev +``` diff --git a/playground/image-response/package.json b/playground/image-response/package.json new file mode 100644 index 000000000..249380f0d --- /dev/null +++ b/playground/image-response/package.json @@ -0,0 +1,25 @@ +{ + "name": "playground-image-response", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "dev": "vp dev", + "preview": "vp preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@void/react": "workspace:*", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "void": "workspace:*" + } +} diff --git a/playground/image-response/pages/examples/[variant].server.ts b/playground/image-response/pages/examples/[variant].server.ts new file mode 100644 index 000000000..2c24c5aec --- /dev/null +++ b/playground/image-response/pages/examples/[variant].server.ts @@ -0,0 +1,38 @@ +import { defineHandler, defineHead } from 'void'; +import { getExample, imagePath } from '../../src/examples.ts'; + +export interface Props { + variant: string; + values: Record; + imageUrl: string; +} + +export const loader = defineHandler((c) => { + const example = getExample(c.req.param('variant') ?? ''); + if (!example) { + return c.notFound(); + } + const values = Object.fromEntries( + example.controls.map((control) => { + const requested = c.req.query(control.name) ?? control.value; + const value = + control.options && !control.options.some((option) => option.value === requested) + ? control.value + : requested.slice(0, 200); + return [control.name, value]; + }), + ); + return { variant: example.id, values, imageUrl: imagePath(example.id, values) } satisfies Props; +}); + +export const head = defineHead((c, props) => { + const example = getExample(props.variant)!; + return { + title: `${example.title} | ImageResponse Playground`, + meta: [ + { property: 'og:title', content: example.title }, + { property: 'og:image', content: new URL(props.imageUrl, c.req.url).href }, + { name: 'twitter:card', content: 'summary_large_image' }, + ], + }; +}); diff --git a/playground/image-response/pages/examples/[variant].tsx b/playground/image-response/pages/examples/[variant].tsx new file mode 100644 index 000000000..46fe47eff --- /dev/null +++ b/playground/image-response/pages/examples/[variant].tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react'; +import { getExample } from '../../src/examples.ts'; +import { SourceLink } from '../../src/source-link.tsx'; +import type { Props } from './[variant].server.ts'; + +function Preview({ imageUrl, title }: { imageUrl: string; title: string }) { + const [imageSrc, setImageSrc] = useState(); + const [size, setSize] = useState('Loading image…'); + const [responseInfo, setResponseInfo] = useState('Fetching image…'); + + useEffect(() => { + const controller = new AbortController(); + let objectUrl: string | undefined; + + async function load() { + let info = ''; + try { + const response = await fetch(imageUrl, { cache: 'no-store', signal: controller.signal }); + info = [ + `HTTP ${response.status}`, + ...Array.from(response.headers, ([k, v]) => `${k}: ${v}`), + ].join('\n'); + setResponseInfo(info); + const bytes = await response.arrayBuffer(); + if (controller.signal.aborted) { + return; + } + info += `\n\nBody: ${bytes.byteLength.toLocaleString()} bytes`; + const png = new DataView(bytes); + if ( + !response.ok || + bytes.byteLength < 24 || + png.getUint32(0) !== 0x89504e47 || + png.getUint32(4) !== 0x0d0a1a0a + ) { + info += `\n\n${new TextDecoder().decode(bytes.slice(0, 2000))}`; + throw new Error( + response.ok ? 'The response body is not a PNG.' : `HTTP ${response.status}`, + ); + } + info += `\nPNG: ${png.getUint32(16)} × ${png.getUint32(20)}`; + objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); + setImageSrc(objectUrl); + setResponseInfo(info); + } catch (error) { + if (controller.signal.aborted) { + return; + } + setSize('Image failed to load. See the response below and server log.'); + setResponseInfo(`${info}\n\nImage request/body failed: ${String(error)}`.trim()); + } + } + + void load(); + return () => { + controller.abort(); + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [imageUrl]); + + return ( +
+
+ {imageSrc && ( + {title} + setSize( + `${event.currentTarget.naturalWidth} × ${event.currentTarget.naturalHeight} PNG`, + ) + } + onError={() => + setSize('Image could not be decoded. See the response below and server log.') + } + /> + )} +
+

{size}

+

+ Open PNG |{' '} + + Download PNG + +

+

+ {imageUrl} +

+

Response

+
{responseInfo}
+
+ ); +} + +export default function ExamplePage({ variant, values, imageUrl }: Props) { + const example = getExample(variant)!; + return ( + <> +

{example.title}

+

{example.description}

+

+ +

+ {variant === 'article' && ( +

+ |{' '} + Missing article (404) +

+ )} +
{example.code}
+ {example.network && ( +

+ Network: {example.network} +

+ )} + {example.controls.length > 0 && ( +
+ {example.controls.map((control) => ( + + ))} +
+ )} +
+ +
+

Expected

+
    + {example.checks.map((check) => ( +
  • {check}
  • + ))} +
+

All images are 1200 × 630 unless the demo explicitly sets a different size.

+

+ (loader and page head) +

+
+
+ + ); +} diff --git a/playground/image-response/pages/index.tsx b/playground/image-response/pages/index.tsx new file mode 100644 index 000000000..b70d9a335 --- /dev/null +++ b/playground/image-response/pages/index.tsx @@ -0,0 +1,36 @@ +import { Link } from '@void/react'; +import { examples } from '../src/examples.ts'; +import { SourceLink } from '../src/source-link.tsx'; + +export default function Home() { + return ( + <> +

ImageResponse Playground

+

+ Eight distinct uses of ImageResponse from void/og. Each example + has a live preview, source links, and expected results. Options that exercise the same use + case stay together on one page. +

+

+ Start with the JSX or HTML examples for an offline baseline. Fonts, multilingual text, + emoji, and remote images also exercise server-side downloads. Use the Network panel and + server log alongside the previews. +

+
+ {examples.map((example) => ( +
+

+ {example.title} +

+

{example.description}

+ +
{example.code}
+

+ Open demo +

+
+ ))} +
+ + ); +} diff --git a/playground/image-response/pages/layout.tsx b/playground/image-response/pages/layout.tsx new file mode 100644 index 000000000..54310bee8 --- /dev/null +++ b/playground/image-response/pages/layout.tsx @@ -0,0 +1,18 @@ +import { Link } from '@void/react'; +import type { ReactNode } from 'react'; +import './styles.css'; + +export default function Layout({ children }: { children: ReactNode }) { + return ( +
+
+ +
+
+
{children}
+
+ ); +} diff --git a/playground/image-response/pages/styles.css b/playground/image-response/pages/styles.css new file mode 100644 index 000000000..2a9159a6e --- /dev/null +++ b/playground/image-response/pages/styles.css @@ -0,0 +1,101 @@ +:root { + font-family: system-ui, sans-serif; + color: #0f172a; + background: #f8fafc; + line-height: 1.5; +} + +body { + margin: 0; +} +a { + color: #1d4ed8; +} +button, +input, +select { + font: inherit; +} +button { + cursor: pointer; +} +.example-container { + max-width: 100rem; + margin-inline: auto; + padding: 1.5rem; +} +p { + max-width: 80ch; +} +pre { + padding: 1rem; + background: #e2e8f0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +code { + overflow-wrap: anywhere; +} +.examples { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem 2rem; +} +.example { + min-width: 0; + border-bottom: 1px solid #cbd5e1; + padding-bottom: 1rem; +} +.example h2 { + font-size: 1.2rem; +} +.controls { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 1rem; + margin-block: 1.5rem; +} +.controls label { + display: grid; + gap: 0.25rem; +} +.controls input { + width: min(30rem, 75vw); +} +.demo { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr); + gap: 2rem; +} +.preview { + background: #e2e8f0; + border: 1px solid #cbd5e1; + padding: 0.5rem; +} +.preview img { + display: block; + max-width: 100%; + height: auto; + margin-inline: auto; +} +.notice { + padding: 0.75rem; + background: #fef3c7; +} +li + li { + margin-top: 0.75rem; +} + +@media (max-width: 75rem) { + .examples { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 50rem) { + .examples, + .demo { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/playground/image-response/routes/og/article/[slug].tsx b/playground/image-response/routes/og/article/[slug].tsx new file mode 100644 index 000000000..05b8ee329 --- /dev/null +++ b/playground/image-response/routes/og/article/[slug].tsx @@ -0,0 +1,18 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { Card } from '../../../src/card.tsx'; +import { getPost } from '../../../src/posts.ts'; + +export const GET = defineHandler((c) => { + const post = getPost(c.req.param('slug') ?? ''); + if (!post) { + return c.notFound(); + } + return new ImageResponse( + +
{post.category}
+
{post.title}
+
{post.author}
+
, + ); +}); diff --git a/playground/image-response/routes/og/emoji.tsx b/playground/image-response/routes/og/emoji.tsx new file mode 100644 index 000000000..57d1342d4 --- /dev/null +++ b/playground/image-response/routes/og/emoji.tsx @@ -0,0 +1,21 @@ +import { defineHandler } from 'void'; +import { ImageResponse, type ImageResponseOptions } from 'void/og'; +import { Card } from '../../src/card.tsx'; + +const styles = ['twemoji', 'blobmoji', 'noto', 'openmoji', 'fluent', 'fluentFlat'] as const; + +export const GET = defineHandler((c) => { + const requested = c.req.query('emoji'); + const emoji: ImageResponseOptions['emoji'] = + styles.find((style) => style === requested) ?? 'twemoji'; + return new ImageResponse( + +
EMOJI / {emoji}
+
Ship something great 🚀
+
+ {c.req.query('sample') === 'joined' ? '👩‍💻 🎉 🌍 ❤️' : '😀 🎉 🌍 ❤️'} +
+
, + { emoji, images: { timeout: 5000 } }, + ); +}); diff --git a/playground/image-response/routes/og/fonts.tsx b/playground/image-response/routes/og/fonts.tsx new file mode 100644 index 000000000..ab4cd8fc6 --- /dev/null +++ b/playground/image-response/routes/og/fonts.tsx @@ -0,0 +1,30 @@ +import { defineHandler } from 'void'; +import { ImageResponse, type ImageResponseOptions } from 'void/og'; +import { Card } from '../../src/card.tsx'; +import { fontPresets } from '../../src/fonts.ts'; + +export const GET = defineHandler(async (c) => { + const font = fontPresets.find((preset) => preset.id === c.req.query('font')) ?? fontPresets[0]!; + let fonts: ImageResponseOptions['fonts'] = [font.url]; + if (c.req.query('mode') === 'data') { + const response = await fetch(font.url); + if (!response.ok) { + return c.text('Could not download the demo font.', 502); + } + // No weight: the variable font follows each element's fontWeight. + fonts = [{ name: font.name, data: await response.arrayBuffer() }]; + } + return new ImageResponse( + +
+ {font.name.toUpperCase()} / VARIABLE WEIGHTS +
+ {[400, 550, 700].map((weight) => ( +
+ {weight} — Build with Void +
+ ))} +
, + { fonts, fallbackFonts: false }, + ); +}); diff --git a/playground/image-response/routes/og/html.ts b/playground/image-response/routes/og/html.ts new file mode 100644 index 000000000..c696e6ec8 --- /dev/null +++ b/playground/image-response/routes/og/html.ts @@ -0,0 +1,15 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; + +const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); + +export const GET = defineHandler((c) => { + const title = (c.req.query('title') ?? 'Hello without JSX').slice(0, 200); + return new ImageResponse(` +
+
HTML string, not React
+
${escapeHtml(title)}
+
+ `); +}); diff --git a/playground/image-response/routes/og/languages.tsx b/playground/image-response/routes/og/languages.tsx new file mode 100644 index 000000000..918fcc986 --- /dev/null +++ b/playground/image-response/routes/og/languages.tsx @@ -0,0 +1,18 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { Card } from '../../src/card.tsx'; + +export const GET = defineHandler((c) => { + const requested = c.req.query('lang') ?? 'ja'; + const lang = ['ja', 'zh-CN', 'zh-TW', 'ko'].includes(requested) ? requested : 'ja'; + return new ImageResponse( + +
GEIST + FALLBACK FONTS / {lang}
+
Hello, Łódź! → ★
+
骨 直 令 / 世界 / タワー
+
مرحبا بالعالم
+
สวัสดีชาวโลก
+
, + { lang, fallbackFonts: c.req.query('fallback') !== 'off' }, + ); +}); diff --git a/playground/image-response/routes/og/layout.tsx b/playground/image-response/routes/og/layout.tsx new file mode 100644 index 000000000..e99f04fd6 --- /dev/null +++ b/playground/image-response/routes/og/layout.tsx @@ -0,0 +1,23 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; + +export const GET = defineHandler( + (c) => + new ImageResponse( +
+
+
VOID / LAYOUT
+
Small markup. Big preview.
+
Tailwind classes inside the image.
+
+
+
Vite
+
Void
+
+
, + { debug: c.req.query('debug') === 'on' }, + ), +); diff --git a/playground/image-response/routes/og/remote.tsx b/playground/image-response/routes/og/remote.tsx new file mode 100644 index 000000000..e14ef090f --- /dev/null +++ b/playground/image-response/routes/og/remote.tsx @@ -0,0 +1,28 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { Card } from '../../src/card.tsx'; + +// Fixed input: this demo never accepts arbitrary remote URLs from a request. +const avatar = 'https://avatars.githubusercontent.com/u/65625612?s=256'; + +export const GET = defineHandler((c) => { + const mode = c.req.query('mode') ?? 'allow'; + return new ImageResponse( + +
REMOTE IMAGE / {mode}
+
+ +
An image from the web.
+
+
The text survives a lenient image failure.
+
, + { + images: { + allowUrl: (url) => url === avatar && (mode === 'allow' || mode === 'limit'), + maxBytes: mode === 'limit' ? 64 : 1024 * 1024, + timeout: 5000, + throwOnError: mode === 'allow' || mode === 'fail', + }, + }, + ); +}); diff --git a/playground/image-response/routes/og/square.tsx b/playground/image-response/routes/og/square.tsx new file mode 100644 index 000000000..950f66d66 --- /dev/null +++ b/playground/image-response/routes/og/square.tsx @@ -0,0 +1,15 @@ +import { defineHandler } from 'void'; +import { ImageResponse } from 'void/og'; +import { Card } from '../../src/card.tsx'; + +export const GET = defineHandler( + () => + new ImageResponse( + +
A DIFFERENT FORMAT
+
Think outside the rectangle.
+
600 x 600
+
, + { width: 600, height: 600, headers: { 'Cache-Control': 'no-store', 'X-OG-Demo': 'square' } }, + ), +); diff --git a/playground/image-response/src/card.tsx b/playground/image-response/src/card.tsx new file mode 100644 index 000000000..60df3b596 --- /dev/null +++ b/playground/image-response/src/card.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +export function Card({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/playground/image-response/src/examples.ts b/playground/image-response/src/examples.ts new file mode 100644 index 000000000..5d8dc2126 --- /dev/null +++ b/playground/image-response/src/examples.ts @@ -0,0 +1,231 @@ +import { fontPresets } from './fonts.ts'; + +export interface Control { + name: string; + label: string; + value: string; + options?: Array<{ value: string; label: string }>; +} + +interface Example { + id: string; + title: string; + description: string; + file: string; + code: string; + checks: Array; + controls: Array; + network?: string; +} + +export const examples: Array = [ + { + id: 'article', + title: 'JSX article card', + description: 'A reusable JSX component, route data, and an absolute og:image in the page head.', + file: 'routes/og/article/[slug].tsx', + code: 'return new ImageResponse(…{post.title}…);', + checks: [ + 'The PNG is 1200 × 630 with Geist text and a byline.', + 'Choose the long title to check wrapping. Open the missing article URL to check its 404.', + 'View page source for og:image and twitter:card; the image URL includes the selected slug.', + ], + controls: [ + { + name: 'slug', + label: 'Article', + value: 'hello', + options: [ + { value: 'hello', label: 'Short title' }, + { value: 'long', label: 'Long title' }, + ], + }, + ], + }, + { + id: 'html', + title: 'HTML with request input', + description: 'Framework-independent markup with an escaped title from the query string.', + file: 'routes/og/html.ts', + code: 'new ImageResponse(`
${escapeHtml(title)}
`);', + checks: [ + 'The title appears literally, including , &, and quotes; it must not become markup.', + 'Edit the title, then leave the field or press Enter. A long title should wrap without leaving the card.', + ], + controls: [{ name: 'title', label: 'Title', value: 'Void is not HTML & "friends"' }], + }, + { + id: 'layout', + title: 'Tailwind and debug borders', + description: 'Utility classes, a gradient, and a two-column layout without a CSS build step.', + file: 'routes/og/layout.tsx', + code: 'new ImageResponse(
…
, { debug });', + checks: [ + 'The card has two columns, rounded boxes, and a gradient background.', + 'Enable debug to see borders around the layout boxes; disable it to compare.', + ], + controls: [ + { + name: 'debug', + label: 'Debug borders', + value: 'off', + options: [ + { value: 'off', label: 'Off' }, + { value: 'on', label: 'On' }, + ], + }, + ], + }, + { + id: 'square', + title: 'Square image and response headers', + description: 'A 600 × 600 social card with an explicit cache policy and a custom header.', + file: 'routes/og/square.tsx', + code: "new ImageResponse(card, { width: 600, height: 600, headers: { 'Cache-Control': 'no-store', 'X-OG-Demo': 'square' } });", + checks: [ + 'The actual PNG dimensions are 600 × 600, not just a square browser crop.', + 'The response shows Content-Type image/png, Cache-Control no-store, and X-OG-Demo square.', + ], + controls: [], + }, + { + id: 'fonts', + title: 'Custom variable font', + description: + 'Compare five variable typefaces by URL or font bytes, with multiple weights in each image.', + file: 'routes/og/fonts.tsx', + code: 'new ImageResponse(card, { fonts: [font.url], fallbackFonts: false });', + network: 'Downloads the selected font from fonts.gstatic.com. Both modes need internet access.', + checks: [ + 'The 400, 550, and 700 rows get progressively heavier without boxes or clipping.', + 'Switch typefaces to compare clean sans-serif, slab serif, editorial serif, monospace, and handwriting styles.', + 'URL and font-data modes should look the same. No weight is pinned on the variable font entry.', + ], + controls: [ + { + name: 'font', + label: 'Typeface', + value: 'inter', + options: fontPresets.map((font) => ({ value: font.id, label: font.label })), + }, + { + name: 'mode', + label: 'Font source', + value: 'url', + options: [ + { value: 'url', label: 'Font URL' }, + { value: 'data', label: 'Font bytes' }, + ], + }, + ], + }, + { + id: 'languages', + title: 'Multilingual fallback fonts', + description: 'Mixed scripts, accented Latin, symbols, and language-specific Chinese glyphs.', + file: 'routes/og/languages.tsx', + code: "new ImageResponse(card, { lang: 'zh-TW', fallbackFonts: true });", + network: + 'Automatic fallback downloads fonts from Google Fonts. The first render can be slower.', + checks: [ + 'With fallback enabled, Japanese, Łódź, Arabic, Thai, and symbols should not be boxes.', + 'Switch the language to compare the same Han characters in Japanese, Simplified Chinese, Traditional Chinese, and Korean styles.', + 'Disable fallback to see missing glyphs while basic Latin remains readable; switch it back on to compare.', + ], + controls: [ + { + name: 'lang', + label: 'Han glyph style', + value: 'ja', + options: [ + { value: 'ja', label: 'Japanese' }, + { value: 'zh-CN', label: 'Simplified Chinese' }, + { value: 'zh-TW', label: 'Traditional Chinese' }, + { value: 'ko', label: 'Korean' }, + ], + }, + { + name: 'fallback', + label: 'Automatic fonts', + value: 'on', + options: [ + { value: 'on', label: 'Enabled' }, + { value: 'off', label: 'Disabled (expect missing glyphs)' }, + ], + }, + ], + }, + { + id: 'emoji', + title: 'Emoji styles', + description: 'Compare the six emoji sets, with a separate sample for joined emoji.', + file: 'routes/og/emoji.tsx', + code: "new ImageResponse(card, { emoji: 'twemoji' });", + network: 'Downloads emoji images from jsDelivr; repeat renders can reuse them.', + checks: [ + 'Switch styles: the emoji artwork changes, but the text and layout stay the same.', + 'Choose the joined sample: the technologist should be one emoji, not separate person and laptop images.', + 'An emoji missing from the chosen set fails the image. Compare another set and inspect the server log for the failed download URL.', + ], + controls: [ + { + name: 'emoji', + label: 'Emoji set', + value: 'twemoji', + options: ['twemoji', 'blobmoji', 'noto', 'openmoji', 'fluent', 'fluentFlat'].map( + (value) => ({ value, label: value }), + ), + }, + { + name: 'sample', + label: 'Sample', + value: 'common', + options: [ + { value: 'common', label: 'Common emoji' }, + { value: 'joined', label: 'Joined emoji (availability varies)' }, + ], + }, + ], + }, + { + id: 'remote', + title: 'Remote image policies', + description: + 'A fixed remote avatar with allow-listing, size limits, and strict or lenient failures.', + file: 'routes/og/remote.tsx', + code: 'new ImageResponse(card, { images: { allowUrl, maxBytes, timeout: 5000, throwOnError } });', + network: + 'The allowed image downloads from avatars.githubusercontent.com. No arbitrary URL input.', + checks: [ + 'Allowed: the Vite avatar appears next to the text.', + 'Blocked / omit and size limit / omit: the card still renders without the avatar.', + 'Blocked / fail: the image intentionally fails. The response reports the failure; check the server log too.', + 'In dev, an intentional failure may open the Vite error overlay. Dismiss it to use the page again.', + ], + controls: [ + { + name: 'mode', + label: 'Image policy', + value: 'allow', + options: [ + { value: 'allow', label: 'Allowed' }, + { value: 'omit', label: 'Blocked / omit' }, + { value: 'limit', label: 'Size limit / omit' }, + { value: 'fail', label: 'Blocked / fail' }, + ], + }, + ], + }, +]; + +export function getExample(id: string) { + return examples.find((example) => example.id === id); +} + +export function imagePath(id: string, values: Record) { + if (id === 'article') { + return `/og/article/${encodeURIComponent(values.slug ?? 'hello')}`; + } + const query = new URLSearchParams(values).toString(); + return `/og/${id}${query ? `?${query}` : ''}`; +} diff --git a/playground/image-response/src/fonts.ts b/playground/image-response/src/fonts.ts new file mode 100644 index 000000000..eb2ee047d --- /dev/null +++ b/playground/image-response/src/fonts.ts @@ -0,0 +1,32 @@ +export const fontPresets = [ + { + id: 'inter', + name: 'Inter', + label: 'Inter — clean sans-serif', + url: 'https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2', + }, + { + id: 'roboto-slab', + name: 'Roboto Slab', + label: 'Roboto Slab — sturdy slab serif', + url: 'https://fonts.gstatic.com/s/robotoslab/v36/BngMUXZYTXPIvIBgJJSb6ufN5qWr4xCC.woff2', + }, + { + id: 'playfair-display', + name: 'Playfair Display', + label: 'Playfair Display — editorial serif', + url: 'https://fonts.gstatic.com/s/playfairdisplay/v40/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgEM86xQ.woff2', + }, + { + id: 'jetbrains-mono', + name: 'JetBrains Mono', + label: 'JetBrains Mono — developer monospace', + url: 'https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbV2o-flEEny0FZhsfKu5WU4xD7OwGtT0rU.woff2', + }, + { + id: 'caveat', + name: 'Caveat', + label: 'Caveat — casual handwriting', + url: 'https://fonts.gstatic.com/s/caveat/v23/Wnz6HAc5bAfYB2Q7ZjYYiAzcPA.woff2', + }, +]; diff --git a/playground/image-response/src/posts.ts b/playground/image-response/src/posts.ts new file mode 100644 index 000000000..b6c12464f --- /dev/null +++ b/playground/image-response/src/posts.ts @@ -0,0 +1,12 @@ +export const posts = { + hello: { title: 'Hello from Void', author: 'The Void team', category: 'Getting started' }, + long: { + title: 'Build full-stack applications on Cloudflare Workers with Vite and Void', + author: 'The Void team', + category: 'A longer title', + }, +}; + +export function getPost(slug: string) { + return Object.entries(posts).find(([key]) => key === slug)?.[1]; +} diff --git a/playground/image-response/src/source-link.tsx b/playground/image-response/src/source-link.tsx new file mode 100644 index 000000000..988189881 --- /dev/null +++ b/playground/image-response/src/source-link.tsx @@ -0,0 +1,14 @@ +declare const __IMAGE_RESPONSE_SOURCE_ROOT__: string; + +export function SourceLink({ file }: { file: string }) { + const source = new URL( + file.split('/').map(encodeURIComponent).join('/'), + __IMAGE_RESPONSE_SOURCE_ROOT__, + ); + const host = source.host ? `//${source.host}` : ''; + return ( + + {file} + + ); +} diff --git a/playground/image-response/tsconfig.json b/playground/image-response/tsconfig.json new file mode 100644 index 000000000..0ef417fe5 --- /dev/null +++ b/playground/image-response/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./.void/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["pages", "routes", "src", "*.config.ts"] +} diff --git a/playground/image-response/vite.config.ts b/playground/image-response/vite.config.ts new file mode 100644 index 000000000..ab6c2f623 --- /dev/null +++ b/playground/image-response/vite.config.ts @@ -0,0 +1,10 @@ +import { voidReact } from '@void/react/plugin'; +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + define: { + __IMAGE_RESPONSE_SOURCE_ROOT__: JSON.stringify(new URL('./', import.meta.url).href), + }, + plugins: [voidPlugin(), voidReact()], +}); diff --git a/playground/image-response/void.config.ts b/playground/image-response/void.config.ts new file mode 100644 index 000000000..fd88a7db9 --- /dev/null +++ b/playground/image-response/void.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'void/config'; + +export default defineConfig({ + head: { + title: 'ImageResponse Playground', + htmlAttrs: { lang: 'en' }, + meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }], + }, +}); diff --git a/playground/image-response/void.lock.json b/playground/image-response/void.lock.json new file mode 100644 index 000000000..393006e90 --- /dev/null +++ b/playground/image-response/void.lock.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "source": {}, + "resolved": { + "compatibility_date": "2026-02-24" + }, + "text": "{\n \"compatibility_date\": \"2026-02-24\"\n}\n" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d370b14b5..05eab89fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -873,6 +873,9 @@ importers: smol-toml: specifier: 'catalog:' version: 1.9.1 + takumi-js: + specifier: ~2.20.0 + version: 2.20.0(csstype@3.2.3)(react@19.3.1) terminal-link: specifier: ^5.0.0 version: 5.0.0 @@ -1357,6 +1360,40 @@ importers: specifier: workspace:* version: link:../../packages/void + playground/image-response: + dependencies: + hono: + specifier: 'catalog:' + version: 4.13.18 + react: + specifier: 'catalog:' + version: 19.3.1 + react-dom: + specifier: 'catalog:' + version: 19.3.1(react@19.3.1) + devDependencies: + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@void/react': + specifier: workspace:* + version: link:../../packages/react + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' + vite-plus: + specifier: 'catalog:' + version: 1.6.0(@astrojs/compiler-rs@0.5.3)(@types/node@26.6.6)(@vitest/ui@5.1.0)(esbuild@0.28.2)(happy-dom@21.0.4)(jiti@2.7.0)(playwright@1.65.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1) + void: + specifier: workspace:* + version: link:../../packages/void + playground/kitchen-sink: dependencies: hono: @@ -5402,6 +5439,88 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@takumi-rs/core-darwin-arm64@2.20.0': + resolution: {integrity: sha512-wescbJ8pm0fBN8uXxjiZUDqN7JkdDDmxt8Gq1pSrpRCuGxvAnoj3agMMhHtJ5Ty+BufkIwSvV2cBXRLM4wyYZw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@takumi-rs/core-darwin-x64@2.20.0': + resolution: {integrity: sha512-idCQC8PLuuDFQ4c2YEx142Q1WM+GbrR3RqNxuJay5MxfCXcZ42dv+96xGcgs0U2AWOk1GkY/vTY4y/Om7O/QHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@takumi-rs/core-linux-arm64-gnu@2.20.0': + resolution: {integrity: sha512-TeqwOXlVdjAoSoOVQVoJOER4upQnh7nX6QJnYqVDlbByiSkMhqYULXc7I83NqzkL3U/fbT7/oASNeGWAI2axBA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@takumi-rs/core-linux-arm64-musl@2.20.0': + resolution: {integrity: sha512-gOcZxdGq7ebhqRN1Ue3FYJV7jKFG8yG99Opiky13viXXH8hkOde+OKUMe2kqWRH42j7p5EhBPj2qmdUBXXQBCQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@takumi-rs/core-linux-x64-gnu@2.20.0': + resolution: {integrity: sha512-MwIdLqyMUUz+6wuxkGJZyEO9alZODuDqe826HAG2+RErwpSUwfqtD2pHPWG9bnWkr7qpVeziFut6Ng68lYzcvQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@takumi-rs/core-linux-x64-musl@2.20.0': + resolution: {integrity: sha512-bM9QEwOuKg0+im1M+XjownCCdN+zHKQXGblDttpKuoa2QEd4F9t0JJcQMBSjLFucC2sf+rGicAmayMsQRxwgfg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@takumi-rs/core-win32-arm64-msvc@2.20.0': + resolution: {integrity: sha512-VwGj5dsODmHYV09P3AFhvcHL3hk8cS3Y0PYYHxFQtopwcWTsDN522+siotYL1lXZaEag9FoMJOuqiuZsVGgR3w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@takumi-rs/core-win32-x64-msvc@2.20.0': + resolution: {integrity: sha512-3am5JIowXAETxUHwQh8MONCagATpN4jFiXISKT2djvceJxrriPZKwWNjB7zTUzZmVLYTbslMrG7x2+U1wPndYg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@takumi-rs/core@2.20.0': + resolution: {integrity: sha512-28uryNYSyuXZWv5uBfJiWDrRXVdAunkU0sZiB7KG2WgLej64pUNL6jZSTQa+gBXfVu+3DdCz58OnpONBO6EUqQ==} + engines: {node: '>=18'} + peerDependencies: + csstype: '*' + peerDependenciesMeta: + csstype: + optional: true + + '@takumi-rs/helpers@2.20.0': + resolution: {integrity: sha512-efdeG4Ujsw0IEYXhq3lUN1UiKhK6f3tak7VSMrCEWSvumFs0q5moBHSLdsR6aJhPNsYei21Ea6Rjtv94/qdyjg==} + engines: {node: '>=18'} + peerDependencies: + preact: ^10.0.0 || ^11.0.0 + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + preact: + optional: true + react: + optional: true + + '@takumi-rs/wasm@2.20.0': + resolution: {integrity: sha512-2Xfu55Ye8gmJMXQEDun/5zl/78LyCXKUjP503alH1tG0OyotkkWpZbgW8iWGuxxf4IJvp9kIaO+NLGbq4B6J8A==} + engines: {node: '>=20.19'} + peerDependencies: + csstype: '*' + peerDependenciesMeta: + csstype: + optional: true + '@tanstack/history@1.162.5': resolution: {integrity: sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==} engines: {node: '>=20.19'} @@ -9688,6 +9807,10 @@ packages: tailwindcss@4.3.5: resolution: {integrity: sha512-FcQk9BuffmEHJP2G0XmB2SplU1aLTI7j07Kunk+U74Ny+XiWOtzXz9aujFZ/avntw1e2cOv/3PkgzflQ7gX2vQ==} + takumi-js@2.20.0: + resolution: {integrity: sha512-WnqocRxTlQcDeA6Q0Awcz3+diSF3UGA2hmIL3pLsqiGXZHpeeRVpVad97EO33sYiMT6+L2kMya28EVGJbjCyfg==} + engines: {node: '>=20.19'} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -13541,6 +13664,60 @@ snapshots: tailwindcss: 4.3.5 vite: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' + '@takumi-rs/core-darwin-arm64@2.20.0': + optional: true + + '@takumi-rs/core-darwin-x64@2.20.0': + optional: true + + '@takumi-rs/core-linux-arm64-gnu@2.20.0': + optional: true + + '@takumi-rs/core-linux-arm64-musl@2.20.0': + optional: true + + '@takumi-rs/core-linux-x64-gnu@2.20.0': + optional: true + + '@takumi-rs/core-linux-x64-musl@2.20.0': + optional: true + + '@takumi-rs/core-win32-arm64-msvc@2.20.0': + optional: true + + '@takumi-rs/core-win32-x64-msvc@2.20.0': + optional: true + + '@takumi-rs/core@2.20.0(csstype@3.2.3)(react@19.3.1)': + dependencies: + '@takumi-rs/helpers': 2.20.0(react@19.3.1) + optionalDependencies: + '@takumi-rs/core-darwin-arm64': 2.20.0 + '@takumi-rs/core-darwin-x64': 2.20.0 + '@takumi-rs/core-linux-arm64-gnu': 2.20.0 + '@takumi-rs/core-linux-arm64-musl': 2.20.0 + '@takumi-rs/core-linux-x64-gnu': 2.20.0 + '@takumi-rs/core-linux-x64-musl': 2.20.0 + '@takumi-rs/core-win32-arm64-msvc': 2.20.0 + '@takumi-rs/core-win32-x64-msvc': 2.20.0 + csstype: 3.2.3 + transitivePeerDependencies: + - preact + - react + + '@takumi-rs/helpers@2.20.0(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + + '@takumi-rs/wasm@2.20.0(csstype@3.2.3)(react@19.3.1)': + dependencies: + '@takumi-rs/helpers': 2.20.0(react@19.3.1) + optionalDependencies: + csstype: 3.2.3 + transitivePeerDependencies: + - preact + - react + '@tanstack/history@1.162.5': {} '@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': @@ -18415,6 +18592,16 @@ snapshots: tailwindcss@4.3.5: {} + takumi-js@2.20.0(csstype@3.2.3)(react@19.3.1): + dependencies: + '@takumi-rs/core': 2.20.0(csstype@3.2.3)(react@19.3.1) + '@takumi-rs/helpers': 2.20.0(react@19.3.1) + '@takumi-rs/wasm': 2.20.0(csstype@3.2.3)(react@19.3.1) + transitivePeerDependencies: + - csstype + - preact + - react + tapable@2.3.3: {} tar-stream@3.2.2(bare-buffer@3.7.1): diff --git a/test/integration-timings.ts b/test/integration-timings.ts index ef386ccae..1b9b458eb 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -29,6 +29,7 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/node-static.test.ts': 13667, 'packages/void/test/integration/node-target.test.ts': 10833, 'packages/void/test/integration/nuxt.test.ts': 43000, + 'packages/void/test/integration/og-image.test.ts': 40000, 'packages/void/test/integration/operator-cli.test.ts': 4910, 'packages/void/test/integration/pages-target-assets.test.ts': 10966, 'packages/void/test/integration/pg-api-test.test.ts': 11000,