@getjack/jack 0.1.2 → 0.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/package.json +54 -47
- package/src/commands/agents.ts +145 -10
- package/src/commands/down.ts +110 -102
- package/src/commands/feedback.ts +189 -0
- package/src/commands/init.ts +8 -12
- package/src/commands/login.ts +88 -0
- package/src/commands/logout.ts +14 -0
- package/src/commands/logs.ts +21 -0
- package/src/commands/mcp.ts +134 -7
- package/src/commands/new.ts +43 -17
- package/src/commands/open.ts +13 -6
- package/src/commands/projects.ts +269 -143
- package/src/commands/secrets.ts +413 -0
- package/src/commands/services.ts +96 -123
- package/src/commands/ship.ts +5 -1
- package/src/commands/whoami.ts +31 -0
- package/src/index.ts +218 -144
- package/src/lib/agent-files.ts +34 -0
- package/src/lib/agents.ts +390 -22
- package/src/lib/asset-hash.ts +50 -0
- package/src/lib/auth/client.ts +115 -0
- package/src/lib/auth/constants.ts +5 -0
- package/src/lib/auth/guard.ts +57 -0
- package/src/lib/auth/index.ts +18 -0
- package/src/lib/auth/store.ts +54 -0
- package/src/lib/binding-validator.ts +136 -0
- package/src/lib/build-helper.ts +211 -0
- package/src/lib/cloudflare-api.ts +24 -0
- package/src/lib/config.ts +5 -6
- package/src/lib/control-plane.ts +295 -0
- package/src/lib/debug.ts +3 -1
- package/src/lib/deploy-mode.ts +93 -0
- package/src/lib/deploy-upload.ts +92 -0
- package/src/lib/errors.ts +2 -0
- package/src/lib/github.ts +31 -1
- package/src/lib/hooks.ts +4 -12
- package/src/lib/intent.ts +88 -0
- package/src/lib/jsonc.ts +125 -0
- package/src/lib/local-paths.test.ts +902 -0
- package/src/lib/local-paths.ts +258 -0
- package/src/lib/managed-deploy.ts +175 -0
- package/src/lib/managed-down.ts +159 -0
- package/src/lib/mcp-config.ts +55 -34
- package/src/lib/names.ts +9 -29
- package/src/lib/project-operations.ts +676 -249
- package/src/lib/project-resolver.ts +476 -0
- package/src/lib/registry.ts +76 -37
- package/src/lib/resources.ts +196 -0
- package/src/lib/schema.ts +30 -1
- package/src/lib/storage/file-filter.ts +1 -0
- package/src/lib/storage/index.ts +5 -1
- package/src/lib/telemetry.ts +14 -0
- package/src/lib/tty.ts +15 -0
- package/src/lib/zip-packager.ts +255 -0
- package/src/mcp/resources/index.ts +8 -2
- package/src/mcp/server.ts +32 -4
- package/src/mcp/tools/index.ts +35 -13
- package/src/mcp/types.ts +6 -0
- package/src/mcp/utils.ts +1 -1
- package/src/templates/index.ts +42 -4
- package/src/templates/types.ts +13 -0
- package/templates/CLAUDE.md +166 -0
- package/templates/api/.jack.json +4 -0
- package/templates/api/bun.lock +1 -0
- package/templates/api/wrangler.jsonc +5 -0
- package/templates/hello/.jack.json +28 -0
- package/templates/hello/package.json +10 -0
- package/templates/hello/src/index.ts +11 -0
- package/templates/hello/tsconfig.json +11 -0
- package/templates/hello/wrangler.jsonc +5 -0
- package/templates/miniapp/.jack.json +15 -4
- package/templates/miniapp/bun.lock +135 -40
- package/templates/miniapp/index.html +1 -0
- package/templates/miniapp/package.json +3 -1
- package/templates/miniapp/public/.well-known/farcaster.json +7 -5
- package/templates/miniapp/public/icon.png +0 -0
- package/templates/miniapp/public/og.png +0 -0
- package/templates/miniapp/schema.sql +8 -0
- package/templates/miniapp/src/App.tsx +254 -3
- package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
- package/templates/miniapp/src/hooks/useAI.ts +35 -0
- package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
- package/templates/miniapp/src/hooks/useShare.ts +76 -0
- package/templates/miniapp/src/index.css +15 -0
- package/templates/miniapp/src/lib/api.ts +2 -1
- package/templates/miniapp/src/worker.ts +515 -1
- package/templates/miniapp/wrangler.jsonc +15 -3
- package/LICENSE +0 -190
- package/README.md +0 -55
- package/src/commands/cloud.ts +0 -230
- package/templates/api/wrangler.toml +0 -3
|
@@ -2,14 +2,125 @@
|
|
|
2
2
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { cors } from "hono/cors";
|
|
5
|
+
import { ImageResponse } from "workers-og";
|
|
5
6
|
|
|
6
7
|
type Env = {
|
|
7
8
|
DB: D1Database;
|
|
8
9
|
NEYNAR_API_KEY: string;
|
|
9
10
|
ASSETS: Fetcher;
|
|
11
|
+
AI: Ai;
|
|
12
|
+
OPENAI_API_KEY?: string;
|
|
13
|
+
APP_URL?: string; // Production URL for share embeds (e.g., https://my-app.workers.dev)
|
|
10
14
|
};
|
|
11
15
|
|
|
16
|
+
// Get production base URL - required for valid Farcaster embeds
|
|
17
|
+
// Farcaster requires absolute https:// URLs (no localhost, no relative paths)
|
|
18
|
+
// See: https://miniapps.farcaster.xyz/docs/embeds
|
|
19
|
+
function getBaseUrl(
|
|
20
|
+
env: Env,
|
|
21
|
+
c: { req: { header: (name: string) => string | undefined; url: string } },
|
|
22
|
+
): string | null {
|
|
23
|
+
// 1. Prefer explicit APP_URL if set (most reliable for custom domains)
|
|
24
|
+
if (env.APP_URL && env.APP_URL.trim() !== "") {
|
|
25
|
+
const url = env.APP_URL.replace(/\/$/, "");
|
|
26
|
+
if (url.startsWith("https://")) {
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
// If APP_URL is set but not https, warn and continue
|
|
30
|
+
console.warn(`APP_URL should be https, got: ${url}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Use Host header (always set by Cloudflare in production)
|
|
34
|
+
const host = c.req.header("host");
|
|
35
|
+
if (host) {
|
|
36
|
+
// Reject localhost - embeds won't work in local dev
|
|
37
|
+
if (host.startsWith("localhost") || host.startsWith("127.0.0.1")) {
|
|
38
|
+
return null; // Signal that we can't generate valid embed URLs
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Get protocol: prefer cf-visitor (Cloudflare-specific), then x-forwarded-proto
|
|
42
|
+
let proto = "https";
|
|
43
|
+
const cfVisitor = c.req.header("cf-visitor");
|
|
44
|
+
if (cfVisitor) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(cfVisitor);
|
|
47
|
+
if (parsed.scheme) proto = parsed.scheme;
|
|
48
|
+
} catch {
|
|
49
|
+
// Ignore parse errors
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
proto = c.req.header("x-forwarded-proto") || "https";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Workers.dev domains are always https in production
|
|
56
|
+
if (host.endsWith(".workers.dev")) {
|
|
57
|
+
proto = "https";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${proto}://${host}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Fallback to URL origin from request
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(c.req.url);
|
|
66
|
+
// Reject localhost origins
|
|
67
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return url.origin;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Rate limiting configuration
|
|
77
|
+
const AI_RATE_LIMIT = 10; // requests per window
|
|
78
|
+
const AI_RATE_WINDOW_MS = 60_000; // 1 minute
|
|
79
|
+
|
|
80
|
+
// Check and update rate limit, returns { allowed, remaining, resetInSeconds }
|
|
81
|
+
async function checkAIRateLimit(
|
|
82
|
+
db: D1Database,
|
|
83
|
+
identifier: string,
|
|
84
|
+
): Promise<{ allowed: boolean; remaining: number; resetInSeconds: number }> {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const windowStart = Math.floor(now / AI_RATE_WINDOW_MS) * AI_RATE_WINDOW_MS;
|
|
87
|
+
|
|
88
|
+
// Atomic upsert: insert new record or update existing
|
|
89
|
+
// If window has expired, reset count to 1; otherwise increment
|
|
90
|
+
const result = await db
|
|
91
|
+
.prepare(
|
|
92
|
+
`INSERT INTO ai_rate_limits (identifier, request_count, window_start)
|
|
93
|
+
VALUES (?, 1, ?)
|
|
94
|
+
ON CONFLICT(identifier) DO UPDATE SET
|
|
95
|
+
request_count = CASE
|
|
96
|
+
WHEN window_start < ? THEN 1
|
|
97
|
+
ELSE request_count + 1
|
|
98
|
+
END,
|
|
99
|
+
window_start = CASE
|
|
100
|
+
WHEN window_start < ? THEN ?
|
|
101
|
+
ELSE window_start
|
|
102
|
+
END
|
|
103
|
+
RETURNING request_count, window_start`,
|
|
104
|
+
)
|
|
105
|
+
.bind(identifier, windowStart, windowStart, windowStart, windowStart)
|
|
106
|
+
.first<{ request_count: number; window_start: number }>();
|
|
107
|
+
|
|
108
|
+
if (!result) {
|
|
109
|
+
// Shouldn't happen, but allow the request if DB fails
|
|
110
|
+
return { allowed: true, remaining: AI_RATE_LIMIT - 1, resetInSeconds: 60 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const resetInSeconds = Math.ceil((result.window_start + AI_RATE_WINDOW_MS - now) / 1000);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
allowed: result.request_count <= AI_RATE_LIMIT,
|
|
117
|
+
remaining: Math.max(0, AI_RATE_LIMIT - result.request_count),
|
|
118
|
+
resetInSeconds: Math.max(1, resetInSeconds),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
12
122
|
const app = new Hono<{ Bindings: Env }>();
|
|
123
|
+
const AI_MODEL = "@cf/meta/llama-3.1-8b-instruct" as keyof AiModels;
|
|
13
124
|
|
|
14
125
|
// CORS for local dev
|
|
15
126
|
app.use("/api/*", cors());
|
|
@@ -36,7 +147,7 @@ app.get("/api/notifications", async (c) => {
|
|
|
36
147
|
|
|
37
148
|
if (!response.ok) {
|
|
38
149
|
const error = await response.text();
|
|
39
|
-
return c.json({ error: "Neynar API error", details: error },
|
|
150
|
+
return c.json({ error: "Neynar API error", details: error }, 502);
|
|
40
151
|
}
|
|
41
152
|
|
|
42
153
|
return c.json(await response.json());
|
|
@@ -91,6 +202,409 @@ app.post("/api/guestbook", async (c) => {
|
|
|
91
202
|
}
|
|
92
203
|
});
|
|
93
204
|
|
|
205
|
+
// POST /api/ai/analyze-profile - Analyze Farcaster profile with AI
|
|
206
|
+
// Fetches user data + recent casts from Neynar, then runs AI analysis
|
|
207
|
+
app.post("/api/ai/analyze-profile", async (c) => {
|
|
208
|
+
try {
|
|
209
|
+
const { fid } = await c.req.json<{ fid: number }>();
|
|
210
|
+
if (!fid) {
|
|
211
|
+
return c.json({ error: "fid is required" }, 400);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Rate limiting
|
|
215
|
+
const identifier =
|
|
216
|
+
c.req.header("cf-connecting-ip") || c.req.header("x-forwarded-for") || "unknown";
|
|
217
|
+
const rateLimit = await checkAIRateLimit(c.env.DB, identifier);
|
|
218
|
+
if (!rateLimit.allowed) {
|
|
219
|
+
return c.json(
|
|
220
|
+
{ error: `Rate limit exceeded. Try again in ${rateLimit.resetInSeconds} seconds.` },
|
|
221
|
+
429,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fetch user profile from Neynar
|
|
226
|
+
const userRes = await fetch(`https://api.neynar.com/v2/farcaster/user/bulk?fids=${fid}`, {
|
|
227
|
+
headers: { "x-api-key": c.env.NEYNAR_API_KEY },
|
|
228
|
+
});
|
|
229
|
+
const userData = (await userRes.json()) as {
|
|
230
|
+
users?: Array<{
|
|
231
|
+
username: string;
|
|
232
|
+
display_name: string;
|
|
233
|
+
profile?: { bio?: { text?: string } };
|
|
234
|
+
follower_count?: number;
|
|
235
|
+
following_count?: number;
|
|
236
|
+
power_badge?: boolean;
|
|
237
|
+
}>;
|
|
238
|
+
};
|
|
239
|
+
const user = userData.users?.[0];
|
|
240
|
+
|
|
241
|
+
// Fetch recent casts from Neynar
|
|
242
|
+
const castsRes = await fetch(
|
|
243
|
+
`https://api.neynar.com/v2/farcaster/feed/user/${fid}/replies_and_recasts?limit=5`,
|
|
244
|
+
{ headers: { "x-api-key": c.env.NEYNAR_API_KEY } },
|
|
245
|
+
);
|
|
246
|
+
const castsData = (await castsRes.json()) as {
|
|
247
|
+
casts?: Array<{ text: string }>;
|
|
248
|
+
};
|
|
249
|
+
const recentCasts = castsData.casts?.slice(0, 5).map((c) => c.text) || [];
|
|
250
|
+
|
|
251
|
+
// Build rich prompt
|
|
252
|
+
const prompt = `Analyze this Farcaster user and categorize them. Return ONLY valid JSON.
|
|
253
|
+
|
|
254
|
+
USER PROFILE:
|
|
255
|
+
- Username: ${user?.username || "unknown"}
|
|
256
|
+
- Display name: ${user?.display_name || "unknown"}
|
|
257
|
+
- Bio: ${user?.profile?.bio?.text || "No bio"}
|
|
258
|
+
- Followers: ${user?.follower_count || 0}
|
|
259
|
+
- Following: ${user?.following_count || 0}
|
|
260
|
+
- Power badge: ${user?.power_badge ? "Yes" : "No"}
|
|
261
|
+
|
|
262
|
+
RECENT CASTS:
|
|
263
|
+
${recentCasts.length > 0 ? recentCasts.map((c, i) => `${i + 1}. "${c.slice(0, 200)}"`).join("\n") : "No recent casts"}
|
|
264
|
+
|
|
265
|
+
CATEGORIES (pick exactly one):
|
|
266
|
+
- builder: Ships code, creates tools, technical content
|
|
267
|
+
- creator: Makes art, memes, threads, media content
|
|
268
|
+
- collector: NFTs, tokens, digital collectibles focus
|
|
269
|
+
- connector: Community building, networking, introductions
|
|
270
|
+
- lurker: Mostly observes, occasional engagement
|
|
271
|
+
|
|
272
|
+
Return: {"category": "one_of_above", "reason": "2-3 sentence explanation based on their bio and casts"}`;
|
|
273
|
+
|
|
274
|
+
// Call AI
|
|
275
|
+
let result = "";
|
|
276
|
+
let provider: "openai" | "workers-ai" = "workers-ai";
|
|
277
|
+
|
|
278
|
+
if (c.env.OPENAI_API_KEY) {
|
|
279
|
+
try {
|
|
280
|
+
const { OpenAI } = await import("openai");
|
|
281
|
+
const openai = new OpenAI({ apiKey: c.env.OPENAI_API_KEY, timeout: 15_000 });
|
|
282
|
+
const completion = await openai.chat.completions.create({
|
|
283
|
+
model: "gpt-4o-mini",
|
|
284
|
+
messages: [{ role: "user", content: prompt }],
|
|
285
|
+
});
|
|
286
|
+
result = completion.choices[0]?.message?.content || "";
|
|
287
|
+
provider = "openai";
|
|
288
|
+
} catch {
|
|
289
|
+
// Fall through to Workers AI
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!result) {
|
|
294
|
+
const response = (await c.env.AI.run(AI_MODEL, {
|
|
295
|
+
prompt,
|
|
296
|
+
})) as { response: string };
|
|
297
|
+
result = response.response || "";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Parse and validate JSON response
|
|
301
|
+
try {
|
|
302
|
+
const parsed = JSON.parse(result);
|
|
303
|
+
return c.json({ ...parsed, provider });
|
|
304
|
+
} catch {
|
|
305
|
+
return c.json({ category: "unknown", reason: result, provider });
|
|
306
|
+
}
|
|
307
|
+
} catch (error) {
|
|
308
|
+
return c.json(
|
|
309
|
+
{ error: "Analysis failed", details: error instanceof Error ? error.message : String(error) },
|
|
310
|
+
500,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// POST /api/ai/generate - AI text generation
|
|
316
|
+
// Rate limited to 10 requests/minute per IP
|
|
317
|
+
// Cost: Workers AI is free; OpenAI gpt-4o-mini ~$0.0001/request
|
|
318
|
+
app.post("/api/ai/generate", async (c) => {
|
|
319
|
+
try {
|
|
320
|
+
// Rate limiting by IP address
|
|
321
|
+
const identifier =
|
|
322
|
+
c.req.header("cf-connecting-ip") || c.req.header("x-forwarded-for") || "unknown";
|
|
323
|
+
const rateLimit = await checkAIRateLimit(c.env.DB, identifier);
|
|
324
|
+
|
|
325
|
+
if (!rateLimit.allowed) {
|
|
326
|
+
return c.json(
|
|
327
|
+
{
|
|
328
|
+
error: `Rate limit exceeded. Try again in ${rateLimit.resetInSeconds} seconds.`,
|
|
329
|
+
retryAfter: rateLimit.resetInSeconds,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
status: 429,
|
|
333
|
+
headers: {
|
|
334
|
+
"Retry-After": String(rateLimit.resetInSeconds),
|
|
335
|
+
"X-RateLimit-Limit": String(AI_RATE_LIMIT),
|
|
336
|
+
"X-RateLimit-Remaining": "0",
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const body = await c.req.json<{
|
|
343
|
+
prompt: string;
|
|
344
|
+
schema?: object;
|
|
345
|
+
}>();
|
|
346
|
+
|
|
347
|
+
// Validate prompt
|
|
348
|
+
if (!body.prompt || typeof body.prompt !== "string") {
|
|
349
|
+
return c.json({ error: "prompt is required and must be a string" }, 400);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const prompt = body.prompt.trim();
|
|
353
|
+
if (!prompt) {
|
|
354
|
+
return c.json({ error: "prompt cannot be empty" }, 400);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Try OpenAI first if key is available
|
|
358
|
+
if (c.env.OPENAI_API_KEY) {
|
|
359
|
+
try {
|
|
360
|
+
// Dynamic import to avoid bundling issues
|
|
361
|
+
const { OpenAI } = await import("openai");
|
|
362
|
+
const openai = new OpenAI({
|
|
363
|
+
apiKey: c.env.OPENAI_API_KEY,
|
|
364
|
+
timeout: 15_000, // 15 second timeout
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const params = {
|
|
368
|
+
model: "gpt-4o-mini" as const,
|
|
369
|
+
messages: [{ role: "user" as const, content: prompt }],
|
|
370
|
+
...(body.schema && {
|
|
371
|
+
response_format: {
|
|
372
|
+
type: "json_schema" as const,
|
|
373
|
+
json_schema: {
|
|
374
|
+
name: "response",
|
|
375
|
+
schema: body.schema as Record<string, unknown>,
|
|
376
|
+
strict: true,
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
} as const;
|
|
381
|
+
|
|
382
|
+
const completion = await openai.chat.completions.create(params as any);
|
|
383
|
+
const result = completion.choices[0]?.message?.content || "";
|
|
384
|
+
|
|
385
|
+
return c.json(
|
|
386
|
+
{ result, provider: "openai" },
|
|
387
|
+
{
|
|
388
|
+
headers: {
|
|
389
|
+
"X-RateLimit-Limit": String(AI_RATE_LIMIT),
|
|
390
|
+
"X-RateLimit-Remaining": String(rateLimit.remaining),
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
// If OpenAI fails (timeout, rate limit, etc.), fall back to Workers AI
|
|
396
|
+
console.error(
|
|
397
|
+
"OpenAI failed, falling back to Workers AI:",
|
|
398
|
+
error instanceof Error ? error.message : String(error),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Fallback to Workers AI (free, always available)
|
|
404
|
+
try {
|
|
405
|
+
const response = (await c.env.AI.run(AI_MODEL, {
|
|
406
|
+
prompt,
|
|
407
|
+
})) as { response: string };
|
|
408
|
+
|
|
409
|
+
return c.json(
|
|
410
|
+
{ result: response.response || "", provider: "workers-ai" },
|
|
411
|
+
{
|
|
412
|
+
headers: {
|
|
413
|
+
"X-RateLimit-Limit": String(AI_RATE_LIMIT),
|
|
414
|
+
"X-RateLimit-Remaining": String(rateLimit.remaining),
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return c.json(
|
|
420
|
+
{
|
|
421
|
+
error: "AI generation failed. Workers AI might be temporarily unavailable.",
|
|
422
|
+
details: error instanceof Error ? error.message : String(error),
|
|
423
|
+
},
|
|
424
|
+
500,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return c.json(
|
|
429
|
+
{
|
|
430
|
+
error: "Failed to process AI request",
|
|
431
|
+
details: error instanceof Error ? error.message : String(error),
|
|
432
|
+
},
|
|
433
|
+
500,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// GET /api/og - Generate dynamic Open Graph image for sharing
|
|
439
|
+
app.get("/api/og", async (c) => {
|
|
440
|
+
const username = c.req.query("username") || "someone";
|
|
441
|
+
const displayName = c.req.query("displayName") || username;
|
|
442
|
+
const pfpUrl = c.req.query("pfpUrl");
|
|
443
|
+
const message = c.req.query("message") || "signed the guestbook!";
|
|
444
|
+
const appName = c.req.query("appName") || "jack-template";
|
|
445
|
+
|
|
446
|
+
// Fetch and convert pfp to base64 if provided
|
|
447
|
+
// Use timeout to prevent hanging on slow/unreachable URLs
|
|
448
|
+
let pfpBase64 = "";
|
|
449
|
+
if (pfpUrl) {
|
|
450
|
+
try {
|
|
451
|
+
const controller = new AbortController();
|
|
452
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
|
453
|
+
|
|
454
|
+
const pfpResponse = await fetch(pfpUrl, { signal: controller.signal });
|
|
455
|
+
clearTimeout(timeoutId);
|
|
456
|
+
|
|
457
|
+
if (pfpResponse.ok) {
|
|
458
|
+
const buffer = await pfpResponse.arrayBuffer();
|
|
459
|
+
// Skip images > 500KB to prevent memory issues
|
|
460
|
+
if (buffer.byteLength < 500_000) {
|
|
461
|
+
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
|
|
462
|
+
const contentType = pfpResponse.headers.get("content-type") || "image/png";
|
|
463
|
+
pfpBase64 = `data:${contentType};base64,${base64}`;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} catch {
|
|
467
|
+
// Ignore pfp fetch errors (timeout, network, etc.) - show placeholder instead
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Escape HTML entities for safety (including quotes for attribute values)
|
|
472
|
+
const escapeHtml = (str: string) =>
|
|
473
|
+
str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
474
|
+
|
|
475
|
+
// Truncate message for display (shorter for larger font size)
|
|
476
|
+
const truncatedMessage = message.length > 50 ? `${message.slice(0, 47)}...` : message;
|
|
477
|
+
|
|
478
|
+
// Profile picture HTML (larger for 1200x630 canvas)
|
|
479
|
+
const pfpHtml = pfpBase64
|
|
480
|
+
? `<img src="${pfpBase64}" width="100" height="100" style="border-radius: 50%; border: 3px solid #7c3aed; margin-right: 24px;" />`
|
|
481
|
+
: `<div style="width: 100px; height: 100px; border-radius: 50%; background: #3f3f46; border: 3px solid #7c3aed; display: flex; align-items: center; justify-content: center; font-size: 40px; margin-right: 24px;">👤</div>`;
|
|
482
|
+
|
|
483
|
+
// Standard OG image size (1200x630) with extra left padding for Farcaster's crop behavior
|
|
484
|
+
const html = `
|
|
485
|
+
<div style="width: 1200px; height: 630px; display: flex; flex-direction: column; background: linear-gradient(135deg, #18181b 0%, #27272a 100%); padding: 60px 60px 60px 180px; font-family: system-ui, sans-serif;">
|
|
486
|
+
<div style="display: flex; align-items: center; margin-bottom: 40px;">
|
|
487
|
+
${pfpHtml}
|
|
488
|
+
<div style="display: flex; flex-direction: column;">
|
|
489
|
+
<span style="font-size: 36px; font-weight: 600; color: #fafafa;">${escapeHtml(displayName)}</span>
|
|
490
|
+
<span style="font-size: 24px; color: #a1a1aa;">@${escapeHtml(username)}</span>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
<div style="flex: 1; display: flex; align-items: center;">
|
|
494
|
+
<p style="font-size: 40px; color: #e4e4e7; margin: 0;">"${escapeHtml(truncatedMessage)}"</p>
|
|
495
|
+
</div>
|
|
496
|
+
<div style="display: flex; justify-content: space-between; align-items: flex-end;">
|
|
497
|
+
<span style="font-size: 20px; color: #71717a;">${escapeHtml(appName)}</span>
|
|
498
|
+
<span style="font-size: 16px; color: #52525b;">powered by getjack.org</span>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
`;
|
|
502
|
+
|
|
503
|
+
const response = new ImageResponse(html, {
|
|
504
|
+
width: 1200,
|
|
505
|
+
height: 630,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// Cache for 1 day - OG images are immutable once shared
|
|
509
|
+
response.headers.set("Cache-Control", "public, max-age=86400, immutable");
|
|
510
|
+
return response;
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// GET /share - Shareable page with fc:miniapp meta tags for viral embedding
|
|
514
|
+
// When this URL is shared in a cast, Farcaster renders it as a clickable miniapp card
|
|
515
|
+
app.get("/share", (c) => {
|
|
516
|
+
const url = new URL(c.req.url);
|
|
517
|
+
const username = url.searchParams.get("username") || "someone";
|
|
518
|
+
const displayName = url.searchParams.get("displayName") || username;
|
|
519
|
+
const message = url.searchParams.get("message") || "signed the guestbook!";
|
|
520
|
+
const appName = url.searchParams.get("appName") || "jack-template";
|
|
521
|
+
|
|
522
|
+
// Get production URL - returns null in local dev (localhost not valid for embeds)
|
|
523
|
+
const baseUrl = getBaseUrl(c.env, c);
|
|
524
|
+
|
|
525
|
+
// Local development: show helpful error instead of broken embed
|
|
526
|
+
if (!baseUrl) {
|
|
527
|
+
const errorHtml = `<!DOCTYPE html>
|
|
528
|
+
<html lang="en">
|
|
529
|
+
<head>
|
|
530
|
+
<meta charset="UTF-8">
|
|
531
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
532
|
+
<title>Share Preview (Local Dev)</title>
|
|
533
|
+
<style>
|
|
534
|
+
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
|
|
535
|
+
.warning { background: #fef3c7; border: 1px solid #f59e0b; padding: 16px; border-radius: 8px; }
|
|
536
|
+
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }
|
|
537
|
+
</style>
|
|
538
|
+
</head>
|
|
539
|
+
<body>
|
|
540
|
+
<div class="warning">
|
|
541
|
+
<h2>⚠️ Share embeds require production deployment</h2>
|
|
542
|
+
<p>Farcaster embeds need valid <code>https://</code> URLs. Localhost URLs won't work.</p>
|
|
543
|
+
<p><strong>To test sharing:</strong></p>
|
|
544
|
+
<ol>
|
|
545
|
+
<li>Deploy with <code>jack ship</code></li>
|
|
546
|
+
<li>Set <code>APP_URL</code> in wrangler.jsonc to your deployed URL</li>
|
|
547
|
+
<li>Or access via your <code>*.workers.dev</code> URL</li>
|
|
548
|
+
</ol>
|
|
549
|
+
</div>
|
|
550
|
+
<h3>Preview data:</h3>
|
|
551
|
+
<ul>
|
|
552
|
+
<li>User: ${displayName} (@${username})</li>
|
|
553
|
+
<li>Message: "${message}"</li>
|
|
554
|
+
<li>App: ${appName}</li>
|
|
555
|
+
</ul>
|
|
556
|
+
</body>
|
|
557
|
+
</html>`;
|
|
558
|
+
return c.html(errorHtml, 200);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Build the OG image URL with same params
|
|
562
|
+
const ogParams = new URLSearchParams();
|
|
563
|
+
ogParams.set("username", username);
|
|
564
|
+
ogParams.set("displayName", displayName);
|
|
565
|
+
ogParams.set("message", message);
|
|
566
|
+
ogParams.set("appName", appName);
|
|
567
|
+
const pfpUrl = url.searchParams.get("pfpUrl");
|
|
568
|
+
if (pfpUrl) ogParams.set("pfpUrl", pfpUrl);
|
|
569
|
+
|
|
570
|
+
const ogImageUrl = `${baseUrl}/api/og?${ogParams.toString()}`;
|
|
571
|
+
|
|
572
|
+
// fc:miniapp meta tag for Farcaster embed
|
|
573
|
+
const miniappMeta = JSON.stringify({
|
|
574
|
+
version: "1",
|
|
575
|
+
imageUrl: ogImageUrl,
|
|
576
|
+
button: {
|
|
577
|
+
title: "Open App",
|
|
578
|
+
action: {
|
|
579
|
+
type: "launch_miniapp",
|
|
580
|
+
name: appName,
|
|
581
|
+
url: baseUrl,
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const html = `<!DOCTYPE html>
|
|
587
|
+
<html lang="en">
|
|
588
|
+
<head>
|
|
589
|
+
<meta charset="UTF-8">
|
|
590
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
591
|
+
<title>${displayName} signed ${appName}</title>
|
|
592
|
+
<meta name="fc:miniapp" content='${miniappMeta}' />
|
|
593
|
+
<meta property="og:title" content="${displayName} signed the guestbook" />
|
|
594
|
+
<meta property="og:description" content="${message}" />
|
|
595
|
+
<meta property="og:image" content="${ogImageUrl}" />
|
|
596
|
+
</head>
|
|
597
|
+
<body>
|
|
598
|
+
<script>window.location.href = "${baseUrl}";</script>
|
|
599
|
+
<p>Redirecting to ${appName}...</p>
|
|
600
|
+
</body>
|
|
601
|
+
</html>`;
|
|
602
|
+
|
|
603
|
+
return c.html(html, 200, {
|
|
604
|
+
"Cache-Control": "public, max-age=86400",
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
94
608
|
// Serve React app for all other routes
|
|
95
609
|
app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw));
|
|
96
610
|
|
|
@@ -3,14 +3,26 @@
|
|
|
3
3
|
"main": "src/worker.ts",
|
|
4
4
|
"compatibility_date": "2024-12-01",
|
|
5
5
|
"assets": {
|
|
6
|
-
"directory": "./dist",
|
|
7
6
|
"binding": "ASSETS",
|
|
8
|
-
"
|
|
7
|
+
"directory": "dist/client",
|
|
8
|
+
"not_found_handling": "single-page-application",
|
|
9
|
+
// Required for dynamic routes (/share, /api/og) to work alongside static assets
|
|
10
|
+
// Without this, Cloudflare serves static files directly, bypassing the worker
|
|
11
|
+
"run_worker_first": true
|
|
9
12
|
},
|
|
10
13
|
"d1_databases": [
|
|
11
14
|
{
|
|
12
15
|
"binding": "DB",
|
|
13
16
|
"database_name": "jack-template-db"
|
|
14
17
|
}
|
|
15
|
-
]
|
|
18
|
+
],
|
|
19
|
+
"ai": {
|
|
20
|
+
"binding": "AI"
|
|
21
|
+
},
|
|
22
|
+
"vars": {
|
|
23
|
+
// Set this after first deploy - required for share embeds
|
|
24
|
+
// Get your URL from: jack projects or wrangler deployments list
|
|
25
|
+
// Example: "APP_URL": "https://my-app.username.workers.dev"
|
|
26
|
+
"APP_URL": ""
|
|
27
|
+
}
|
|
16
28
|
}
|