@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.
@@ -0,0 +1,5 @@
1
+ export declare function brandedFallbackHtml(snapOrigin: string, og?: {
2
+ ogImageUrl?: string;
3
+ resourcePath?: string;
4
+ siteName?: string;
5
+ }): string;
@@ -0,0 +1,66 @@
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
+ export function brandedFallbackHtml(snapOrigin, og) {
3
+ const snapUrl = encodeURIComponent(snapOrigin + "/");
4
+ const testUrl = `https://farcaster.xyz/~/developers/snaps?url=${snapUrl}`;
5
+ const pageUrl = snapOrigin + (og?.resourcePath ?? "/");
6
+ const ogLines = [
7
+ `<meta name="description" content="An interactive embed for Farcaster.">`,
8
+ `<meta property="og:title" content="Farcaster Snap">`,
9
+ `<meta property="og:description" content="An interactive embed for Farcaster.">`,
10
+ `<meta property="og:url" content="${escHtml(pageUrl)}">`,
11
+ `<meta property="og:type" content="website">`,
12
+ `<meta property="og:locale" content="en_US">`,
13
+ ];
14
+ if (og?.siteName) {
15
+ ogLines.push(`<meta property="og:site_name" content="${escHtml(og.siteName)}">`);
16
+ }
17
+ if (og?.ogImageUrl) {
18
+ ogLines.push(`<meta property="og:image" content="${escHtml(og.ogImageUrl)}">`, `<meta name="twitter:image" content="${escHtml(og.ogImageUrl)}">`, `<meta name="twitter:card" content="summary_large_image">`);
19
+ }
20
+ else {
21
+ ogLines.push(`<meta name="twitter:card" content="summary">`);
22
+ }
23
+ ogLines.push(`<meta name="twitter:title" content="Farcaster Snap">`, `<meta name="twitter:description" content="An interactive embed for Farcaster.">`);
24
+ return `<!DOCTYPE html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="utf-8">
28
+ <meta name="viewport" content="width=device-width, initial-scale=1">
29
+ <title>Farcaster Snap</title>
30
+ ${ogLines.join("\n")}
31
+ <style>
32
+ *{margin:0;padding:0;box-sizing:border-box}
33
+ 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}
34
+ .c{text-align:center;max-width:400px;padding:48px 32px}
35
+ .logo{color:#8B5CF6;margin-bottom:24px}
36
+ .logo svg{width:48px;height:42px}
37
+ h1{font-size:24px;font-weight:700;margin-bottom:8px}
38
+ p{color:#A1A1AA;font-size:15px;line-height:1.5;margin-bottom:32px}
39
+ .btns{display:flex;flex-direction:column;gap:12px}
40
+ 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}
41
+ a:hover{opacity:.85}
42
+ .p{background:#8B5CF6;color:#fff}
43
+ .s{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
44
+ .s svg{width:20px;height:18px}
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div class="c">
49
+ <div class="logo">${FARCASTER_ICON_SVG}</div>
50
+ <h1>Farcaster Snap</h1>
51
+ <p>This is a Farcaster Snap &mdash; an interactive embed that lives in the feed.</p>
52
+ <div class="btns">
53
+ <a href="${testUrl}" class="p">Test this snap</a>
54
+ <a href="https://farcaster.xyz" class="s">${FARCASTER_ICON_SVG} Sign up for Farcaster</a>
55
+ </div>
56
+ </div>
57
+ </body>
58
+ </html>`;
59
+ }
60
+ function escHtml(s) {
61
+ return s
62
+ .replace(/&/g, "&amp;")
63
+ .replace(/</g, "&lt;")
64
+ .replace(/>/g, "&gt;")
65
+ .replace(/"/g, "&quot;");
66
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Hono } from "hono";
2
2
  import { type SnapFunction } from "@farcaster/snap";
3
+ import { type OgOptions } from "./og-image.js";
3
4
  export type SnapHandlerOptions = {
4
5
  /**
5
6
  * Route path to register GET and POST handlers on.
@@ -16,6 +17,12 @@ export type SnapHandlerOptions = {
16
17
  * over the default branded fallback.
17
18
  */
18
19
  fallbackHtml?: string;
20
+ /**
21
+ * Open Graph configuration. Set to `false` to disable OG tag injection and
22
+ * the `/~/og-image` route. Pass an `OgOptions` object to customize rendering.
23
+ * @default true
24
+ */
25
+ og?: boolean | OgOptions;
19
26
  };
20
27
  /**
21
28
  * Register GET and POST snap handlers on `app` at `options.path` (default `/`).
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { cors } from "hono/cors";
2
- import { MEDIA_TYPE } from "@farcaster/snap";
2
+ import { createDefaultDataStore, MEDIA_TYPE, ACTION_TYPE_GET, } from "@farcaster/snap";
3
3
  import { parseRequest } from "@farcaster/snap/server";
4
+ import { brandedFallbackHtml } from "./fallback.js";
4
5
  import { payloadToResponse, snapHeaders } from "./payloadToResponse.js";
5
6
  import { renderSnapPage } from "./renderSnapPage.js";
7
+ import { renderSnapPageToPng, renderWithDedup, etagForPage, } from "./og-image.js";
8
+ const defaultData = createDefaultDataStore();
6
9
  /**
7
10
  * Register GET and POST snap handlers on `app` at `options.path` (default `/`).
8
11
  *
@@ -16,12 +19,83 @@ import { renderSnapPage } from "./renderSnapPage.js";
16
19
  */
17
20
  export function registerSnapHandler(app, snapFn, options = {}) {
18
21
  const path = options.path ?? "/";
22
+ const ogEnabled = options.og !== false;
23
+ const ogOptions = typeof options.og === "object" ? options.og : ogEnabled ? {} : undefined;
19
24
  app.use(path, cors({ origin: "*" }));
25
+ // ─── /~/og-image PNG route ────────────────────────────
26
+ if (ogEnabled && ogOptions) {
27
+ const imgPath = ogImagePath(path);
28
+ app.get(imgPath, async (c) => {
29
+ const resourcePath = resourcePathFromRequest(c.req.url);
30
+ const key = resourcePath;
31
+ const renderFn = async () => {
32
+ const snap = await snapFn({
33
+ action: { type: ACTION_TYPE_GET },
34
+ request: stripAuthHeaders(c.req.raw),
35
+ data: defaultData,
36
+ });
37
+ const snapJson = JSON.stringify(snap);
38
+ const etag = etagForPage(snapJson);
39
+ const t0 = Date.now();
40
+ const png = await renderSnapPageToPng(snap, ogOptions);
41
+ const elapsed = Date.now() - t0;
42
+ return { png, etag, elapsed };
43
+ };
44
+ try {
45
+ // Adapter cache check
46
+ const adapter = ogOptions.cache;
47
+ if (adapter) {
48
+ const hit = await adapter.get(key);
49
+ if (hit) {
50
+ return new Response(hit.png, {
51
+ status: 200,
52
+ headers: {
53
+ "Content-Type": "image/png",
54
+ ETag: hit.etag,
55
+ "X-OG-Cache": "HIT",
56
+ ...ogCacheHeaders(ogOptions),
57
+ },
58
+ });
59
+ }
60
+ }
61
+ const result = await renderWithDedup(key, async () => {
62
+ const r = await renderFn();
63
+ return r;
64
+ });
65
+ const { png, etag } = result;
66
+ const elapsed = result
67
+ .elapsed;
68
+ if (adapter) {
69
+ await adapter
70
+ .set(key, { png, etag }, ogOptions.cdnMaxAge ?? 86400)
71
+ .catch(() => undefined);
72
+ }
73
+ return new Response(png, {
74
+ status: 200,
75
+ headers: {
76
+ "Content-Type": "image/png",
77
+ ETag: etag,
78
+ "X-OG-Cache": "MISS",
79
+ ...(elapsed != null ? { "X-OG-Render-Ms": String(elapsed) } : {}),
80
+ ...ogCacheHeaders(ogOptions),
81
+ },
82
+ });
83
+ }
84
+ catch {
85
+ return new Response(null, {
86
+ status: 500,
87
+ headers: { "Cache-Control": "no-store" },
88
+ });
89
+ }
90
+ });
91
+ }
92
+ // ─── Main snap route ───────────────────────────────────
20
93
  app.get(path, async (c) => {
21
94
  const resourcePath = resourcePathFromRequest(c.req.url);
22
95
  const accept = c.req.header("Accept");
23
96
  if (!clientWantsSnapResponse(accept)) {
24
- const fallbackHtml = options.fallbackHtml ?? (await getFallbackHtml(c.req.raw, snapFn));
97
+ const fallbackHtml = options.fallbackHtml ??
98
+ (await getFallbackHtml(c.req.raw, snapFn, ogEnabled ? buildOgImageUrl(c.req.raw, path) : undefined));
25
99
  return new Response(fallbackHtml, {
26
100
  status: 200,
27
101
  headers: snapHeaders(resourcePath, "text/html", [
@@ -31,8 +105,9 @@ export function registerSnapHandler(app, snapFn, options = {}) {
31
105
  });
32
106
  }
33
107
  const response = await snapFn({
34
- action: { type: "get" },
108
+ action: { type: ACTION_TYPE_GET },
35
109
  request: c.req.raw,
110
+ data: defaultData,
36
111
  });
37
112
  return payloadToResponse(response, {
38
113
  resourcePath,
@@ -64,30 +139,61 @@ export function registerSnapHandler(app, snapFn, options = {}) {
64
139
  }
65
140
  }
66
141
  }
67
- const response = await snapFn({ action: parsed.action, request: raw });
142
+ const response = await snapFn({
143
+ action: parsed.action,
144
+ request: raw,
145
+ data: defaultData,
146
+ });
68
147
  return payloadToResponse(response, {
69
148
  resourcePath: resourcePathFromRequest(raw.url),
70
149
  mediaTypes: [MEDIA_TYPE, "text/html"],
71
150
  });
72
151
  });
73
152
  }
153
+ // ─── Helpers ──────────────────────────────────────────────
154
+ function ogImagePath(snapPath) {
155
+ const p = snapPath.replace(/\/+$/, "") || "/";
156
+ return p === "/" ? "/~/og-image" : `${p}/~/og-image`;
157
+ }
158
+ function buildOgImageUrl(request, snapPath) {
159
+ const origin = snapOriginFromRequest(request);
160
+ return origin + ogImagePath(snapPath);
161
+ }
162
+ function ogCacheHeaders(opts) {
163
+ const cdnMaxAge = opts.cdnMaxAge ?? 86400;
164
+ const browserMaxAge = opts.browserMaxAge ?? 60;
165
+ return {
166
+ "Cache-Control": `public, max-age=${browserMaxAge}, s-maxage=${cdnMaxAge}, stale-while-revalidate=604800`,
167
+ };
168
+ }
169
+ function stripAuthHeaders(request) {
170
+ const headers = new Headers(request.headers);
171
+ headers.delete("cookie");
172
+ headers.delete("authorization");
173
+ return new Request(request.url, { method: request.method, headers });
174
+ }
74
175
  function resourcePathFromRequest(url) {
75
176
  const u = new URL(url);
76
177
  return u.pathname + u.search;
77
178
  }
78
- async function getFallbackHtml(request, snapFn) {
179
+ async function getFallbackHtml(request, snapFn, ogImageUrl) {
180
+ const origin = snapOriginFromRequest(request);
181
+ const siteName = process.env.SNAP_OG_SITE_NAME?.trim() ||
182
+ process.env.OG_SITE_NAME?.trim() ||
183
+ undefined;
184
+ const resourcePath = resourcePathFromRequest(request.url);
79
185
  try {
80
186
  const snap = await snapFn({
81
- action: { type: "get" },
82
- request,
187
+ action: { type: ACTION_TYPE_GET },
188
+ request: stripAuthHeaders(request),
189
+ data: defaultData,
83
190
  });
84
- return renderSnapPage(snap, snapOriginFromRequest(request));
191
+ return renderSnapPage(snap, origin, { ogImageUrl, resourcePath, siteName });
85
192
  }
86
193
  catch {
87
- return brandedFallbackHtml(snapOriginFromRequest(request));
194
+ return brandedFallbackHtml(origin, { ogImageUrl, resourcePath, siteName });
88
195
  }
89
196
  }
90
- 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>`;
91
197
  function snapOriginFromRequest(request) {
92
198
  const fromEnv = process.env.SNAP_PUBLIC_BASE_URL?.trim();
93
199
  if (fromEnv)
@@ -99,44 +205,6 @@ function snapOriginFromRequest(request) {
99
205
  return `${proto}://${host}`.replace(/\/$/, "");
100
206
  return "https://snap.farcaster.xyz";
101
207
  }
102
- function brandedFallbackHtml(snapOrigin) {
103
- const snapUrl = encodeURIComponent(snapOrigin + "/");
104
- const testUrl = `https://farcaster.xyz/~/developers/snaps?url=${snapUrl}`;
105
- return `<!DOCTYPE html>
106
- <html lang="en">
107
- <head>
108
- <meta charset="utf-8">
109
- <meta name="viewport" content="width=device-width, initial-scale=1">
110
- <title>Farcaster Snap</title>
111
- <style>
112
- *{margin:0;padding:0;box-sizing:border-box}
113
- 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}
114
- .c{text-align:center;max-width:400px;padding:48px 32px}
115
- .logo{color:#8B5CF6;margin-bottom:24px}
116
- .logo svg{width:48px;height:42px}
117
- h1{font-size:24px;font-weight:700;margin-bottom:8px}
118
- p{color:#A1A1AA;font-size:15px;line-height:1.5;margin-bottom:32px}
119
- .btns{display:flex;flex-direction:column;gap:12px}
120
- 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}
121
- a:hover{opacity:.85}
122
- .p{background:#8B5CF6;color:#fff}
123
- .s{background:#1A1A2E;color:#FAFAFA;border:1px solid #2D2D44}
124
- .s svg{width:20px;height:18px}
125
- </style>
126
- </head>
127
- <body>
128
- <div class="c">
129
- <div class="logo">${FARCASTER_ICON_SVG}</div>
130
- <h1>Farcaster Snap</h1>
131
- <p>This is a Farcaster Snap &mdash; an interactive embed that lives in the feed.</p>
132
- <div class="btns">
133
- <a href="${testUrl}" class="p">Test this snap</a>
134
- <a href="https://farcaster.xyz" class="s">${FARCASTER_ICON_SVG} Sign up for Farcaster</a>
135
- </div>
136
- </div>
137
- </body>
138
- </html>`;
139
- }
140
208
  function clientWantsSnapResponse(accept) {
141
209
  if (!accept || accept.trim() === "")
142
210
  return false;
@@ -0,0 +1,46 @@
1
+ import type { SnapHandlerResult } from "@farcaster/snap";
2
+ export type OgFontSpec = {
3
+ /** Absolute path to a .woff2 (or .woff / .ttf) file on disk. */
4
+ path: string;
5
+ weight: 400 | 700;
6
+ style?: "normal" | "italic";
7
+ };
8
+ export type OgCacheAdapter = {
9
+ get(key: string): Promise<{
10
+ png: Uint8Array;
11
+ etag: string;
12
+ } | null>;
13
+ set(key: string, value: {
14
+ png: Uint8Array;
15
+ etag: string;
16
+ }, ttlSeconds: number): Promise<void>;
17
+ };
18
+ export type OgOptions = {
19
+ /** OG image width in pixels. @default card width + outer margin (~508) */
20
+ width?: number;
21
+ /** OG image height in pixels. @default derived from snap content + margins */
22
+ height?: number;
23
+ /**
24
+ * Font files to use for OG image rendering. Pass absolute disk paths to
25
+ * woff2/ttf files. Falls back to a CDN-loaded Inter if omitted or unavailable.
26
+ */
27
+ fonts?: OgFontSpec[];
28
+ /**
29
+ * Optional distributed cache adapter (e.g. Upstash Redis).
30
+ * When omitted the function relies entirely on CDN Cache-Control headers.
31
+ */
32
+ cache?: OgCacheAdapter;
33
+ /** CDN s-maxage in seconds. @default 86400 */
34
+ cdnMaxAge?: number;
35
+ /** Browser max-age in seconds. @default 60 */
36
+ browserMaxAge?: number;
37
+ };
38
+ export declare function etagForPage(snapJson: string): string;
39
+ export declare function renderWithDedup(key: string, render: () => Promise<{
40
+ png: Uint8Array;
41
+ etag: string;
42
+ }>): Promise<{
43
+ png: Uint8Array;
44
+ etag: string;
45
+ }>;
46
+ export declare function renderSnapPageToPng(snap: SnapHandlerResult, options?: OgOptions): Promise<Uint8Array>;