@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.
@@ -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,16 +17,22 @@ 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 `/`).
22
29
  *
23
- * - GET → calls `snapFn({ action: { type: "get" }, request })` and returns the response.
30
+ * - GET → calls `snapFn(ctx)` with `ctx.action.type === "get"` and returns the response.
24
31
  * - POST → parses the JFS-shaped JSON body; verifies it via {@link verifyJFSRequestBody} unless
25
- * `skipJFSVerification` is true, then calls `snapFn({ action, request })` and returns the response.
32
+ * `skipJFSVerification` is true, then calls `snapFn(ctx)` with the parsed post action and returns the response.
26
33
  *
27
34
  * All parsing, schema validation, signature verification, and error responses
28
- * are handled automatically. `SnapContext.request` is the raw `Request` so handlers
35
+ * are handled automatically. `ctx.request` is the raw `Request` so handlers
29
36
  * can read query params, headers, or the URL when needed.
30
37
  */
31
38
  export declare function registerSnapHandler(app: Hono, snapFn: SnapFunction, options?: SnapHandlerOptions): void;
package/dist/index.js CHANGED
@@ -1,27 +1,99 @@
1
1
  import { cors } from "hono/cors";
2
2
  import { MEDIA_TYPE } 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";
6
8
  /**
7
9
  * Register GET and POST snap handlers on `app` at `options.path` (default `/`).
8
10
  *
9
- * - GET → calls `snapFn({ action: { type: "get" }, request })` and returns the response.
11
+ * - GET → calls `snapFn(ctx)` with `ctx.action.type === "get"` and returns the response.
10
12
  * - POST → parses the JFS-shaped JSON body; verifies it via {@link verifyJFSRequestBody} unless
11
- * `skipJFSVerification` is true, then calls `snapFn({ action, request })` and returns the response.
13
+ * `skipJFSVerification` is true, then calls `snapFn(ctx)` with the parsed post action and returns the response.
12
14
  *
13
15
  * All parsing, schema validation, signature verification, and error responses
14
- * are handled automatically. `SnapContext.request` is the raw `Request` so handlers
16
+ * are handled automatically. `ctx.request` is the raw `Request` so handlers
15
17
  * can read query params, headers, or the URL when needed.
16
18
  */
17
19
  export function registerSnapHandler(app, snapFn, options = {}) {
18
20
  const path = options.path ?? "/";
21
+ const ogEnabled = options.og !== false;
22
+ const ogOptions = typeof options.og === "object" ? options.og : ogEnabled ? {} : undefined;
19
23
  app.use(path, cors({ origin: "*" }));
24
+ // ─── /~/og-image PNG route ────────────────────────────
25
+ if (ogEnabled && ogOptions) {
26
+ const imgPath = ogImagePath(path);
27
+ app.get(imgPath, async (c) => {
28
+ const resourcePath = resourcePathFromRequest(c.req.url);
29
+ const key = resourcePath;
30
+ const renderFn = async () => {
31
+ const snap = await snapFn({
32
+ action: { type: "get" },
33
+ request: stripAuthHeaders(c.req.raw),
34
+ });
35
+ const snapJson = JSON.stringify(snap);
36
+ const etag = etagForPage(snapJson);
37
+ const t0 = Date.now();
38
+ const png = await renderSnapPageToPng(snap, ogOptions);
39
+ const elapsed = Date.now() - t0;
40
+ return { png, etag, elapsed };
41
+ };
42
+ try {
43
+ // Adapter cache check
44
+ const adapter = ogOptions.cache;
45
+ if (adapter) {
46
+ const hit = await adapter.get(key);
47
+ if (hit) {
48
+ return new Response(hit.png, {
49
+ status: 200,
50
+ headers: {
51
+ "Content-Type": "image/png",
52
+ ETag: hit.etag,
53
+ "X-OG-Cache": "HIT",
54
+ ...ogCacheHeaders(ogOptions),
55
+ },
56
+ });
57
+ }
58
+ }
59
+ const result = await renderWithDedup(key, async () => {
60
+ const r = await renderFn();
61
+ return r;
62
+ });
63
+ const { png, etag } = result;
64
+ const elapsed = result
65
+ .elapsed;
66
+ if (adapter) {
67
+ await adapter
68
+ .set(key, { png, etag }, ogOptions.cdnMaxAge ?? 86400)
69
+ .catch(() => undefined);
70
+ }
71
+ return new Response(png, {
72
+ status: 200,
73
+ headers: {
74
+ "Content-Type": "image/png",
75
+ ETag: etag,
76
+ "X-OG-Cache": "MISS",
77
+ ...(elapsed != null ? { "X-OG-Render-Ms": String(elapsed) } : {}),
78
+ ...ogCacheHeaders(ogOptions),
79
+ },
80
+ });
81
+ }
82
+ catch {
83
+ return new Response(null, {
84
+ status: 500,
85
+ headers: { "Cache-Control": "no-store" },
86
+ });
87
+ }
88
+ });
89
+ }
90
+ // ─── Main snap route ───────────────────────────────────
20
91
  app.get(path, async (c) => {
21
92
  const resourcePath = resourcePathFromRequest(c.req.url);
22
93
  const accept = c.req.header("Accept");
23
94
  if (!clientWantsSnapResponse(accept)) {
24
- const fallbackHtml = options.fallbackHtml ?? (await getFallbackHtml(c.req.raw, snapFn));
95
+ const fallbackHtml = options.fallbackHtml ??
96
+ (await getFallbackHtml(c.req.raw, snapFn, ogEnabled ? buildOgImageUrl(c.req.raw, path) : undefined));
25
97
  return new Response(fallbackHtml, {
26
98
  status: 200,
27
99
  headers: snapHeaders(resourcePath, "text/html", [
@@ -71,23 +143,49 @@ export function registerSnapHandler(app, snapFn, options = {}) {
71
143
  });
72
144
  });
73
145
  }
146
+ // ─── Helpers ──────────────────────────────────────────────
147
+ function ogImagePath(snapPath) {
148
+ const p = snapPath.replace(/\/+$/, "") || "/";
149
+ return p === "/" ? "/~/og-image" : `${p}/~/og-image`;
150
+ }
151
+ function buildOgImageUrl(request, snapPath) {
152
+ const origin = snapOriginFromRequest(request);
153
+ return origin + ogImagePath(snapPath);
154
+ }
155
+ function ogCacheHeaders(opts) {
156
+ const cdnMaxAge = opts.cdnMaxAge ?? 86400;
157
+ const browserMaxAge = opts.browserMaxAge ?? 60;
158
+ return {
159
+ "Cache-Control": `public, max-age=${browserMaxAge}, s-maxage=${cdnMaxAge}, stale-while-revalidate=604800`,
160
+ };
161
+ }
162
+ function stripAuthHeaders(request) {
163
+ const headers = new Headers(request.headers);
164
+ headers.delete("cookie");
165
+ headers.delete("authorization");
166
+ return new Request(request.url, { method: request.method, headers });
167
+ }
74
168
  function resourcePathFromRequest(url) {
75
169
  const u = new URL(url);
76
170
  return u.pathname + u.search;
77
171
  }
78
- async function getFallbackHtml(request, snapFn) {
172
+ async function getFallbackHtml(request, snapFn, ogImageUrl) {
173
+ const origin = snapOriginFromRequest(request);
174
+ const siteName = process.env.SNAP_OG_SITE_NAME?.trim() ||
175
+ process.env.OG_SITE_NAME?.trim() ||
176
+ undefined;
177
+ const resourcePath = resourcePathFromRequest(request.url);
79
178
  try {
80
179
  const snap = await snapFn({
81
180
  action: { type: "get" },
82
- request,
181
+ request: stripAuthHeaders(request),
83
182
  });
84
- return renderSnapPage(snap, snapOriginFromRequest(request));
183
+ return renderSnapPage(snap, origin, { ogImageUrl, resourcePath, siteName });
85
184
  }
86
185
  catch {
87
- return brandedFallbackHtml(snapOriginFromRequest(request));
186
+ return brandedFallbackHtml(origin, { ogImageUrl, resourcePath, siteName });
88
187
  }
89
188
  }
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
189
  function snapOriginFromRequest(request) {
92
190
  const fromEnv = process.env.SNAP_PUBLIC_BASE_URL?.trim();
93
191
  if (fromEnv)
@@ -99,44 +197,6 @@ function snapOriginFromRequest(request) {
99
197
  return `${proto}://${host}`.replace(/\/$/, "");
100
198
  return "https://snap.farcaster.xyz";
101
199
  }
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
200
  function clientWantsSnapResponse(accept) {
141
201
  if (!accept || accept.trim() === "")
142
202
  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>;