@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.
- package/dist/fallback.d.ts +5 -0
- package/dist/fallback.js +66 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +107 -47
- package/dist/og-image.d.ts +46 -0
- package/dist/og-image.js +628 -0
- package/dist/payloadToResponse.d.ts +2 -2
- package/dist/payloadToResponse.js +3 -3
- package/dist/renderSnapPage.d.ts +19 -2
- package/dist/renderSnapPage.js +98 -46
- package/package.json +4 -2
- package/src/fallback.ts +81 -0
- package/src/index.ts +142 -49
- package/src/og-image.ts +878 -0
- package/src/payloadToResponse.ts +6 -6
- package/src/renderSnapPage.ts +225 -105
package/dist/renderSnapPage.js
CHANGED
|
@@ -1,13 +1,59 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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, """);
|
|
18
64
|
}
|
|
19
65
|
function accentHex(accent) {
|
|
20
|
-
return
|
|
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 ===
|
|
71
|
+
if (!color || color === PALETTE_COLOR_ACCENT)
|
|
24
72
|
return accent;
|
|
25
|
-
return
|
|
73
|
+
return PALETTE_LIGHT_HEX[color] ?? accent;
|
|
26
74
|
}
|
|
27
75
|
// ─── Element renderers ──────────────────────────────────
|
|
28
76
|
function renderElement(el, accent) {
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
const
|
|
243
|
-
const
|
|
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
|
|
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
|
-
"@
|
|
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"
|
package/src/fallback.ts
ADDED
|
@@ -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 — 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, "&")
|
|
78
|
+
.replace(/</g, "<")
|
|
79
|
+
.replace(/>/g, ">")
|
|
80
|
+
.replace(/"/g, """);
|
|
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(
|
|
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(
|
|
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. `
|
|
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 ??
|
|
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,
|
|
264
|
+
return renderSnapPage(snap, origin, { ogImageUrl, resourcePath, siteName });
|
|
130
265
|
} catch {
|
|
131
|
-
return brandedFallbackHtml(
|
|
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 — 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();
|