@farcaster/snap-hono 1.2.0 → 1.3.0

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.2.0",
3
+ "version": "1.3.0",
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.4.0"
29
+ "@resvg/resvg-wasm": "^2.6.2",
30
+ "satori": "^0.10.0",
31
+ "@farcaster/snap": "1.5.0"
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
@@ -1,9 +1,23 @@
1
1
  import type { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
- import { MEDIA_TYPE, type SnapFunction } from "@farcaster/snap";
3
+ import {
4
+ createDefaultDataStore,
5
+ MEDIA_TYPE,
6
+ type SnapFunction,
7
+ ACTION_TYPE_GET,
8
+ } from "@farcaster/snap";
4
9
  import { parseRequest } from "@farcaster/snap/server";
10
+ import { brandedFallbackHtml } from "./fallback";
5
11
  import { payloadToResponse, snapHeaders } from "./payloadToResponse";
6
12
  import { renderSnapPage } from "./renderSnapPage";
13
+ import {
14
+ renderSnapPageToPng,
15
+ renderWithDedup,
16
+ etagForPage,
17
+ type OgOptions,
18
+ } from "./og-image";
19
+
20
+ const defaultData = createDefaultDataStore();
7
21
 
8
22
  export type SnapHandlerOptions = {
9
23
  /**
@@ -23,6 +37,13 @@ export type SnapHandlerOptions = {
23
37
  * over the default branded fallback.
24
38
  */
25
39
  fallbackHtml?: string;
40
+
41
+ /**
42
+ * Open Graph configuration. Set to `false` to disable OG tag injection and
43
+ * the `/~/og-image` route. Pass an `OgOptions` object to customize rendering.
44
+ * @default true
45
+ */
46
+ og?: boolean | OgOptions;
26
47
  };
27
48
 
28
49
  /**
@@ -42,15 +63,100 @@ export function registerSnapHandler(
42
63
  options: SnapHandlerOptions = {},
43
64
  ): void {
44
65
  const path = options.path ?? "/";
66
+ const ogEnabled = options.og !== false;
67
+ const ogOptions =
68
+ typeof options.og === "object" ? options.og : ogEnabled ? {} : undefined;
45
69
 
46
70
  app.use(path, cors({ origin: "*" }));
47
71
 
72
+ // ─── /~/og-image PNG route ────────────────────────────
73
+ if (ogEnabled && ogOptions) {
74
+ const imgPath = ogImagePath(path);
75
+
76
+ app.get(imgPath, async (c) => {
77
+ const resourcePath = resourcePathFromRequest(c.req.url);
78
+ const key = resourcePath;
79
+
80
+ const renderFn = async (): Promise<{ png: Uint8Array; etag: string }> => {
81
+ const snap = await snapFn({
82
+ action: { type: ACTION_TYPE_GET },
83
+ request: stripAuthHeaders(c.req.raw),
84
+ data: defaultData,
85
+ });
86
+ const snapJson = JSON.stringify(snap);
87
+ const etag = etagForPage(snapJson);
88
+ const t0 = Date.now();
89
+ const png = await renderSnapPageToPng(snap, ogOptions);
90
+ const elapsed = Date.now() - t0;
91
+ return { png, etag, elapsed } as { png: Uint8Array; etag: string } & {
92
+ elapsed: number;
93
+ };
94
+ };
95
+
96
+ try {
97
+ // Adapter cache check
98
+ const adapter = ogOptions.cache;
99
+ if (adapter) {
100
+ const hit = await adapter.get(key);
101
+ if (hit) {
102
+ return new Response(hit.png as BodyInit, {
103
+ status: 200,
104
+ headers: {
105
+ "Content-Type": "image/png",
106
+ ETag: hit.etag,
107
+ "X-OG-Cache": "HIT",
108
+ ...ogCacheHeaders(ogOptions),
109
+ },
110
+ });
111
+ }
112
+ }
113
+
114
+ const result = await renderWithDedup(key, async () => {
115
+ const r = await renderFn();
116
+ return r;
117
+ });
118
+
119
+ const { png, etag } = result;
120
+ const elapsed = (result as typeof result & { elapsed?: number })
121
+ .elapsed;
122
+
123
+ if (adapter) {
124
+ await adapter
125
+ .set(key, { png, etag }, ogOptions.cdnMaxAge ?? 86400)
126
+ .catch(() => undefined);
127
+ }
128
+
129
+ return new Response(png as BodyInit, {
130
+ status: 200,
131
+ headers: {
132
+ "Content-Type": "image/png",
133
+ ETag: etag,
134
+ "X-OG-Cache": "MISS",
135
+ ...(elapsed != null ? { "X-OG-Render-Ms": String(elapsed) } : {}),
136
+ ...ogCacheHeaders(ogOptions),
137
+ },
138
+ });
139
+ } catch {
140
+ return new Response(null, {
141
+ status: 500,
142
+ headers: { "Cache-Control": "no-store" },
143
+ });
144
+ }
145
+ });
146
+ }
147
+
148
+ // ─── Main snap route ───────────────────────────────────
48
149
  app.get(path, async (c) => {
49
150
  const resourcePath = resourcePathFromRequest(c.req.url);
50
151
  const accept = c.req.header("Accept");
51
152
  if (!clientWantsSnapResponse(accept)) {
52
153
  const fallbackHtml =
53
- options.fallbackHtml ?? (await getFallbackHtml(c.req.raw, snapFn));
154
+ options.fallbackHtml ??
155
+ (await getFallbackHtml(
156
+ c.req.raw,
157
+ snapFn,
158
+ ogEnabled ? buildOgImageUrl(c.req.raw, path) : undefined,
159
+ ));
54
160
  return new Response(fallbackHtml, {
55
161
  status: 200,
56
162
  headers: snapHeaders(resourcePath, "text/html", [
@@ -61,8 +167,9 @@ export function registerSnapHandler(
61
167
  }
62
168
 
63
169
  const response = await snapFn({
64
- action: { type: "get" },
170
+ action: { type: ACTION_TYPE_GET },
65
171
  request: c.req.raw,
172
+ data: defaultData,
66
173
  });
67
174
 
68
175
  return payloadToResponse(response, {
@@ -103,7 +210,11 @@ export function registerSnapHandler(
103
210
  }
104
211
  }
105
212
 
106
- const response = await snapFn({ action: parsed.action, request: raw });
213
+ const response = await snapFn({
214
+ action: parsed.action,
215
+ request: raw,
216
+ data: defaultData,
217
+ });
107
218
 
108
219
  return payloadToResponse(response, {
109
220
  resourcePath: resourcePathFromRequest(raw.url),
@@ -112,6 +223,35 @@ export function registerSnapHandler(
112
223
  });
113
224
  }
114
225
 
226
+ // ─── Helpers ──────────────────────────────────────────────
227
+
228
+ function ogImagePath(snapPath: string): string {
229
+ const p = snapPath.replace(/\/+$/, "") || "/";
230
+ return p === "/" ? "/~/og-image" : `${p}/~/og-image`;
231
+ }
232
+
233
+ function buildOgImageUrl(request: Request, snapPath: string): string {
234
+ const origin = snapOriginFromRequest(request);
235
+ return origin + ogImagePath(snapPath);
236
+ }
237
+
238
+ function ogCacheHeaders(
239
+ opts: import("./og-image").OgOptions,
240
+ ): Record<string, string> {
241
+ const cdnMaxAge = opts.cdnMaxAge ?? 86400;
242
+ const browserMaxAge = opts.browserMaxAge ?? 60;
243
+ return {
244
+ "Cache-Control": `public, max-age=${browserMaxAge}, s-maxage=${cdnMaxAge}, stale-while-revalidate=604800`,
245
+ };
246
+ }
247
+
248
+ function stripAuthHeaders(request: Request): Request {
249
+ const headers = new Headers(request.headers);
250
+ headers.delete("cookie");
251
+ headers.delete("authorization");
252
+ return new Request(request.url, { method: request.method, headers });
253
+ }
254
+
115
255
  function resourcePathFromRequest(url: string): string {
116
256
  const u = new URL(url);
117
257
  return u.pathname + u.search;
@@ -120,20 +260,27 @@ function resourcePathFromRequest(url: string): string {
120
260
  async function getFallbackHtml(
121
261
  request: Request,
122
262
  snapFn: SnapFunction,
263
+ ogImageUrl?: string,
123
264
  ): Promise<string> {
265
+ const origin = snapOriginFromRequest(request);
266
+ const siteName =
267
+ process.env.SNAP_OG_SITE_NAME?.trim() ||
268
+ process.env.OG_SITE_NAME?.trim() ||
269
+ undefined;
270
+ const resourcePath = resourcePathFromRequest(request.url);
271
+
124
272
  try {
125
273
  const snap = await snapFn({
126
- action: { type: "get" },
127
- request,
274
+ action: { type: ACTION_TYPE_GET },
275
+ request: stripAuthHeaders(request),
276
+ data: defaultData,
128
277
  });
129
- return renderSnapPage(snap, snapOriginFromRequest(request));
278
+ return renderSnapPage(snap, origin, { ogImageUrl, resourcePath, siteName });
130
279
  } catch {
131
- return brandedFallbackHtml(snapOriginFromRequest(request));
280
+ return brandedFallbackHtml(origin, { ogImageUrl, resourcePath, siteName });
132
281
  }
133
282
  }
134
283
 
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
284
  function snapOriginFromRequest(request: Request): string {
138
285
  const fromEnv = process.env.SNAP_PUBLIC_BASE_URL?.trim();
139
286
  if (fromEnv) return fromEnv.replace(/\/$/, "");
@@ -147,46 +294,6 @@ function snapOriginFromRequest(request: Request): string {
147
294
  return "https://snap.farcaster.xyz";
148
295
  }
149
296
 
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
297
  function clientWantsSnapResponse(accept: string | undefined): boolean {
191
298
  if (!accept || accept.trim() === "") return false;
192
299
  const want = MEDIA_TYPE.toLowerCase();