@farcaster/snap-hono 1.1.2 → 1.1.3

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/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export type SnapHandlerOptions = {
12
12
  */
13
13
  skipJFSVerification?: boolean;
14
14
  /**
15
- * Text shown on GET requests from browsers / non-snap clients.
15
+ * Visible message in the HTML page served on GET when the client does not request snap JSON.
16
16
  * @default "This is a Farcaster Snap server."
17
17
  */
18
18
  fallbackText?: string;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
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 { payloadToResponse } from "./payloadToResponse.js";
4
+ import { payloadToResponse, snapHeaders } from "./payloadToResponse.js";
5
5
  /**
6
6
  * Register GET and POST snap handlers on `app` at `options.path` (default `/`).
7
7
  *
@@ -16,18 +16,28 @@ import { payloadToResponse } from "./payloadToResponse.js";
16
16
  export function registerSnapHandler(app, snapFn, options = {}) {
17
17
  const path = options.path ?? "/";
18
18
  const fallbackText = options.fallbackText ??
19
- "This is a Farcaster Snap server. See https://snap.farcaster.xyz for more info.";
19
+ "This is a Farcaster Snap server. See <a href='https://snap.farcaster.xyz'>snap.farcaster.xyz</a> for more info.";
20
20
  app.use(path, cors({ origin: "*" }));
21
21
  app.get(path, async (c) => {
22
+ const resourcePath = resourcePathFromRequest(c.req.url);
22
23
  const accept = c.req.header("Accept");
23
24
  if (!clientWantsSnapResponse(accept)) {
24
- return c.text(fallbackText, 200, { Vary: "Accept" });
25
+ return new Response(fallbackHtmlDocument(fallbackText), {
26
+ status: 200,
27
+ headers: snapHeaders(resourcePath, "text/html", [
28
+ MEDIA_TYPE,
29
+ "text/html",
30
+ ]),
31
+ });
25
32
  }
26
33
  const response = await snapFn({
27
34
  action: { type: "get" },
28
35
  request: c.req.raw,
29
36
  });
30
- return payloadToResponse(response);
37
+ return payloadToResponse(response, {
38
+ resourcePath,
39
+ mediaTypes: [MEDIA_TYPE, "text/html"],
40
+ });
31
41
  });
32
42
  app.post(path, async (c) => {
33
43
  const raw = c.req.raw;
@@ -55,9 +65,38 @@ export function registerSnapHandler(app, snapFn, options = {}) {
55
65
  }
56
66
  }
57
67
  const response = await snapFn({ action: parsed.action, request: raw });
58
- return payloadToResponse(response);
68
+ return payloadToResponse(response, {
69
+ resourcePath: resourcePathFromRequest(raw.url),
70
+ mediaTypes: [MEDIA_TYPE, "text/html"],
71
+ });
59
72
  });
60
73
  }
74
+ function resourcePathFromRequest(url) {
75
+ const u = new URL(url);
76
+ return u.pathname + u.search;
77
+ }
78
+ function escapeHtml(text) {
79
+ return text
80
+ .replace(/&/g, "&amp;")
81
+ .replace(/</g, "&lt;")
82
+ .replace(/>/g, "&gt;")
83
+ .replace(/"/g, "&quot;")
84
+ .replace(/'/g, "&#39;");
85
+ }
86
+ function fallbackHtmlDocument(message) {
87
+ const body = escapeHtml(message);
88
+ return `<!DOCTYPE html>
89
+ <html lang="en">
90
+ <head>
91
+ <meta charset="utf-8">
92
+ <meta name="viewport" content="width=device-width, initial-scale=1">
93
+ <title>Farcaster Snap</title>
94
+ </head>
95
+ <body>
96
+ <p>${body}</p>
97
+ </body>
98
+ </html>`;
99
+ }
61
100
  function clientWantsSnapResponse(accept) {
62
101
  if (!accept || accept.trim() === "")
63
102
  return false;
@@ -1,8 +1,13 @@
1
1
  import { type SnapResponse } from "@farcaster/snap";
2
- /**
3
- * Validate a snap root object, then return a JSON Response for the client.
4
- * Sets `Content-Type: application/vnd.farcaster.snap+json` and `Vary: Accept`.
5
- *
6
- * On validation failure returns JSON `{ "error": "...", "issues": [...] }` with status 400.
7
- */
8
- export declare function payloadToResponse(payload: SnapResponse): Response;
2
+ type PayloadToResponseOptions = {
3
+ resourcePath: string;
4
+ mediaTypes: string[];
5
+ };
6
+ export declare function payloadToResponse(payload: SnapResponse, options?: Partial<PayloadToResponseOptions>): Response;
7
+ export declare function snapHeaders(resourcePath: string, currentMediaType: string, availableMediaTypes: string[]): {
8
+ "Content-Type": string;
9
+ Vary: string;
10
+ Link: string;
11
+ };
12
+ export declare function buildSnapAlternateLinkHeader(resourcePath: string, mediaTypes: string[]): string;
13
+ export {};
@@ -1,11 +1,8 @@
1
1
  import { MEDIA_TYPE, rootSchema, validatePage, } from "@farcaster/snap";
2
- /**
3
- * Validate a snap root object, then return a JSON Response for the client.
4
- * Sets `Content-Type: application/vnd.farcaster.snap+json` and `Vary: Accept`.
5
- *
6
- * On validation failure returns JSON `{ "error": "...", "issues": [...] }` with status 400.
7
- */
8
- export function payloadToResponse(payload) {
2
+ const DEFAULT_LINK_MEDIA_TYPES = [MEDIA_TYPE, "text/html"];
3
+ export function payloadToResponse(payload, options = {}) {
4
+ const resourcePath = options.resourcePath ?? "/";
5
+ const mediaTypes = options.mediaTypes ?? [...DEFAULT_LINK_MEDIA_TYPES];
9
6
  const validation = validatePage(payload);
10
7
  if (!validation.valid) {
11
8
  return new Response(JSON.stringify({
@@ -22,8 +19,20 @@ export function payloadToResponse(payload) {
22
19
  return new Response(JSON.stringify(finalized), {
23
20
  status: 200,
24
21
  headers: {
25
- "Content-Type": `${MEDIA_TYPE}; charset=utf-8`,
26
- Vary: "Accept",
22
+ ...snapHeaders(resourcePath, MEDIA_TYPE, mediaTypes),
27
23
  },
28
24
  });
29
25
  }
26
+ export function snapHeaders(resourcePath, currentMediaType, availableMediaTypes) {
27
+ return {
28
+ "Content-Type": `${currentMediaType}; charset=utf-8`,
29
+ Vary: "Accept",
30
+ Link: buildSnapAlternateLinkHeader(resourcePath, availableMediaTypes),
31
+ };
32
+ }
33
+ export function buildSnapAlternateLinkHeader(resourcePath, mediaTypes) {
34
+ const p = resourcePath.startsWith("/") ? resourcePath : `/${resourcePath}`;
35
+ return mediaTypes
36
+ .map((mediaType) => `<${p}>; rel="alternate"; type="${mediaType}"`)
37
+ .join(", ");
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap-hono",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Hono integration for Farcaster Snap servers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@farcaster/snap": "1.2.0"
29
+ "@farcaster/snap": "1.2.1"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "hono": ">=4.0.0"
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ 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 { payloadToResponse } from "./payloadToResponse";
5
+ import { payloadToResponse, snapHeaders } from "./payloadToResponse";
6
6
 
7
7
  export type SnapHandlerOptions = {
8
8
  /**
@@ -18,7 +18,7 @@ export type SnapHandlerOptions = {
18
18
  skipJFSVerification?: boolean;
19
19
 
20
20
  /**
21
- * Text shown on GET requests from browsers / non-snap clients.
21
+ * Visible message in the HTML page served on GET when the client does not request snap JSON.
22
22
  * @default "This is a Farcaster Snap server."
23
23
  */
24
24
  fallbackText?: string;
@@ -43,21 +43,31 @@ export function registerSnapHandler(
43
43
  const path = options.path ?? "/";
44
44
  const fallbackText =
45
45
  options.fallbackText ??
46
- "This is a Farcaster Snap server. See https://snap.farcaster.xyz for more info.";
46
+ "This is a Farcaster Snap server. See <a href='https://snap.farcaster.xyz'>snap.farcaster.xyz</a> for more info.";
47
47
 
48
48
  app.use(path, cors({ origin: "*" }));
49
49
 
50
50
  app.get(path, async (c) => {
51
+ const resourcePath = resourcePathFromRequest(c.req.url);
51
52
  const accept = c.req.header("Accept");
52
53
  if (!clientWantsSnapResponse(accept)) {
53
- return c.text(fallbackText, 200, { Vary: "Accept" });
54
+ return new Response(fallbackHtmlDocument(fallbackText), {
55
+ status: 200,
56
+ headers: snapHeaders(resourcePath, "text/html", [
57
+ MEDIA_TYPE,
58
+ "text/html",
59
+ ]),
60
+ });
54
61
  }
55
62
 
56
63
  const response = await snapFn({
57
64
  action: { type: "get" },
58
65
  request: c.req.raw,
59
66
  });
60
- return payloadToResponse(response);
67
+ return payloadToResponse(response, {
68
+ resourcePath,
69
+ mediaTypes: [MEDIA_TYPE, "text/html"],
70
+ });
61
71
  });
62
72
 
63
73
  app.post(path, async (c) => {
@@ -94,10 +104,42 @@ export function registerSnapHandler(
94
104
 
95
105
  const response = await snapFn({ action: parsed.action, request: raw });
96
106
 
97
- return payloadToResponse(response);
107
+ return payloadToResponse(response, {
108
+ resourcePath: resourcePathFromRequest(raw.url),
109
+ mediaTypes: [MEDIA_TYPE, "text/html"],
110
+ });
98
111
  });
99
112
  }
100
113
 
114
+ function resourcePathFromRequest(url: string): string {
115
+ const u = new URL(url);
116
+ return u.pathname + u.search;
117
+ }
118
+
119
+ function escapeHtml(text: string): string {
120
+ return text
121
+ .replace(/&/g, "&amp;")
122
+ .replace(/</g, "&lt;")
123
+ .replace(/>/g, "&gt;")
124
+ .replace(/"/g, "&quot;")
125
+ .replace(/'/g, "&#39;");
126
+ }
127
+
128
+ function fallbackHtmlDocument(message: string): string {
129
+ const body = escapeHtml(message);
130
+ return `<!DOCTYPE html>
131
+ <html lang="en">
132
+ <head>
133
+ <meta charset="utf-8">
134
+ <meta name="viewport" content="width=device-width, initial-scale=1">
135
+ <title>Farcaster Snap</title>
136
+ </head>
137
+ <body>
138
+ <p>${body}</p>
139
+ </body>
140
+ </html>`;
141
+ }
142
+
101
143
  function clientWantsSnapResponse(accept: string | undefined): boolean {
102
144
  if (!accept || accept.trim() === "") return false;
103
145
  const want = MEDIA_TYPE.toLowerCase();
@@ -5,13 +5,20 @@ import {
5
5
  validatePage,
6
6
  } from "@farcaster/snap";
7
7
 
8
- /**
9
- * Validate a snap root object, then return a JSON Response for the client.
10
- * Sets `Content-Type: application/vnd.farcaster.snap+json` and `Vary: Accept`.
11
- *
12
- * On validation failure returns JSON `{ "error": "...", "issues": [...] }` with status 400.
13
- */
14
- export function payloadToResponse(payload: SnapResponse): Response {
8
+ type PayloadToResponseOptions = {
9
+ resourcePath: string;
10
+ mediaTypes: string[];
11
+ };
12
+
13
+ const DEFAULT_LINK_MEDIA_TYPES = [MEDIA_TYPE, "text/html"] as const;
14
+
15
+ export function payloadToResponse(
16
+ payload: SnapResponse,
17
+ options: Partial<PayloadToResponseOptions> = {},
18
+ ): Response {
19
+ const resourcePath = options.resourcePath ?? "/";
20
+ const mediaTypes = options.mediaTypes ?? [...DEFAULT_LINK_MEDIA_TYPES];
21
+
15
22
  const validation = validatePage(payload);
16
23
  if (!validation.valid) {
17
24
  return new Response(
@@ -32,8 +39,29 @@ export function payloadToResponse(payload: SnapResponse): Response {
32
39
  return new Response(JSON.stringify(finalized), {
33
40
  status: 200,
34
41
  headers: {
35
- "Content-Type": `${MEDIA_TYPE}; charset=utf-8`,
36
- Vary: "Accept",
42
+ ...snapHeaders(resourcePath, MEDIA_TYPE, mediaTypes),
37
43
  },
38
44
  });
39
45
  }
46
+
47
+ export function snapHeaders(
48
+ resourcePath: string,
49
+ currentMediaType: string,
50
+ availableMediaTypes: string[],
51
+ ) {
52
+ return {
53
+ "Content-Type": `${currentMediaType}; charset=utf-8`,
54
+ Vary: "Accept",
55
+ Link: buildSnapAlternateLinkHeader(resourcePath, availableMediaTypes),
56
+ };
57
+ }
58
+
59
+ export function buildSnapAlternateLinkHeader(
60
+ resourcePath: string,
61
+ mediaTypes: string[],
62
+ ): string {
63
+ const p = resourcePath.startsWith("/") ? resourcePath : `/${resourcePath}`;
64
+ return mediaTypes
65
+ .map((mediaType) => `<${p}>; rel="alternate"; type="${mediaType}"`)
66
+ .join(", ");
67
+ }