@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.
Files changed (91) hide show
  1. package/package.json +54 -47
  2. package/src/commands/agents.ts +145 -10
  3. package/src/commands/down.ts +110 -102
  4. package/src/commands/feedback.ts +189 -0
  5. package/src/commands/init.ts +8 -12
  6. package/src/commands/login.ts +88 -0
  7. package/src/commands/logout.ts +14 -0
  8. package/src/commands/logs.ts +21 -0
  9. package/src/commands/mcp.ts +134 -7
  10. package/src/commands/new.ts +43 -17
  11. package/src/commands/open.ts +13 -6
  12. package/src/commands/projects.ts +269 -143
  13. package/src/commands/secrets.ts +413 -0
  14. package/src/commands/services.ts +96 -123
  15. package/src/commands/ship.ts +5 -1
  16. package/src/commands/whoami.ts +31 -0
  17. package/src/index.ts +218 -144
  18. package/src/lib/agent-files.ts +34 -0
  19. package/src/lib/agents.ts +390 -22
  20. package/src/lib/asset-hash.ts +50 -0
  21. package/src/lib/auth/client.ts +115 -0
  22. package/src/lib/auth/constants.ts +5 -0
  23. package/src/lib/auth/guard.ts +57 -0
  24. package/src/lib/auth/index.ts +18 -0
  25. package/src/lib/auth/store.ts +54 -0
  26. package/src/lib/binding-validator.ts +136 -0
  27. package/src/lib/build-helper.ts +211 -0
  28. package/src/lib/cloudflare-api.ts +24 -0
  29. package/src/lib/config.ts +5 -6
  30. package/src/lib/control-plane.ts +295 -0
  31. package/src/lib/debug.ts +3 -1
  32. package/src/lib/deploy-mode.ts +93 -0
  33. package/src/lib/deploy-upload.ts +92 -0
  34. package/src/lib/errors.ts +2 -0
  35. package/src/lib/github.ts +31 -1
  36. package/src/lib/hooks.ts +4 -12
  37. package/src/lib/intent.ts +88 -0
  38. package/src/lib/jsonc.ts +125 -0
  39. package/src/lib/local-paths.test.ts +902 -0
  40. package/src/lib/local-paths.ts +258 -0
  41. package/src/lib/managed-deploy.ts +175 -0
  42. package/src/lib/managed-down.ts +159 -0
  43. package/src/lib/mcp-config.ts +55 -34
  44. package/src/lib/names.ts +9 -29
  45. package/src/lib/project-operations.ts +676 -249
  46. package/src/lib/project-resolver.ts +476 -0
  47. package/src/lib/registry.ts +76 -37
  48. package/src/lib/resources.ts +196 -0
  49. package/src/lib/schema.ts +30 -1
  50. package/src/lib/storage/file-filter.ts +1 -0
  51. package/src/lib/storage/index.ts +5 -1
  52. package/src/lib/telemetry.ts +14 -0
  53. package/src/lib/tty.ts +15 -0
  54. package/src/lib/zip-packager.ts +255 -0
  55. package/src/mcp/resources/index.ts +8 -2
  56. package/src/mcp/server.ts +32 -4
  57. package/src/mcp/tools/index.ts +35 -13
  58. package/src/mcp/types.ts +6 -0
  59. package/src/mcp/utils.ts +1 -1
  60. package/src/templates/index.ts +42 -4
  61. package/src/templates/types.ts +13 -0
  62. package/templates/CLAUDE.md +166 -0
  63. package/templates/api/.jack.json +4 -0
  64. package/templates/api/bun.lock +1 -0
  65. package/templates/api/wrangler.jsonc +5 -0
  66. package/templates/hello/.jack.json +28 -0
  67. package/templates/hello/package.json +10 -0
  68. package/templates/hello/src/index.ts +11 -0
  69. package/templates/hello/tsconfig.json +11 -0
  70. package/templates/hello/wrangler.jsonc +5 -0
  71. package/templates/miniapp/.jack.json +15 -4
  72. package/templates/miniapp/bun.lock +135 -40
  73. package/templates/miniapp/index.html +1 -0
  74. package/templates/miniapp/package.json +3 -1
  75. package/templates/miniapp/public/.well-known/farcaster.json +7 -5
  76. package/templates/miniapp/public/icon.png +0 -0
  77. package/templates/miniapp/public/og.png +0 -0
  78. package/templates/miniapp/schema.sql +8 -0
  79. package/templates/miniapp/src/App.tsx +254 -3
  80. package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
  81. package/templates/miniapp/src/hooks/useAI.ts +35 -0
  82. package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
  83. package/templates/miniapp/src/hooks/useShare.ts +76 -0
  84. package/templates/miniapp/src/index.css +15 -0
  85. package/templates/miniapp/src/lib/api.ts +2 -1
  86. package/templates/miniapp/src/worker.ts +515 -1
  87. package/templates/miniapp/wrangler.jsonc +15 -3
  88. package/LICENSE +0 -190
  89. package/README.md +0 -55
  90. package/src/commands/cloud.ts +0 -230
  91. package/templates/api/wrangler.toml +0 -3
@@ -1 +1,16 @@
1
1
  @import "tailwindcss";
2
+
3
+ @keyframes slide-up {
4
+ from {
5
+ transform: translateY(100%);
6
+ opacity: 0;
7
+ }
8
+ to {
9
+ transform: translateY(0);
10
+ opacity: 1;
11
+ }
12
+ }
13
+
14
+ .animate-slide-up {
15
+ animation: slide-up 0.2s ease-out;
16
+ }
@@ -2,4 +2,5 @@ import { hc } from "hono/client";
2
2
  import type { AppType } from "../worker";
3
3
 
4
4
  // Type-safe API client powered by Hono RPC
5
- export const api = hc<AppType>("/");
5
+ type ApiClient = ReturnType<typeof hc<AppType>>;
6
+ export const api: ApiClient = hc<AppType>("/");
@@ -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 }, response.status as any);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
- "not_found_handling": "single-page-application"
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
  }