@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.
- package/dist/ssr-og-runtime.d.ts +25 -0
- package/dist/ssr-runtime.d.ts +10 -1
- package/package.json +5 -1
- package/src/ssr-og-image.test.ts +164 -0
- package/src/ssr-og-runtime.ts +137 -0
- package/src/ssr-runtime.ts +193 -9
|
@@ -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>;
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
|