@farcaster/snap-hono 1.1.8 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,59 @@
1
- const PALETTE = {
2
- gray: "#8F8F8F",
3
- blue: "#006BFF",
4
- red: "#FC0036",
5
- amber: "#FFAE00",
6
- green: "#28A948",
7
- teal: "#00AC96",
8
- purple: "#8B5CF6",
9
- pink: "#F32782",
10
- };
1
+ import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX, PALETTE_COLOR_ACCENT, } from "@farcaster/snap";
2
+ export function extractPageMeta(page) {
3
+ let title = "Farcaster Snap";
4
+ let description = "";
5
+ let imageUrl;
6
+ let imageAlt;
7
+ for (const el of page.elements.children) {
8
+ if (el.type === "text") {
9
+ const style = el.style;
10
+ const content = el.content;
11
+ if (style === "title" && title === "Farcaster Snap" && content) {
12
+ title = content;
13
+ }
14
+ else if ((style === "body" || style === "caption") &&
15
+ !description &&
16
+ content) {
17
+ description = content;
18
+ }
19
+ }
20
+ if (el.type === "image" && !imageUrl) {
21
+ imageUrl = el.url;
22
+ imageAlt = el.alt;
23
+ }
24
+ }
25
+ return {
26
+ title,
27
+ description: description || title,
28
+ imageUrl,
29
+ imageAlt,
30
+ };
31
+ }
32
+ function buildOgMeta(opts) {
33
+ const { title, description, pageUrl, ogImageUrl, imageAlt, siteName } = opts;
34
+ const imgUrl = ogImageUrl ?? undefined;
35
+ const twitterCard = imgUrl ? "summary_large_image" : "summary";
36
+ const lines = [
37
+ `<meta name="description" content="${esc(description)}">`,
38
+ `<meta property="og:title" content="${esc(title)}">`,
39
+ `<meta property="og:description" content="${esc(description)}">`,
40
+ `<meta property="og:url" content="${esc(pageUrl)}">`,
41
+ `<meta property="og:type" content="website">`,
42
+ `<meta property="og:locale" content="en_US">`,
43
+ ];
44
+ if (siteName) {
45
+ lines.push(`<meta property="og:site_name" content="${esc(siteName)}">`);
46
+ }
47
+ if (imgUrl) {
48
+ lines.push(`<meta property="og:image" content="${esc(imgUrl)}">`);
49
+ lines.push(`<meta property="og:image:alt" content="${esc(imageAlt ?? title)}">`);
50
+ }
51
+ lines.push(`<meta name="twitter:card" content="${twitterCard}">`, `<meta name="twitter:title" content="${esc(title)}">`, `<meta name="twitter:description" content="${esc(description)}">`);
52
+ if (imgUrl) {
53
+ lines.push(`<meta name="twitter:image" content="${esc(imgUrl)}">`);
54
+ }
55
+ return lines.join("\n");
56
+ }
11
57
  const FC_ICON = `<svg viewBox="0 0 520 457" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M519.801 0V61.6809H458.172V123.31H477.054V123.331H519.801V456.795H416.57L416.507 456.49L363.832 207.03C358.81 183.251 345.667 161.736 326.827 146.434C307.988 131.133 284.255 122.71 260.006 122.71H259.8C235.551 122.71 211.818 131.133 192.979 146.434C174.139 161.736 160.996 183.259 155.974 207.03L103.239 456.795H0V123.323H42.7471V123.31H61.6262V61.6809H0V0H519.801Z" fill="currentColor"/></svg>`;
12
58
  function esc(s) {
13
59
  return s
@@ -17,17 +63,18 @@ function esc(s) {
17
63
  .replace(/"/g, "&quot;");
18
64
  }
19
65
  function accentHex(accent) {
20
- return PALETTE[accent ?? "purple"] ?? PALETTE.purple;
66
+ return accent && PALETTE_LIGHT_HEX[accent]
67
+ ? PALETTE_LIGHT_HEX[accent]
68
+ : PALETTE_LIGHT_HEX[DEFAULT_THEME_ACCENT];
21
69
  }
22
70
  function colorHex(color, accent) {
23
- if (!color || color === "accent")
71
+ if (!color || color === PALETTE_COLOR_ACCENT)
24
72
  return accent;
25
- return PALETTE[color] ?? accent;
73
+ return PALETTE_LIGHT_HEX[color] ?? accent;
26
74
  }
27
75
  // ─── Element renderers ──────────────────────────────────
28
76
  function renderElement(el, accent) {
29
- const type = el.type;
30
- switch (type) {
77
+ switch (el.type) {
31
78
  case "text":
32
79
  return renderText(el, accent);
33
80
  case "image":
@@ -52,14 +99,8 @@ function renderElement(el, accent) {
52
99
  return renderGroup(el, accent);
53
100
  case "divider":
54
101
  return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
55
- case "spacer": {
56
- const sizes = {
57
- small: "8px",
58
- medium: "16px",
59
- large: "24px",
60
- };
61
- return `<div style="height:${sizes[el.size ?? "medium"] ?? "16px"}"></div>`;
62
- }
102
+ case "spacer":
103
+ return renderSpacer(el);
63
104
  default:
64
105
  return "";
65
106
  }
@@ -84,9 +125,7 @@ function renderImage(el) {
84
125
  return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(el.alt ?? "")}" style="width:100%;height:100%;object-fit:cover"></div>`;
85
126
  }
86
127
  function renderGrid(el) {
87
- const cols = el.cols;
88
- const rows = el.rows;
89
- const cells = el.cells;
128
+ const { cols, rows, cells } = el;
90
129
  const cellSize = el.cellSize ?? "auto";
91
130
  const gap = el.gap ?? "small";
92
131
  const gapPx = {
@@ -110,9 +149,7 @@ function renderGrid(el) {
110
149
  return `<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:${gapPx[gap] ?? "2px"}">${cellsHtml}</div>`;
111
150
  }
112
151
  function renderProgress(el, accent) {
113
- const value = el.value;
114
- const max = el.max;
115
- const label = el.label;
152
+ const { value, max, label } = el;
116
153
  const color = colorHex(el.color, accent);
117
154
  const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
118
155
  const labelHtml = label
@@ -121,12 +158,12 @@ function renderProgress(el, accent) {
121
158
  return `<div>${labelHtml}<div style="height:8px;background:#E5E7EB;border-radius:4px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${color};border-radius:4px"></div></div></div>`;
122
159
  }
123
160
  function renderBarChart(el, accent) {
124
- const bars = el.bars;
161
+ const { bars } = el;
125
162
  const max = el.max ?? Math.max(...bars.map((b) => b.value), 1);
126
163
  const defaultColor = colorHex(el.color, accent);
127
164
  let html = `<div style="display:flex;align-items:flex-end;gap:12px;height:120px">`;
128
165
  for (const bar of bars) {
129
- const color = bar.color ? PALETTE[bar.color] ?? defaultColor : defaultColor;
166
+ const color = colorHex(bar.color, defaultColor);
130
167
  const pct = max > 0 ? (bar.value / max) * 100 : 0;
131
168
  html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end">`;
132
169
  html += `<div style="font-size:11px;color:#6B7280;margin-bottom:4px">${bar.value}</div>`;
@@ -139,7 +176,7 @@ function renderBarChart(el, accent) {
139
176
  }
140
177
  function renderList(el) {
141
178
  const style = el.style ?? "ordered";
142
- const items = el.items;
179
+ const { items } = el;
143
180
  let html = "";
144
181
  for (let i = 0; i < items.length; i++) {
145
182
  const item = items[i];
@@ -156,7 +193,7 @@ function renderList(el) {
156
193
  return `<div>${html}</div>`;
157
194
  }
158
195
  function renderButtonGroup(el, accent) {
159
- const options = el.options;
196
+ const { options } = el;
160
197
  const layout = el.style ?? "row";
161
198
  const dir = layout === "stack" ? "column" : "row";
162
199
  let html = `<div style="display:flex;flex-direction:${dir};gap:8px">`;
@@ -167,12 +204,8 @@ function renderButtonGroup(el, accent) {
167
204
  return html;
168
205
  }
169
206
  function renderSlider(el, accent) {
170
- const label = el.label;
171
- const min = el.min;
172
- const max = el.max;
207
+ const { label, min, max, minLabel, maxLabel } = el;
173
208
  const value = el.value ?? (min + max) / 2;
174
- const minLabel = el.minLabel;
175
- const maxLabel = el.maxLabel;
176
209
  const labelHtml = label
177
210
  ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(label)}</div>`
178
211
  : "";
@@ -190,7 +223,7 @@ function renderTextInput(el) {
190
223
  }
191
224
  function renderToggle(el, accent) {
192
225
  const label = esc(el.label);
193
- const value = el.value;
226
+ const { value } = el;
194
227
  const bg = value ? accent : "#D1D5DB";
195
228
  const tx = value ? "20px" : "2px";
196
229
  return `<div style="display:flex;align-items:center;justify-content:space-between">
@@ -198,10 +231,17 @@ function renderToggle(el, accent) {
198
231
  <div style="width:44px;height:24px;background:${bg};border-radius:12px;position:relative;opacity:0.7"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx};transition:left .2s"></div></div>
199
232
  </div>`;
200
233
  }
234
+ function renderSpacer(el) {
235
+ const sizes = {
236
+ small: "8px",
237
+ medium: "16px",
238
+ large: "24px",
239
+ };
240
+ return `<div style="height:${sizes[el.size ?? "medium"] ?? "16px"}"></div>`;
241
+ }
201
242
  function renderGroup(el, accent) {
202
- const children = el.children;
203
243
  let html = `<div style="display:flex;gap:12px">`;
204
- for (const child of children) {
244
+ for (const child of el.children) {
205
245
  html += `<div style="flex:1">${renderElement(child, accent)}</div>`;
206
246
  }
207
247
  html += `</div>`;
@@ -229,18 +269,29 @@ function renderButtons(buttons, layout, accent) {
229
269
  const bg = style === "primary" ? accent : "transparent";
230
270
  const color = style === "primary" ? "#fff" : accent;
231
271
  const border = style === "primary" ? "none" : `2px solid ${accent}`;
232
- html += `<button onclick="showModal()" style="flex:1;padding:10px 16px;border-radius:10px;background:${bg};color:${color};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit">${label}</button>`;
272
+ const pad = style === "primary" ? "18px 16px" : "10px 16px";
273
+ const minH = style === "primary" ? "min-height:52px;" : "";
274
+ html += `<button onclick="showModal()" style="flex:1;${minH}padding:${pad};border-radius:10px;background:${bg};color:${color};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;box-sizing:border-box">${label}</button>`;
233
275
  }
234
276
  html += `</div>`;
235
277
  return html;
236
278
  }
237
279
  // ─── Main renderer ──────────────────────────────────────
238
- export function renderSnapPage(snap, snapOrigin) {
280
+ export function renderSnapPage(snap, snapOrigin, opts) {
239
281
  const page = snap.page;
240
282
  const accent = accentHex(page.theme?.accent);
241
- // Extract title for <title> tag
242
- const titleEl = page.elements.children.find((el) => el.type === "text" && el.style === "title");
243
- const pageTitle = titleEl ? esc(titleEl.content) : "Farcaster Snap";
283
+ const meta = extractPageMeta(page);
284
+ const pageTitle = esc(meta.title);
285
+ const resourcePath = opts?.resourcePath ?? "/";
286
+ const pageUrl = snapOrigin.replace(/\/$/, "") + resourcePath;
287
+ const ogMeta = buildOgMeta({
288
+ title: meta.title,
289
+ description: meta.description,
290
+ pageUrl,
291
+ ogImageUrl: opts?.ogImageUrl,
292
+ imageAlt: meta.imageAlt ?? meta.imageUrl ? meta.title : undefined,
293
+ siteName: opts?.siteName,
294
+ });
244
295
  const snapUrl = encodeURIComponent(snapOrigin + "/");
245
296
  // Render elements
246
297
  let elementsHtml = "";
@@ -255,6 +306,7 @@ export function renderSnapPage(snap, snapOrigin) {
255
306
  <meta charset="utf-8">
256
307
  <meta name="viewport" content="width=device-width, initial-scale=1">
257
308
  <title>${pageTitle}</title>
309
+ ${ogMeta}
258
310
  <style>
259
311
  *{margin:0;padding:0;box-sizing:border-box}
260
312
  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0A0A0A;min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:24px}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap-hono",
3
- "version": "1.1.8",
3
+ "version": "1.2.1",
4
4
  "description": "Hono integration for Farcaster Snap servers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,9 @@
26
26
  },
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@farcaster/snap": "1.3.3"
29
+ "@resvg/resvg-wasm": "^2.6.2",
30
+ "satori": "^0.10.0",
31
+ "@farcaster/snap": "1.4.1"
30
32
  },
31
33
  "peerDependencies": {
32
34
  "hono": ">=4.0.0"
@@ -0,0 +1,81 @@
1
+ const FARCASTER_ICON_SVG = `<svg aria-hidden="true" focusable="false" viewBox="0 0 520 457" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M519.801 0V61.6809H458.172V123.31H477.054V123.331H519.801V456.795H416.57L416.507 456.49L363.832 207.03C358.81 183.251 345.667 161.736 326.827 146.434C307.988 131.133 284.255 122.71 260.006 122.71H259.8C235.551 122.71 211.818 131.133 192.979 146.434C174.139 161.736 160.996 183.259 155.974 207.03L103.239 456.795H0V123.323H42.7471V123.31H61.6262V61.6809H0V0H519.801Z" fill="currentColor"/></svg>`;
2
+
3
+ export function brandedFallbackHtml(
4
+ snapOrigin: string,
5
+ og?: { ogImageUrl?: string; resourcePath?: string; siteName?: string },
6
+ ): string {
7
+ const snapUrl = encodeURIComponent(snapOrigin + "/");
8
+ const testUrl = `https://farcaster.xyz/~/developers/snaps?url=${snapUrl}`;
9
+ const pageUrl = snapOrigin + (og?.resourcePath ?? "/");
10
+
11
+ const ogLines = [
12
+ `<meta name="description" content="An interactive embed for Farcaster.">`,
13
+ `<meta property="og:title" content="Farcaster Snap">`,
14
+ `<meta property="og:description" content="An interactive embed for Farcaster.">`,
15
+ `<meta property="og:url" content="${escHtml(pageUrl)}">`,
16
+ `<meta property="og:type" content="website">`,
17
+ `<meta property="og:locale" content="en_US">`,
18
+ ];
19
+ if (og?.siteName) {
20
+ ogLines.push(
21
+ `<meta property="og:site_name" content="${escHtml(og.siteName)}">`,
22
+ );
23
+ }
24
+ if (og?.ogImageUrl) {
25
+ ogLines.push(
26
+ `<meta property="og:image" content="${escHtml(og.ogImageUrl)}">`,
27
+ `<meta name="twitter:image" content="${escHtml(og.ogImageUrl)}">`,
28
+ `<meta name="twitter:card" content="summary_large_image">`,
29
+ );
30
+ } else {
31
+ ogLines.push(`<meta name="twitter:card" content="summary">`);
32
+ }
33
+ ogLines.push(
34
+ `<meta name="twitter:title" content="Farcaster Snap">`,
35
+ `<meta name="twitter:description" content="An interactive embed for Farcaster.">`,
36
+ );
37
+
38
+ return `<!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1">
43
+ <title>Farcaster Snap</title>
44
+ ${ogLines.join("\n")}
45
+ <style>
46
+ *{margin:0;padding:0;box-sizing:border-box}
47
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0A0A0A;color:#FAFAFA;min-height:100vh;display:flex;align-items:center;justify-content:center}
48
+ .c{text-align:center;max-width:400px;padding:48px 32px}
49
+ .logo{color:#8B5CF6;margin-bottom:24px}
50
+ .logo svg{width:48px;height:42px}
51
+ h1{font-size:24px;font-weight:700;margin-bottom:8px}
52
+ p{color:#A1A1AA;font-size:15px;line-height:1.5;margin-bottom:32px}
53
+ .btns{display:flex;flex-direction:column;gap:12px}
54
+ a{display:flex;align-items:center;justify-content:center;gap:8px;padding:12px 24px;border-radius:12px;font-size:15px;font-weight:600;text-decoration:none;transition:opacity .15s}
55
+ a:hover{opacity:.85}
56
+ .p{background:#8B5CF6;color:#fff}
57
+ .s{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
58
+ .s svg{width:20px;height:18px}
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <div class="c">
63
+ <div class="logo">${FARCASTER_ICON_SVG}</div>
64
+ <h1>Farcaster Snap</h1>
65
+ <p>This is a Farcaster Snap &mdash; an interactive embed that lives in the feed.</p>
66
+ <div class="btns">
67
+ <a href="${testUrl}" class="p">Test this snap</a>
68
+ <a href="https://farcaster.xyz" class="s">${FARCASTER_ICON_SVG} Sign up for Farcaster</a>
69
+ </div>
70
+ </div>
71
+ </body>
72
+ </html>`;
73
+ }
74
+
75
+ function escHtml(s: string): string {
76
+ return s
77
+ .replace(/&/g, "&amp;")
78
+ .replace(/</g, "&lt;")
79
+ .replace(/>/g, "&gt;")
80
+ .replace(/"/g, "&quot;");
81
+ }
package/src/index.ts CHANGED
@@ -2,8 +2,15 @@ import type { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
3
  import { MEDIA_TYPE, type SnapFunction } from "@farcaster/snap";
4
4
  import { parseRequest } from "@farcaster/snap/server";
5
+ import { brandedFallbackHtml } from "./fallback";
5
6
  import { payloadToResponse, snapHeaders } from "./payloadToResponse";
6
7
  import { renderSnapPage } from "./renderSnapPage";
8
+ import {
9
+ renderSnapPageToPng,
10
+ renderWithDedup,
11
+ etagForPage,
12
+ type OgOptions,
13
+ } from "./og-image";
7
14
 
8
15
  export type SnapHandlerOptions = {
9
16
  /**
@@ -23,17 +30,24 @@ export type SnapHandlerOptions = {
23
30
  * over the default branded fallback.
24
31
  */
25
32
  fallbackHtml?: string;
33
+
34
+ /**
35
+ * Open Graph configuration. Set to `false` to disable OG tag injection and
36
+ * the `/~/og-image` route. Pass an `OgOptions` object to customize rendering.
37
+ * @default true
38
+ */
39
+ og?: boolean | OgOptions;
26
40
  };
27
41
 
28
42
  /**
29
43
  * Register GET and POST snap handlers on `app` at `options.path` (default `/`).
30
44
  *
31
- * - GET → calls `snapFn({ action: { type: "get" }, request })` and returns the response.
45
+ * - GET → calls `snapFn(ctx)` with `ctx.action.type === "get"` and returns the response.
32
46
  * - POST → parses the JFS-shaped JSON body; verifies it via {@link verifyJFSRequestBody} unless
33
- * `skipJFSVerification` is true, then calls `snapFn({ action, request })` and returns the response.
47
+ * `skipJFSVerification` is true, then calls `snapFn(ctx)` with the parsed post action and returns the response.
34
48
  *
35
49
  * All parsing, schema validation, signature verification, and error responses
36
- * are handled automatically. `SnapContext.request` is the raw `Request` so handlers
50
+ * are handled automatically. `ctx.request` is the raw `Request` so handlers
37
51
  * can read query params, headers, or the URL when needed.
38
52
  */
39
53
  export function registerSnapHandler(
@@ -42,15 +56,99 @@ export function registerSnapHandler(
42
56
  options: SnapHandlerOptions = {},
43
57
  ): void {
44
58
  const path = options.path ?? "/";
59
+ const ogEnabled = options.og !== false;
60
+ const ogOptions =
61
+ typeof options.og === "object" ? options.og : ogEnabled ? {} : undefined;
45
62
 
46
63
  app.use(path, cors({ origin: "*" }));
47
64
 
65
+ // ─── /~/og-image PNG route ────────────────────────────
66
+ if (ogEnabled && ogOptions) {
67
+ const imgPath = ogImagePath(path);
68
+
69
+ app.get(imgPath, async (c) => {
70
+ const resourcePath = resourcePathFromRequest(c.req.url);
71
+ const key = resourcePath;
72
+
73
+ const renderFn = async (): Promise<{ png: Uint8Array; etag: string }> => {
74
+ const snap = await snapFn({
75
+ action: { type: "get" },
76
+ request: stripAuthHeaders(c.req.raw),
77
+ });
78
+ const snapJson = JSON.stringify(snap);
79
+ const etag = etagForPage(snapJson);
80
+ const t0 = Date.now();
81
+ const png = await renderSnapPageToPng(snap, ogOptions);
82
+ const elapsed = Date.now() - t0;
83
+ return { png, etag, elapsed } as { png: Uint8Array; etag: string } & {
84
+ elapsed: number;
85
+ };
86
+ };
87
+
88
+ try {
89
+ // Adapter cache check
90
+ const adapter = ogOptions.cache;
91
+ if (adapter) {
92
+ const hit = await adapter.get(key);
93
+ if (hit) {
94
+ return new Response(hit.png as BodyInit, {
95
+ status: 200,
96
+ headers: {
97
+ "Content-Type": "image/png",
98
+ ETag: hit.etag,
99
+ "X-OG-Cache": "HIT",
100
+ ...ogCacheHeaders(ogOptions),
101
+ },
102
+ });
103
+ }
104
+ }
105
+
106
+ const result = await renderWithDedup(key, async () => {
107
+ const r = await renderFn();
108
+ return r;
109
+ });
110
+
111
+ const { png, etag } = result;
112
+ const elapsed = (result as typeof result & { elapsed?: number })
113
+ .elapsed;
114
+
115
+ if (adapter) {
116
+ await adapter
117
+ .set(key, { png, etag }, ogOptions.cdnMaxAge ?? 86400)
118
+ .catch(() => undefined);
119
+ }
120
+
121
+ return new Response(png as BodyInit, {
122
+ status: 200,
123
+ headers: {
124
+ "Content-Type": "image/png",
125
+ ETag: etag,
126
+ "X-OG-Cache": "MISS",
127
+ ...(elapsed != null ? { "X-OG-Render-Ms": String(elapsed) } : {}),
128
+ ...ogCacheHeaders(ogOptions),
129
+ },
130
+ });
131
+ } catch {
132
+ return new Response(null, {
133
+ status: 500,
134
+ headers: { "Cache-Control": "no-store" },
135
+ });
136
+ }
137
+ });
138
+ }
139
+
140
+ // ─── Main snap route ───────────────────────────────────
48
141
  app.get(path, async (c) => {
49
142
  const resourcePath = resourcePathFromRequest(c.req.url);
50
143
  const accept = c.req.header("Accept");
51
144
  if (!clientWantsSnapResponse(accept)) {
52
145
  const fallbackHtml =
53
- options.fallbackHtml ?? (await getFallbackHtml(c.req.raw, snapFn));
146
+ options.fallbackHtml ??
147
+ (await getFallbackHtml(
148
+ c.req.raw,
149
+ snapFn,
150
+ ogEnabled ? buildOgImageUrl(c.req.raw, path) : undefined,
151
+ ));
54
152
  return new Response(fallbackHtml, {
55
153
  status: 200,
56
154
  headers: snapHeaders(resourcePath, "text/html", [
@@ -112,6 +210,35 @@ export function registerSnapHandler(
112
210
  });
113
211
  }
114
212
 
213
+ // ─── Helpers ──────────────────────────────────────────────
214
+
215
+ function ogImagePath(snapPath: string): string {
216
+ const p = snapPath.replace(/\/+$/, "") || "/";
217
+ return p === "/" ? "/~/og-image" : `${p}/~/og-image`;
218
+ }
219
+
220
+ function buildOgImageUrl(request: Request, snapPath: string): string {
221
+ const origin = snapOriginFromRequest(request);
222
+ return origin + ogImagePath(snapPath);
223
+ }
224
+
225
+ function ogCacheHeaders(
226
+ opts: import("./og-image").OgOptions,
227
+ ): Record<string, string> {
228
+ const cdnMaxAge = opts.cdnMaxAge ?? 86400;
229
+ const browserMaxAge = opts.browserMaxAge ?? 60;
230
+ return {
231
+ "Cache-Control": `public, max-age=${browserMaxAge}, s-maxage=${cdnMaxAge}, stale-while-revalidate=604800`,
232
+ };
233
+ }
234
+
235
+ function stripAuthHeaders(request: Request): Request {
236
+ const headers = new Headers(request.headers);
237
+ headers.delete("cookie");
238
+ headers.delete("authorization");
239
+ return new Request(request.url, { method: request.method, headers });
240
+ }
241
+
115
242
  function resourcePathFromRequest(url: string): string {
116
243
  const u = new URL(url);
117
244
  return u.pathname + u.search;
@@ -120,20 +247,26 @@ function resourcePathFromRequest(url: string): string {
120
247
  async function getFallbackHtml(
121
248
  request: Request,
122
249
  snapFn: SnapFunction,
250
+ ogImageUrl?: string,
123
251
  ): Promise<string> {
252
+ const origin = snapOriginFromRequest(request);
253
+ const siteName =
254
+ process.env.SNAP_OG_SITE_NAME?.trim() ||
255
+ process.env.OG_SITE_NAME?.trim() ||
256
+ undefined;
257
+ const resourcePath = resourcePathFromRequest(request.url);
258
+
124
259
  try {
125
260
  const snap = await snapFn({
126
261
  action: { type: "get" },
127
- request,
262
+ request: stripAuthHeaders(request),
128
263
  });
129
- return renderSnapPage(snap, snapOriginFromRequest(request));
264
+ return renderSnapPage(snap, origin, { ogImageUrl, resourcePath, siteName });
130
265
  } catch {
131
- return brandedFallbackHtml(snapOriginFromRequest(request));
266
+ return brandedFallbackHtml(origin, { ogImageUrl, resourcePath, siteName });
132
267
  }
133
268
  }
134
269
 
135
- const FARCASTER_ICON_SVG = `<svg aria-hidden="true" focusable="false" viewBox="0 0 520 457" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M519.801 0V61.6809H458.172V123.31H477.054V123.331H519.801V456.795H416.57L416.507 456.49L363.832 207.03C358.81 183.251 345.667 161.736 326.827 146.434C307.988 131.133 284.255 122.71 260.006 122.71H259.8C235.551 122.71 211.818 131.133 192.979 146.434C174.139 161.736 160.996 183.259 155.974 207.03L103.239 456.795H0V123.323H42.7471V123.31H61.6262V61.6809H0V0H519.801Z" fill="currentColor"/></svg>`;
136
-
137
270
  function snapOriginFromRequest(request: Request): string {
138
271
  const fromEnv = process.env.SNAP_PUBLIC_BASE_URL?.trim();
139
272
  if (fromEnv) return fromEnv.replace(/\/$/, "");
@@ -147,46 +280,6 @@ function snapOriginFromRequest(request: Request): string {
147
280
  return "https://snap.farcaster.xyz";
148
281
  }
149
282
 
150
- function brandedFallbackHtml(snapOrigin: string): string {
151
- const snapUrl = encodeURIComponent(snapOrigin + "/");
152
- const testUrl = `https://farcaster.xyz/~/developers/snaps?url=${snapUrl}`;
153
-
154
- return `<!DOCTYPE html>
155
- <html lang="en">
156
- <head>
157
- <meta charset="utf-8">
158
- <meta name="viewport" content="width=device-width, initial-scale=1">
159
- <title>Farcaster Snap</title>
160
- <style>
161
- *{margin:0;padding:0;box-sizing:border-box}
162
- body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0A0A0A;color:#FAFAFA;min-height:100vh;display:flex;align-items:center;justify-content:center}
163
- .c{text-align:center;max-width:400px;padding:48px 32px}
164
- .logo{color:#8B5CF6;margin-bottom:24px}
165
- .logo svg{width:48px;height:42px}
166
- h1{font-size:24px;font-weight:700;margin-bottom:8px}
167
- p{color:#A1A1AA;font-size:15px;line-height:1.5;margin-bottom:32px}
168
- .btns{display:flex;flex-direction:column;gap:12px}
169
- a{display:flex;align-items:center;justify-content:center;gap:8px;padding:12px 24px;border-radius:12px;font-size:15px;font-weight:600;text-decoration:none;transition:opacity .15s}
170
- a:hover{opacity:.85}
171
- .p{background:#8B5CF6;color:#fff}
172
- .s{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
173
- .s svg{width:20px;height:18px}
174
- </style>
175
- </head>
176
- <body>
177
- <div class="c">
178
- <div class="logo">${FARCASTER_ICON_SVG}</div>
179
- <h1>Farcaster Snap</h1>
180
- <p>This is a Farcaster Snap &mdash; an interactive embed that lives in the feed.</p>
181
- <div class="btns">
182
- <a href="${testUrl}" class="p">Test this snap</a>
183
- <a href="https://farcaster.xyz" class="s">${FARCASTER_ICON_SVG} Sign up for Farcaster</a>
184
- </div>
185
- </div>
186
- </body>
187
- </html>`;
188
- }
189
-
190
283
  function clientWantsSnapResponse(accept: string | undefined): boolean {
191
284
  if (!accept || accept.trim() === "") return false;
192
285
  const want = MEDIA_TYPE.toLowerCase();