Skip to content

Commit e4dde54

Browse files
author
Evgeny Kuzyakov
authored
Introduce cloudflare workers to generate static previews (NearSocial#179)
Use cloudflare's functions to generate and inject static metadata as well as noscript content for crawlers and previews
1 parent 9d33d88 commit e4dde54

File tree

3 files changed

+335
-10
lines changed

3 files changed

+335
-10
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
.env.development.local
1919
.env.test.local
2020
.env.production.local
21+
.wrangler/
2122

2223
npm-debug.log*
2324
yarn-debug.log*
+323
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import { Buffer } from "node:buffer";
2+
3+
class MetaTitleInjector {
4+
constructor({ title }) {
5+
this.title = title;
6+
}
7+
8+
element(element) {
9+
element.setAttribute("content", this.title);
10+
}
11+
}
12+
13+
class MetaImageInjector {
14+
constructor({ image, authorImage }) {
15+
this.image = image;
16+
this.authorImage = authorImage;
17+
}
18+
19+
element(element) {
20+
if (this.image) {
21+
element.setAttribute("content", this.image);
22+
} else if (this.authorImage) {
23+
element.setAttribute("content", this.authorImage);
24+
}
25+
}
26+
}
27+
28+
class MetaTwitterCardInjector {
29+
constructor({ image }) {
30+
this.image = image;
31+
}
32+
33+
element(element) {
34+
if (!this.image) {
35+
element.setAttribute("content", "summary");
36+
}
37+
}
38+
}
39+
40+
class MetaDescriptionInjector {
41+
constructor({ shortDescription }) {
42+
this.shortDescription = shortDescription;
43+
}
44+
45+
element(element) {
46+
element.setAttribute(
47+
"content",
48+
this.shortDescription?.replaceAll("\n", " ")
49+
);
50+
}
51+
}
52+
53+
class TitleInjector {
54+
constructor({ title }) {
55+
this.title = title;
56+
}
57+
58+
element(element) {
59+
element.setInnerContent(this.title);
60+
}
61+
}
62+
63+
class NoscriptDescriptionInjector {
64+
constructor({ description }) {
65+
this.description = description;
66+
}
67+
68+
element(element) {
69+
element.setInnerContent(this.description);
70+
}
71+
}
72+
73+
function defaultData() {
74+
const image = "https://near.social/assets/logo.png";
75+
const title = "Near Social";
76+
const description = "Social data protocol built on NEAR";
77+
return {
78+
image,
79+
title,
80+
description,
81+
};
82+
}
83+
84+
async function socialGet(keys, blockHeight, parse) {
85+
const request = await fetch("https://api.near.social/get", {
86+
method: "POST",
87+
headers: {
88+
"Content-Type": "application/json",
89+
},
90+
body: JSON.stringify({
91+
keys: [keys],
92+
blockHeight,
93+
}),
94+
});
95+
let data = await request.json();
96+
const parts = keys.split("/");
97+
for (let i = 0; i < parts.length; i++) {
98+
const part = parts[i];
99+
if (part === "*" || part === "**") {
100+
break;
101+
}
102+
data = data?.[part];
103+
}
104+
if (parse) {
105+
try {
106+
data = JSON.parse(data);
107+
} catch (e) {
108+
return null;
109+
}
110+
}
111+
return data;
112+
}
113+
114+
async function viewCall({ contractId, method, args }) {
115+
const res = await fetch("https://rpc.mainnet.near.org", {
116+
method: "POST",
117+
headers: {
118+
"Content-Type": "application/json",
119+
},
120+
body: JSON.stringify({
121+
jsonrpc: "2.0",
122+
id: "dontcare",
123+
method: "query",
124+
params: {
125+
request_type: "call_function",
126+
finality: "final",
127+
account_id: contractId,
128+
method_name: method,
129+
args_base64: btoa(JSON.stringify(args)),
130+
},
131+
}),
132+
});
133+
const json = await res.json();
134+
const result = Buffer.from(json.result.result).toString("utf-8");
135+
return JSON.parse(result);
136+
}
137+
138+
async function nftToImageUrl({ contractId, tokenId }) {
139+
const [token, nftMetadata] = await Promise.all([
140+
viewCall({
141+
contractId,
142+
method: "nft_token",
143+
args: { token_id: tokenId },
144+
}),
145+
viewCall({
146+
contractId,
147+
method: "nft_metadata",
148+
args: {},
149+
}),
150+
]);
151+
152+
const tokenMetadata = token?.metadata || {};
153+
const tokenMedia = tokenMetadata.media || "";
154+
155+
let imageUrl =
156+
tokenMedia.startsWith("https://") ||
157+
tokenMedia.startsWith("http://") ||
158+
tokenMedia.startsWith("data:image")
159+
? tokenMedia
160+
: nftMetadata.base_uri
161+
? `${nftMetadata.base_uri}/${tokenMedia}`
162+
: tokenMedia.startsWith("Qm") || tokenMedia.startsWith("ba")
163+
? `https://ipfs.near.social/ipfs/${tokenMedia}`
164+
: tokenMedia;
165+
166+
if (!tokenMedia && tokenMetadata.reference) {
167+
const metadataUrl =
168+
nftMetadata.base_uri === "https://arweave.net" &&
169+
!tokenMetadata.reference.startsWith("https://")
170+
? `${nftMetadata.base_uri}/${tokenMetadata.reference}`
171+
: tokenMetadata.reference.startsWith("https://") ||
172+
tokenMetadata.reference.startsWith("http://")
173+
? tokenMetadata.reference
174+
: tokenMetadata.reference.startsWith("ar://")
175+
? `https://arweave.net/${tokenMetadata.reference.split("//")[1]}`
176+
: null;
177+
if (metadataUrl) {
178+
const res = await fetch(metadataUrl);
179+
const json = await res.json();
180+
imageUrl = json.media;
181+
}
182+
}
183+
184+
return imageUrl;
185+
}
186+
187+
function wrapImage(url) {
188+
return url ? `https://i.near.social/large/${url}` : null;
189+
}
190+
191+
async function internalImageToUrl(env, image) {
192+
if (image?.url) {
193+
return image.url;
194+
} else if (image?.ipfs_cid) {
195+
return `https://ipfs.near.social/ipfs/${image.ipfs_cid}`;
196+
} else if (image?.nft) {
197+
try {
198+
const { contractId, tokenId } = image.nft;
199+
const NftKV = env.NftKV;
200+
201+
let imageUrl = await NftKV.get(`${contractId}/${tokenId}`);
202+
if (!imageUrl) {
203+
imageUrl = await nftToImageUrl({ contractId, tokenId });
204+
if (imageUrl) {
205+
await NftKV.put(`${contractId}/${tokenId}`, imageUrl);
206+
}
207+
}
208+
return imageUrl;
209+
} catch (e) {
210+
console.log(e);
211+
}
212+
}
213+
return null;
214+
}
215+
216+
async function imageToUrl(env, image) {
217+
return wrapImage(await internalImageToUrl(env, image));
218+
}
219+
220+
async function postData(env, url, data, isPost) {
221+
const accountId = url.searchParams.get("accountId");
222+
const blockHeight = url.searchParams.get("blockHeight");
223+
const [content, name, authorImage] = await Promise.all([
224+
socialGet(
225+
`${accountId}/post/${isPost ? "main" : "comment"}`,
226+
blockHeight,
227+
true
228+
),
229+
socialGet(`${accountId}/profile/name`),
230+
socialGet(`${accountId}/profile/image/**`),
231+
]);
232+
233+
data.raw = content;
234+
data.description = content?.text || "";
235+
data.image = await imageToUrl(env, content?.image);
236+
if (!data.image) {
237+
data.authorImage = await imageToUrl(env, authorImage);
238+
}
239+
data.title = isPost
240+
? `Post by ${name ?? accountId} | Near Social`
241+
: `Comment by ${name ?? accountId} | Near Social`;
242+
data.accountName = name;
243+
data.accountId = accountId;
244+
}
245+
246+
async function profileData(env, url, data) {
247+
const accountId = url.searchParams.get("accountId");
248+
const profile = await socialGet(`${accountId}/profile/**`);
249+
250+
const name = profile?.name;
251+
data.raw = profile;
252+
data.description =
253+
profile?.description || `Profile of ${accountId} on Near Social`;
254+
data.image = await imageToUrl(env, profile?.image);
255+
data.authorImage =
256+
data.image ||
257+
wrapImage(
258+
"https://ipfs.near.social/ipfs/bafkreibmiy4ozblcgv3fm3gc6q62s55em33vconbavfd2ekkuliznaq3zm"
259+
);
260+
data.title = name
261+
? `${name} (${accountId}) | Near Social`
262+
: `${accountId} | Near Social`;
263+
data.accountName = name;
264+
data.accountId = accountId;
265+
}
266+
267+
async function widgetData(env, url, data) {
268+
const parts = url.pathname.split("/");
269+
const accountId = parts[1];
270+
const widgetId = parts[3];
271+
const metadata = await socialGet(
272+
`${accountId}/widget/${widgetId}/metadata/**`
273+
);
274+
275+
const name = metadata?.name || widgetId;
276+
data.raw = metadata;
277+
data.description =
278+
metadata?.description || `Component ${name} created by ${accountId}`;
279+
data.image = await imageToUrl(env, metadata?.image);
280+
data.title = `${name} by ${accountId} | Near Social`;
281+
data.accountName = name;
282+
data.accountId = accountId;
283+
}
284+
285+
async function generateData(env, url) {
286+
const data = defaultData();
287+
try {
288+
if (url.pathname === "/mob.near/widget/MainPage.Post.Page") {
289+
await postData(env, url, data, true);
290+
} else if (url.pathname === "/mob.near/widget/MainPage.Comment.Page") {
291+
await postData(env, url, data, false);
292+
} else if (url.pathname === "/mob.near/widget/ProfilePage") {
293+
await profileData(env, url, data);
294+
} else {
295+
await widgetData(env, url, data);
296+
}
297+
} catch (e) {
298+
console.error(e);
299+
}
300+
data.shortDescription = data.description.slice(0, 250);
301+
302+
return data;
303+
}
304+
305+
export async function onRequest({ request, next, env }) {
306+
const url = new URL(request.url);
307+
if (url.pathname.split("/").length < 4) {
308+
return next();
309+
}
310+
const data = await generateData(env, url);
311+
return (
312+
new HTMLRewriter()
313+
.on('meta[property="og:title"]', new MetaTitleInjector(data))
314+
.on('meta[property="og:image"]', new MetaImageInjector(data))
315+
.on('meta[name="twitter:card"]', new MetaTwitterCardInjector(data))
316+
.on('meta[property="og:description"]', new MetaDescriptionInjector(data))
317+
.on('meta[name="description"]', new MetaDescriptionInjector(data))
318+
// .on("head", new MetaTagInjector(data))
319+
.on("title", new TitleInjector(data))
320+
.on("noscript", new NoscriptDescriptionInjector(data))
321+
.transform(await next())
322+
);
323+
}

public/index.html

+11-10
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,28 @@
55
<link rel="icon" href="/favicon.png" />
66

77
<meta name="viewport" content="width=device-width, initial-scale=1" />
8+
<link rel="apple-touch-icon" href="/favicon.png" />
9+
<!--
10+
manifest.json provides metadata used when your web app is installed on a
11+
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
12+
-->
13+
<link rel="manifest" href="/manifest.json" />
814
<meta
9-
name="description"
10-
content="Social data protocol built on NEAR"
15+
name="description"
16+
content="Social data protocol built on NEAR"
1117
/>
1218
<meta name="twitter:card" content="summary_large_image" />
1319
<meta name="twitter:site" content="@NearSocial_">
14-
<meta name="twitter:image" content="https://near.social/assets/logo.png">
1520
<meta property="og:image" content="https://near.social/assets/logo.png">
1621
<meta property="og:type" content="website">
1722
<meta property="og:title" content="Near Social" />
1823
<meta property="og:description" content="Social data protocol built on NEAR" />
19-
<link rel="apple-touch-icon" href="/favicon.png" />
20-
<!--
21-
manifest.json provides metadata used when your web app is installed on a
22-
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
23-
-->
24-
<link rel="manifest" href="/manifest.json" />
2524
<title>Near Social</title>
2625
</head>
2726
<body>
28-
<noscript>You need to enable JavaScript to run this app.</noscript>
27+
<noscript>
28+
You need to enable JavaScript to run this app.
29+
</noscript>
2930
<div id="root"></div>
3031
</body>
3132
</html>

0 commit comments

Comments
 (0)