@pylonsync/functions 0.3.317 → 0.3.319

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ export interface OgFont {
2
+ name: string;
3
+ data: ArrayBuffer | Buffer | Uint8Array;
4
+ weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
5
+ style?: "normal" | "italic";
6
+ }
7
+ export interface RenderOgOptions {
8
+ width?: number;
9
+ height?: number;
10
+ /** Extra/override fonts. When omitted, the bundled Inter (400+600) is used. */
11
+ fonts?: OgFont[];
12
+ /**
13
+ * Emoji provider passthrough for Satori (`graphemeImages` / `loadAdditionalAsset`).
14
+ * Left undefined by default — emoji render as tofu unless the app supplies fonts.
15
+ */
16
+ loadAdditionalAsset?: (code: string, text: string) => Promise<string> | string;
17
+ }
18
+ /**
19
+ * Render a React element to a PNG buffer via Satori + resvg.
20
+ *
21
+ * `element` is whatever the user's `opengraph-image.tsx` produced — a React
22
+ * element created by the *user's* react. Satori consumes it structurally
23
+ * (by `type`/`props`), so the react instance doesn't matter.
24
+ */
25
+ export declare function renderOgImage(element: unknown, options?: RenderOgOptions): Promise<Uint8Array>;
@@ -335,7 +335,7 @@ export declare function isSafeRedirect(url: string, opts: {
335
335
  /** Merge auto-discovered favicons (icon.* / apple-icon.* / favicon.ico)
336
336
  * into a page's metadata. Explicit `metadata.icons.*` wins. */
337
337
  export declare function applyAutoIcons(component: string, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
338
- export declare function applyAutoSocialImages(component: string, headers: Record<string, string> | undefined, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
338
+ export declare function applyAutoSocialImages(component: string, headers: Record<string, string> | undefined, metadata: SsrMetadata | undefined, requestUrl?: string): SsrMetadata | undefined;
339
339
  /**
340
340
  * Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render
341
341
  * mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the
@@ -541,5 +541,14 @@ export declare function serializeRobots(robots: Robots | undefined): string;
541
541
  * runner). 1-hour cache — sitemaps/robots change rarely; tune via a CDN.
542
542
  */
543
543
  export declare function handleDataRoute(msg: RenderRouteMessage, kind: "sitemap" | "robots", send: Send): Promise<void>;
544
+ /**
545
+ * Import an `opengraph-image.tsx`, call its default export, render the
546
+ * returned `ImageResponse` (or raw React element) to a PNG via Satori +
547
+ * resvg (see `./ssr-og-runtime`), and stream the bytes back with an image
548
+ * content-type. This is the code path behind the `opengraph-image` file
549
+ * convention (Next.js `next/og` parity). Errors surface as a 500 with a
550
+ * short plain-text body so a broken OG module doesn't wedge the runner.
551
+ */
552
+ export declare function handleOgImageRoute(msg: RenderRouteMessage, send: Send): Promise<void>;
544
553
  export declare function handleRenderRoute(msg: RenderRouteMessage, send: Send): Promise<void>;
545
554
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.317",
3
+ "version": "0.3.319",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -44,6 +44,10 @@
44
44
  "database"
45
45
  ],
46
46
  "license": "MIT OR Apache-2.0",
47
+ "dependencies": {
48
+ "satori": "^0.26.0",
49
+ "@resvg/resvg-wasm": "^2.6.2"
50
+ },
47
51
  "devDependencies": {
48
52
  "typescript": "^5.5",
49
53
  "react": "^19.0.0",
@@ -0,0 +1,164 @@
1
+ // Tests for the dynamic OG image convention (app/**/opengraph-image.tsx →
2
+ // PNG, Next.js `next/og` parity): the render engine (`renderOgImage`, the
3
+ // Satori→resvg pipeline) plus `handleOgImageRoute` end-to-end — import a temp
4
+ // module, render, and emit the response_start/chunk/done protocol with an
5
+ // image content-type. Fixtures live UNDER this package dir so their
6
+ // `import React from "react"` / `@pylonsync/react` resolve via workspace
7
+ // walk-up (an os.tmpdir() fixture has no node_modules to reach).
8
+
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+ import * as React from "react";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { renderOgImage } from "./ssr-og-runtime";
15
+ import { handleOgImageRoute, type RenderRouteMessage } from "./ssr-runtime";
16
+
17
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
18
+ const isPng = (b: Uint8Array): boolean =>
19
+ Buffer.from(b.subarray(0, 8)).equals(PNG_MAGIC);
20
+ // PNG IHDR: 8-byte signature, then len(4)+"IHDR"(4)+width(4 BE)+height(4 BE).
21
+ const pngSize = (b: Uint8Array): { w: number; h: number } => {
22
+ const buf = Buffer.from(b);
23
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
24
+ };
25
+
26
+ describe("renderOgImage (Satori → resvg pipeline)", () => {
27
+ test("renders a React element to a PNG of the requested size", async () => {
28
+ const el = React.createElement(
29
+ "div",
30
+ {
31
+ style: {
32
+ display: "flex",
33
+ width: "100%",
34
+ height: "100%",
35
+ alignItems: "center",
36
+ justifyContent: "center",
37
+ fontFamily: "Inter",
38
+ fontSize: 48,
39
+ color: "#0f172a",
40
+ background: "#ffffff",
41
+ },
42
+ },
43
+ "Hello OG",
44
+ );
45
+ const png = await renderOgImage(el, { width: 600, height: 400 });
46
+ expect(isPng(png)).toBe(true);
47
+ expect(png.byteLength).toBeGreaterThan(1000);
48
+ expect(pngSize(png)).toEqual({ w: 600, h: 400 });
49
+ });
50
+
51
+ test("defaults to 1200×630 when no size is given", async () => {
52
+ const el = React.createElement(
53
+ "div",
54
+ { style: { display: "flex", fontFamily: "Inter", fontSize: 40 } },
55
+ "Default size",
56
+ );
57
+ const png = await renderOgImage(el);
58
+ expect(pngSize(png)).toEqual({ w: 1200, h: 630 });
59
+ });
60
+ });
61
+
62
+ describe("handleOgImageRoute (end-to-end import + render)", () => {
63
+ const baseDir = path.dirname(fileURLToPath(import.meta.url));
64
+ const tmpdirs: string[] = [];
65
+ const prevCwd = process.cwd();
66
+ afterEach(() => {
67
+ process.chdir(prevCwd);
68
+ for (const d of tmpdirs.splice(0)) {
69
+ try {
70
+ fs.rmSync(d, { recursive: true, force: true });
71
+ } catch {
72
+ /* best effort */
73
+ }
74
+ }
75
+ });
76
+
77
+ function fixture(src: string): void {
78
+ const dir = fs.mkdtempSync(path.join(baseDir, "og-fixture-"));
79
+ tmpdirs.push(dir);
80
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
81
+ fs.writeFileSync(path.join(dir, "app", "opengraph-image.tsx"), src);
82
+ process.chdir(dir);
83
+ }
84
+
85
+ const collect = async (): Promise<any[]> => {
86
+ const sent: any[] = [];
87
+ const msg = {
88
+ component: "app/opengraph-image",
89
+ call_id: "og1",
90
+ params: {},
91
+ search_params: {},
92
+ } as unknown as RenderRouteMessage;
93
+ await handleOgImageRoute(msg, (m) => sent.push(m));
94
+ return sent;
95
+ };
96
+
97
+ test("ImageResponse default export → 200 image/png with the rendered bytes", async () => {
98
+ // The fixture returns the exact shape `new ImageResponse(el, opts)`
99
+ // produces — the `__pylonImageResponse` brand + `element`/`options` — so
100
+ // this asserts the runtime's structural contract without pulling the
101
+ // whole @pylonsync/react barrel into a temp module. The real
102
+ // `ImageResponse` integration is covered by the live acme e2e.
103
+ fixture(
104
+ `import React from "react";
105
+ export default function OG() {
106
+ return {
107
+ __pylonImageResponse: true,
108
+ element: React.createElement("div",
109
+ { style: { display: "flex", width: "100%", height: "100%", fontSize: 40, fontFamily: "Inter" } },
110
+ "Branded"),
111
+ options: { width: 500, height: 300, headers: { "cache-control": "public, max-age=60" } },
112
+ };
113
+ }`,
114
+ );
115
+ const sent = await collect();
116
+ const start = sent.find((m) => m.type === "response_start");
117
+ const chunk = sent.find((m) => m.type === "render_chunk");
118
+ expect(start.status).toBe(200);
119
+ expect(start.headers["content-type"]).toBe("image/png");
120
+ // options.headers override the default cache-control.
121
+ expect(start.headers["cache-control"]).toBe("public, max-age=60");
122
+ const png = new Uint8Array(Buffer.from(chunk.data, "base64"));
123
+ expect(isPng(png)).toBe(true);
124
+ expect(pngSize(png)).toEqual({ w: 500, h: 300 });
125
+ expect(sent.some((m) => m.type === "render_done")).toBe(true);
126
+ });
127
+
128
+ test("bare React element + `export const size` also renders", async () => {
129
+ fixture(
130
+ `import React from "react";
131
+ export const size = { width: 640, height: 360 };
132
+ export default function OG() {
133
+ return React.createElement("div",
134
+ { style: { display: "flex", fontSize: 32, fontFamily: "Inter" } }, "Bare");
135
+ }`,
136
+ );
137
+ const sent = await collect();
138
+ const start = sent.find((m) => m.type === "response_start");
139
+ const png = new Uint8Array(
140
+ Buffer.from(sent.find((m) => m.type === "render_chunk").data, "base64"),
141
+ );
142
+ expect(start.status).toBe(200);
143
+ expect(start.headers["content-type"]).toBe("image/png");
144
+ // default cache-control applies when the module set none.
145
+ expect(start.headers["cache-control"]).toContain("public");
146
+ expect(pngSize(png)).toEqual({ w: 640, h: 360 });
147
+ });
148
+
149
+ test("a throwing opengraph-image surfaces as 500 text/plain (no wedge)", async () => {
150
+ fixture(
151
+ `export default function OG() { throw new Error("boom"); }`,
152
+ );
153
+ const sent = await collect();
154
+ const start = sent.find((m) => m.type === "response_start");
155
+ const body = Buffer.from(
156
+ sent.find((m) => m.type === "render_chunk").data,
157
+ "base64",
158
+ ).toString("utf8");
159
+ expect(start.status).toBe(500);
160
+ expect(start.headers["content-type"]).toContain("text/plain");
161
+ expect(body).toContain("boom");
162
+ expect(sent.some((m) => m.type === "render_done")).toBe(true);
163
+ });
164
+ });
@@ -0,0 +1,137 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Dynamic OpenGraph image rendering — the JSX → PNG pipeline.
3
+ //
4
+ // This is the server-only engine behind the `opengraph-image.tsx` file
5
+ // convention (Next.js `next/og` parity). A user module default-exports a
6
+ // function returning an `ImageResponse` (or a raw React element); the SSR
7
+ // runner calls it, hands the element here, and we:
8
+ //
9
+ // 1. Satori renders the React element tree to an SVG string. Satori is a
10
+ // flexbox-only layout engine — multi-child nodes need explicit
11
+ // `display: flex` (documented; same constraint as next/og).
12
+ // 2. resvg (WASM) rasterizes the SVG to a PNG.
13
+ //
14
+ // Both deps resolve from the FRAMEWORK's node_modules (this file lives under
15
+ // /pylon/packages/functions/src, so a bare import walks up to /pylon/
16
+ // node_modules) — deliberately NOT the user cwd (unlike react/react-dom).
17
+ // resvg is the WASM build (`@resvg/resvg-wasm`); the native napi build is
18
+ // unreliable under Bun. WASM under Bun is proven by loro-crdt in the same
19
+ // runtime.
20
+ //
21
+ // Fonts: Satori CANNOT synthesize text without a font buffer, and cannot
22
+ // parse woff2 or variable fonts. We bundle static Inter (400 + 600) as the
23
+ // default; apps override via `ImageResponse`'s `fonts` option.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import { readFileSync } from "node:fs";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ // Satori's font descriptor.
30
+ export interface OgFont {
31
+ name: string;
32
+ data: ArrayBuffer | Buffer | Uint8Array;
33
+ weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
34
+ style?: "normal" | "italic";
35
+ }
36
+
37
+ export interface RenderOgOptions {
38
+ width?: number;
39
+ height?: number;
40
+ /** Extra/override fonts. When omitted, the bundled Inter (400+600) is used. */
41
+ fonts?: OgFont[];
42
+ /**
43
+ * Emoji provider passthrough for Satori (`graphemeImages` / `loadAdditionalAsset`).
44
+ * Left undefined by default — emoji render as tofu unless the app supplies fonts.
45
+ */
46
+ loadAdditionalAsset?: (
47
+ code: string,
48
+ text: string,
49
+ ) => Promise<string> | string;
50
+ }
51
+
52
+ const DEFAULT_WIDTH = 1200;
53
+ const DEFAULT_HEIGHT = 630;
54
+
55
+ // ---- lazy, memoized module + wasm init -----------------------------------
56
+ // satori + resvg are only pulled in when an OG render actually happens, and
57
+ // the wasm is initialized exactly once per Bun process (init is not
58
+ // re-entrant — a second initWasm throws).
59
+
60
+ let satoriMod: Promise<typeof import("satori")> | null = null;
61
+ function loadSatori() {
62
+ if (!satoriMod) satoriMod = import("satori");
63
+ return satoriMod;
64
+ }
65
+
66
+ let resvgReady: Promise<typeof import("@resvg/resvg-wasm")> | null = null;
67
+ function loadResvg() {
68
+ if (!resvgReady) {
69
+ resvgReady = (async () => {
70
+ const mod = await import("@resvg/resvg-wasm");
71
+ // Locate the wasm binary next to the package entry and init once.
72
+ const entry = fileURLToPath(import.meta.resolve("@resvg/resvg-wasm"));
73
+ const wasmPath = entry.replace(/index\.[^/]+$/, "index_bg.wasm");
74
+ await mod.initWasm(readFileSync(wasmPath));
75
+ return mod;
76
+ })();
77
+ }
78
+ return resvgReady;
79
+ }
80
+
81
+ // ---- default fonts (bundled static Inter) --------------------------------
82
+
83
+ let defaultFonts: OgFont[] | null = null;
84
+ function loadDefaultFonts(): OgFont[] {
85
+ if (defaultFonts) return defaultFonts;
86
+ const read = (rel: string) =>
87
+ readFileSync(fileURLToPath(new URL(rel, import.meta.url)));
88
+ defaultFonts = [
89
+ {
90
+ name: "Inter",
91
+ data: read("../assets/fonts/Inter-Regular.ttf"),
92
+ weight: 400,
93
+ style: "normal",
94
+ },
95
+ {
96
+ name: "Inter",
97
+ data: read("../assets/fonts/Inter-SemiBold.ttf"),
98
+ weight: 600,
99
+ style: "normal",
100
+ },
101
+ ];
102
+ return defaultFonts;
103
+ }
104
+
105
+ /**
106
+ * Render a React element to a PNG buffer via Satori + resvg.
107
+ *
108
+ * `element` is whatever the user's `opengraph-image.tsx` produced — a React
109
+ * element created by the *user's* react. Satori consumes it structurally
110
+ * (by `type`/`props`), so the react instance doesn't matter.
111
+ */
112
+ export async function renderOgImage(
113
+ element: unknown,
114
+ options: RenderOgOptions = {},
115
+ ): Promise<Uint8Array> {
116
+ const width = options.width ?? DEFAULT_WIDTH;
117
+ const height = options.height ?? DEFAULT_HEIGHT;
118
+ const fonts = options.fonts?.length ? options.fonts : loadDefaultFonts();
119
+
120
+ const { default: satori } = await loadSatori();
121
+ // Satori's types want ReactNode; the element is structurally compatible.
122
+ const svg = await satori(element as never, {
123
+ width,
124
+ height,
125
+ fonts: fonts as never,
126
+ // Our public type is friendlier (sync-or-async → string); Satori's exact
127
+ // signature is stricter but tolerant at runtime.
128
+ loadAdditionalAsset: options.loadAdditionalAsset as never,
129
+ });
130
+
131
+ const { Resvg } = await loadResvg();
132
+ const resvg = new Resvg(svg, {
133
+ fitTo: { mode: "width", value: width },
134
+ font: { loadSystemFonts: false },
135
+ });
136
+ return resvg.render().asPng();
137
+ }
@@ -816,6 +816,54 @@ function findColocatedImage(
816
816
  return null;
817
817
  }
818
818
 
819
+ const OG_IMAGE_CODE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
820
+
821
+ /**
822
+ * Walk up from a page's directory to the nearest colocated dynamic OG
823
+ * module (`opengraph-image.{tsx,ts,jsx,js}`) and return the CONCRETE
824
+ * request path that renders it (origin-less), or null.
825
+ *
826
+ * The path is derived from the live request URL (`url`), not the file path,
827
+ * so dynamic params are already substituted:
828
+ * - colocated with the page → `${url}/opengraph-image`
829
+ * - an ancestor at depth n → first n concrete URL segments + `/opengraph-image`
830
+ * (root → `/opengraph-image`). Route groups `(x)` don't count toward
831
+ * depth; a trailing catch-all only lives at the page's own segment, so
832
+ * ancestor slicing stays 1:1 with the URL.
833
+ * Mirrors `findColocatedImage`'s inheritance walk (closer file wins).
834
+ */
835
+ function findColocatedOgImageRoute(
836
+ componentPath: string,
837
+ url: string,
838
+ ): string | null {
839
+ const fs = require("node:fs");
840
+ const path = require("node:path");
841
+ const cwd = process.cwd();
842
+ let dir = componentPath.replace(/\\/g, "/");
843
+ dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
844
+ const pageDir = dir;
845
+ const urlSegs = url.split("/").filter(Boolean);
846
+ // Non-route-group segment depth of a cwd-relative dir below `app/`.
847
+ const nonGroupDepth = (d: string): number => {
848
+ const segs = d.split("/").filter(Boolean);
849
+ const appIdx = segs.indexOf("app");
850
+ const below = appIdx >= 0 ? segs.slice(appIdx + 1) : segs;
851
+ return below.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).length;
852
+ };
853
+ while (dir && dir !== "." && dir !== "/") {
854
+ for (const ext of OG_IMAGE_CODE_EXTS) {
855
+ if (fs.existsSync(path.join(cwd, dir, `opengraph-image${ext}`))) {
856
+ const prefix =
857
+ dir === pageDir ? urlSegs : urlSegs.slice(0, nonGroupDepth(dir));
858
+ return "/" + [...prefix, "opengraph-image"].join("/");
859
+ }
860
+ }
861
+ const slash = dir.lastIndexOf("/");
862
+ dir = slash >= 0 ? dir.slice(0, slash) : "";
863
+ }
864
+ return null;
865
+ }
866
+
819
867
  /** Best-effort JPEG dimensions: scan SOF markers in the first 128KB. */
820
868
  function readJpegSize(fs: any, fd: number): { w: number; h: number } | null {
821
869
  const CAP = 128 * 1024;
@@ -1090,6 +1138,7 @@ export function applyAutoSocialImages(
1090
1138
  component: string,
1091
1139
  headers: Record<string, string> | undefined,
1092
1140
  metadata: SsrMetadata | undefined,
1141
+ requestUrl?: string,
1093
1142
  ): SsrMetadata | undefined {
1094
1143
  const hasOg = !!metadata?.openGraph?.image;
1095
1144
  const hasTw = !!metadata?.twitter?.image;
@@ -1099,7 +1148,16 @@ export function applyAutoSocialImages(
1099
1148
  const twFile = hasTw
1100
1149
  ? null
1101
1150
  : findColocatedImage(component, "twitter-image") ?? ogFile;
1102
- if (!ogFile && !twFile) return metadata;
1151
+
1152
+ // Dynamic (code) OG image: `app/**/opengraph-image.{tsx,ts,jsx,js}`. Only a
1153
+ // fallback when there's no explicit metadata image and no static raster —
1154
+ // a static file colocated at the same/closer level wins (Next parity).
1155
+ const ogRoute =
1156
+ hasOg || ogFile || requestUrl == null
1157
+ ? null
1158
+ : findColocatedOgImageRoute(component, requestUrl);
1159
+
1160
+ if (!ogFile && !twFile && !ogRoute) return metadata;
1103
1161
 
1104
1162
  const origin = resolveRequestOrigin(headers);
1105
1163
  const urlFor = (rel: string, v: number): string =>
@@ -1117,15 +1175,34 @@ export function applyAutoSocialImages(
1117
1175
  ...(m.height ? { imageHeight: m.height } : {}),
1118
1176
  ...(url.startsWith("https:") ? { imageSecureUrl: url } : {}),
1119
1177
  };
1120
- }
1121
- if (twFile && !hasTw) {
1122
- const m = readSocialImageMeta(twFile);
1123
- out.twitter = {
1124
- card: "summary_large_image",
1125
- ...(out.twitter ?? {}),
1126
- image: urlFor(twFile, m.v),
1178
+ } else if (ogRoute && !hasOg) {
1179
+ const url = `${origin}${ogRoute}`;
1180
+ out.openGraph = {
1181
+ ...(out.openGraph ?? {}),
1182
+ image: url,
1183
+ imageType: "image/png",
1184
+ imageWidth: 1200,
1185
+ imageHeight: 630,
1186
+ ...(url.startsWith("https:") ? { imageSecureUrl: url } : {}),
1127
1187
  };
1128
1188
  }
1189
+ // Twitter falls back to the OG image (static file first, then dynamic route).
1190
+ if (!hasTw) {
1191
+ if (twFile) {
1192
+ const m = readSocialImageMeta(twFile);
1193
+ out.twitter = {
1194
+ card: "summary_large_image",
1195
+ ...(out.twitter ?? {}),
1196
+ image: urlFor(twFile, m.v),
1197
+ };
1198
+ } else if (ogRoute) {
1199
+ out.twitter = {
1200
+ card: "summary_large_image",
1201
+ ...(out.twitter ?? {}),
1202
+ image: `${origin}${ogRoute}`,
1203
+ };
1204
+ }
1205
+ }
1129
1206
  return out;
1130
1207
  }
1131
1208
 
@@ -2404,6 +2481,106 @@ export async function handleDataRoute(
2404
2481
  }
2405
2482
  }
2406
2483
 
2484
+ /**
2485
+ * Import an `opengraph-image.tsx`, call its default export, render the
2486
+ * returned `ImageResponse` (or raw React element) to a PNG via Satori +
2487
+ * resvg (see `./ssr-og-runtime`), and stream the bytes back with an image
2488
+ * content-type. This is the code path behind the `opengraph-image` file
2489
+ * convention (Next.js `next/og` parity). Errors surface as a 500 with a
2490
+ * short plain-text body so a broken OG module doesn't wedge the runner.
2491
+ */
2492
+ export async function handleOgImageRoute(
2493
+ msg: RenderRouteMessage,
2494
+ send: Send,
2495
+ ): Promise<void> {
2496
+ const emitError = (status: number, body: string): void => {
2497
+ send({
2498
+ type: "response_start",
2499
+ call_id: msg.call_id,
2500
+ status,
2501
+ headers: { "content-type": "text/plain; charset=utf-8" },
2502
+ });
2503
+ send({
2504
+ type: "render_chunk",
2505
+ call_id: msg.call_id,
2506
+ data: Buffer.from(body, "utf8").toString("base64"),
2507
+ });
2508
+ send({ type: "render_done", call_id: msg.call_id });
2509
+ };
2510
+ try {
2511
+ const cwd = process.cwd();
2512
+ const mod = await importModule(cwd, msg.component);
2513
+ const exp = mod.default ?? mod.Image ?? mod.image;
2514
+ if (exp == null) {
2515
+ throw new Error(
2516
+ `opengraph-image module "${msg.component}" has no default export`,
2517
+ );
2518
+ }
2519
+ // Next passes `{ params }`; we also hand `searchParams` for query-driven
2520
+ // cards. A non-function default (a pre-built element/response) is used
2521
+ // as-is.
2522
+ const produced =
2523
+ typeof exp === "function"
2524
+ ? await exp({ params: msg.params, searchParams: msg.search_params })
2525
+ : exp;
2526
+
2527
+ // The default export may return an `ImageResponse` (element + options)
2528
+ // or a bare React element. Detect the former structurally (brand set by
2529
+ // @pylonsync/react — no cross-package import / instanceof needed).
2530
+ const isImageResponse =
2531
+ produced != null &&
2532
+ typeof produced === "object" &&
2533
+ (produced as { __pylonImageResponse?: unknown }).__pylonImageResponse ===
2534
+ true;
2535
+ const element = isImageResponse
2536
+ ? (produced as { element: unknown }).element
2537
+ : produced;
2538
+ const options = isImageResponse
2539
+ ? ((produced as { options?: Record<string, any> }).options ?? {})
2540
+ : {};
2541
+
2542
+ // For a bare-element return, honor the Next-style `export const size` /
2543
+ // `export const contentType` module exports.
2544
+ const width = options.width ?? mod.size?.width;
2545
+ const height = options.height ?? mod.size?.height;
2546
+ const contentType =
2547
+ options.contentType ?? mod.contentType ?? "image/png";
2548
+
2549
+ const { renderOgImage } = await import("./ssr-og-runtime");
2550
+ const png = await renderOgImage(element, {
2551
+ width,
2552
+ height,
2553
+ fonts: options.fonts,
2554
+ loadAdditionalAsset: options.loadAdditionalAsset,
2555
+ });
2556
+
2557
+ const headers: Record<string, string> = {
2558
+ "content-type": contentType,
2559
+ // OG cards are expensive to render and change rarely; cache hard at
2560
+ // the CDN. The on-disk ISR layer (serve_via_ssr_rpc) also keys by URL.
2561
+ "cache-control": "public, max-age=3600, s-maxage=86400",
2562
+ ...(options.headers ?? {}),
2563
+ };
2564
+ send({
2565
+ type: "response_start",
2566
+ call_id: msg.call_id,
2567
+ status: 200,
2568
+ headers,
2569
+ });
2570
+ send({
2571
+ type: "render_chunk",
2572
+ call_id: msg.call_id,
2573
+ data: Buffer.from(png).toString("base64"),
2574
+ });
2575
+ send({ type: "render_done", call_id: msg.call_id });
2576
+ } catch (e: any) {
2577
+ emitError(
2578
+ 500,
2579
+ `pylon: failed to render opengraph-image (${msg.component}): ${e?.message ?? String(e)}\n`,
2580
+ );
2581
+ }
2582
+ }
2583
+
2407
2584
  export async function handleRenderRoute(
2408
2585
  msg: RenderRouteMessage,
2409
2586
  send: Send,
@@ -2423,6 +2600,13 @@ export async function handleRenderRoute(
2423
2600
  : null;
2424
2601
  if (dataKind) return handleDataRoute(msg, dataKind, send);
2425
2602
 
2603
+ // `opengraph-image` (and future `twitter-image`) render to a PNG, not
2604
+ // HTML. Same basename-detection contract: the SDK only registers this
2605
+ // component path for app/**/opengraph-image.*.
2606
+ if (/(^|[\\/])opengraph-image$/.test(msg.component)) {
2607
+ return handleOgImageRoute(msg, send);
2608
+ }
2609
+
2426
2610
  // Dev HUD: wall-clock for the whole render, surfaced in the dev overlay's
2427
2611
  // render row. `performance.now()` is monotonic; harmless in prod (unused).
2428
2612
  const renderStart = isDevMode() ? performance.now() : 0;
@@ -2619,7 +2803,7 @@ export async function handleRenderRoute(
2619
2803
  // File conventions: auto-wire <meta og:image>/<twitter:image> from a
2620
2804
  // colocated opengraph-image.* / twitter-image.*, and <link rel="icon">
2621
2805
  // from icon.* / apple-icon.* / favicon.ico — unless the page set them.
2622
- metadata = applyAutoSocialImages(msg.component, msg.headers, metadata);
2806
+ metadata = applyAutoSocialImages(msg.component, msg.headers, metadata, msg.url);
2623
2807
  metadata = applyAutoIcons(msg.component, metadata);
2624
2808
  const metaFragment = renderMetadata(React, metadata);
2625
2809