@getjack/jack 0.1.32 → 0.1.33
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 +1 -1
- package/src/commands/deploys.ts +95 -0
- package/src/commands/link.ts +8 -0
- package/src/commands/mcp.ts +179 -4
- package/src/commands/rollback.ts +53 -0
- package/src/commands/services.ts +11 -1
- package/src/commands/ship.ts +3 -1
- package/src/commands/tokens.ts +16 -1
- package/src/commands/whoami.ts +43 -8
- package/src/index.ts +16 -0
- package/src/lib/agent-files.ts +54 -4
- package/src/lib/agent-integration.ts +4 -166
- package/src/lib/claude-hooks-installer.ts +55 -0
- package/src/lib/control-plane.ts +78 -40
- package/src/lib/debug.ts +2 -1
- package/src/lib/deploy-upload.ts +6 -0
- package/src/lib/hooks.ts +3 -1
- package/src/lib/managed-deploy.ts +12 -9
- package/src/lib/project-link.ts +6 -0
- package/src/lib/project-operations.ts +68 -22
- package/src/lib/telemetry.ts +2 -0
- package/src/mcp/README.md +1 -1
- package/src/mcp/resources/index.ts +1 -16
- package/src/mcp/server.ts +23 -0
- package/src/mcp/tools/index.ts +133 -17
- package/src/mcp/types.ts +1 -0
- package/src/mcp/utils.ts +2 -1
- package/src/templates/index.ts +25 -73
- package/templates/CLAUDE.md +41 -0
- package/templates/ai-chat/.jack.json +10 -5
- package/templates/ai-chat/bun.lock +50 -1
- package/templates/ai-chat/package.json +5 -0
- package/templates/ai-chat/public/app.js +73 -0
- package/templates/ai-chat/public/index.html +14 -197
- package/templates/ai-chat/schema.sql +14 -0
- package/templates/ai-chat/src/index.ts +86 -102
- package/templates/ai-chat/wrangler.jsonc +8 -1
- package/templates/cron/.jack.json +66 -0
- package/templates/cron/bun.lock +23 -0
- package/templates/cron/package.json +16 -0
- package/templates/cron/schema.sql +24 -0
- package/templates/cron/src/index.ts +117 -0
- package/templates/cron/src/jobs.ts +139 -0
- package/templates/cron/src/webhooks.ts +95 -0
- package/templates/cron/tsconfig.json +17 -0
- package/templates/cron/wrangler.jsonc +11 -0
- package/templates/miniapp/.jack.json +1 -1
- package/templates/nextjs/.jack.json +1 -1
- package/templates/nextjs-auth/.jack.json +44 -0
- package/templates/nextjs-auth/app/api/auth/[...all]/route.ts +11 -0
- package/templates/nextjs-auth/app/dashboard/loading.tsx +53 -0
- package/templates/nextjs-auth/app/dashboard/page.tsx +73 -0
- package/templates/nextjs-auth/app/error.tsx +44 -0
- package/templates/nextjs-auth/app/globals.css +1 -0
- package/templates/nextjs-auth/app/health/route.ts +3 -0
- package/templates/nextjs-auth/app/layout.tsx +24 -0
- package/templates/nextjs-auth/app/login/page.tsx +10 -0
- package/templates/nextjs-auth/app/page.tsx +86 -0
- package/templates/nextjs-auth/app/signup/page.tsx +10 -0
- package/templates/nextjs-auth/bun.lock +1065 -0
- package/templates/nextjs-auth/cloudflare-env.d.ts +8 -0
- package/templates/nextjs-auth/components/auth-form.tsx +191 -0
- package/templates/nextjs-auth/components/header.tsx +50 -0
- package/templates/nextjs-auth/components/user-menu.tsx +23 -0
- package/templates/nextjs-auth/lib/auth-client.ts +3 -0
- package/templates/nextjs-auth/lib/auth.ts +43 -0
- package/templates/nextjs-auth/lib/utils.ts +6 -0
- package/templates/nextjs-auth/middleware.ts +33 -0
- package/templates/nextjs-auth/next.config.ts +8 -0
- package/templates/nextjs-auth/open-next.config.ts +6 -0
- package/templates/nextjs-auth/package.json +33 -0
- package/templates/nextjs-auth/postcss.config.mjs +8 -0
- package/templates/nextjs-auth/schema.sql +49 -0
- package/templates/nextjs-auth/tsconfig.json +28 -0
- package/templates/nextjs-auth/wrangler.jsonc +23 -0
- package/templates/nextjs-clerk/.jack.json +54 -0
- package/templates/nextjs-clerk/app/dashboard/page.tsx +69 -0
- package/templates/nextjs-clerk/app/globals.css +1 -0
- package/templates/nextjs-clerk/app/health/route.ts +3 -0
- package/templates/nextjs-clerk/app/layout.tsx +26 -0
- package/templates/nextjs-clerk/app/page.tsx +86 -0
- package/templates/nextjs-clerk/app/sign-in/[[...sign-in]]/page.tsx +9 -0
- package/templates/nextjs-clerk/app/sign-up/[[...sign-up]]/page.tsx +9 -0
- package/templates/nextjs-clerk/bun.lock +1055 -0
- package/templates/nextjs-clerk/cloudflare-env.d.ts +3 -0
- package/templates/nextjs-clerk/components/header.tsx +40 -0
- package/templates/nextjs-clerk/lib/utils.ts +6 -0
- package/templates/nextjs-clerk/middleware.ts +18 -0
- package/templates/nextjs-clerk/next.config.ts +8 -0
- package/templates/nextjs-clerk/open-next.config.ts +6 -0
- package/templates/nextjs-clerk/package.json +31 -0
- package/templates/nextjs-clerk/postcss.config.mjs +8 -0
- package/templates/nextjs-clerk/tsconfig.json +28 -0
- package/templates/nextjs-clerk/wrangler.jsonc +17 -0
- package/templates/nextjs-shadcn/.jack.json +34 -0
- package/templates/nextjs-shadcn/app/dashboard/data.json +614 -0
- package/templates/nextjs-shadcn/app/dashboard/page.tsx +55 -0
- package/templates/nextjs-shadcn/app/globals.css +126 -0
- package/templates/nextjs-shadcn/app/health/route.ts +3 -0
- package/templates/nextjs-shadcn/app/layout.tsx +24 -0
- package/templates/nextjs-shadcn/app/login/page.tsx +19 -0
- package/templates/nextjs-shadcn/app/page.tsx +180 -0
- package/templates/nextjs-shadcn/app/showcase.tsx +1262 -0
- package/templates/nextjs-shadcn/bun.lock +1789 -0
- package/templates/nextjs-shadcn/cloudflare-env.d.ts +4 -0
- package/templates/nextjs-shadcn/components/app-sidebar.tsx +175 -0
- package/templates/nextjs-shadcn/components/chart-area-interactive.tsx +291 -0
- package/templates/nextjs-shadcn/components/data-table.tsx +807 -0
- package/templates/nextjs-shadcn/components/login-form.tsx +95 -0
- package/templates/nextjs-shadcn/components/nav-documents.tsx +92 -0
- package/templates/nextjs-shadcn/components/nav-main.tsx +73 -0
- package/templates/nextjs-shadcn/components/nav-projects.tsx +89 -0
- package/templates/nextjs-shadcn/components/nav-secondary.tsx +42 -0
- package/templates/nextjs-shadcn/components/nav-user.tsx +114 -0
- package/templates/nextjs-shadcn/components/section-cards.tsx +102 -0
- package/templates/nextjs-shadcn/components/site-header.tsx +30 -0
- package/templates/nextjs-shadcn/components/team-switcher.tsx +91 -0
- package/templates/nextjs-shadcn/components/ui/accordion.tsx +66 -0
- package/templates/nextjs-shadcn/components/ui/alert-dialog.tsx +196 -0
- package/templates/nextjs-shadcn/components/ui/alert.tsx +66 -0
- package/templates/nextjs-shadcn/components/ui/aspect-ratio.tsx +11 -0
- package/templates/nextjs-shadcn/components/ui/avatar.tsx +109 -0
- package/templates/nextjs-shadcn/components/ui/badge.tsx +48 -0
- package/templates/nextjs-shadcn/components/ui/breadcrumb.tsx +109 -0
- package/templates/nextjs-shadcn/components/ui/button-group.tsx +83 -0
- package/templates/nextjs-shadcn/components/ui/button.tsx +64 -0
- package/templates/nextjs-shadcn/components/ui/calendar.tsx +220 -0
- package/templates/nextjs-shadcn/components/ui/card.tsx +92 -0
- package/templates/nextjs-shadcn/components/ui/carousel.tsx +241 -0
- package/templates/nextjs-shadcn/components/ui/chart.tsx +357 -0
- package/templates/nextjs-shadcn/components/ui/checkbox.tsx +32 -0
- package/templates/nextjs-shadcn/components/ui/collapsible.tsx +33 -0
- package/templates/nextjs-shadcn/components/ui/combobox.tsx +310 -0
- package/templates/nextjs-shadcn/components/ui/command.tsx +184 -0
- package/templates/nextjs-shadcn/components/ui/context-menu.tsx +252 -0
- package/templates/nextjs-shadcn/components/ui/dialog.tsx +158 -0
- package/templates/nextjs-shadcn/components/ui/direction.tsx +22 -0
- package/templates/nextjs-shadcn/components/ui/drawer.tsx +135 -0
- package/templates/nextjs-shadcn/components/ui/dropdown-menu.tsx +257 -0
- package/templates/nextjs-shadcn/components/ui/empty.tsx +104 -0
- package/templates/nextjs-shadcn/components/ui/field.tsx +248 -0
- package/templates/nextjs-shadcn/components/ui/form.tsx +167 -0
- package/templates/nextjs-shadcn/components/ui/hover-card.tsx +44 -0
- package/templates/nextjs-shadcn/components/ui/input-group.tsx +170 -0
- package/templates/nextjs-shadcn/components/ui/input-otp.tsx +77 -0
- package/templates/nextjs-shadcn/components/ui/input.tsx +21 -0
- package/templates/nextjs-shadcn/components/ui/item.tsx +193 -0
- package/templates/nextjs-shadcn/components/ui/kbd.tsx +28 -0
- package/templates/nextjs-shadcn/components/ui/label.tsx +24 -0
- package/templates/nextjs-shadcn/components/ui/menubar.tsx +276 -0
- package/templates/nextjs-shadcn/components/ui/native-select.tsx +53 -0
- package/templates/nextjs-shadcn/components/ui/navigation-menu.tsx +168 -0
- package/templates/nextjs-shadcn/components/ui/pagination.tsx +127 -0
- package/templates/nextjs-shadcn/components/ui/popover.tsx +89 -0
- package/templates/nextjs-shadcn/components/ui/progress.tsx +31 -0
- package/templates/nextjs-shadcn/components/ui/radio-group.tsx +45 -0
- package/templates/nextjs-shadcn/components/ui/resizable.tsx +53 -0
- package/templates/nextjs-shadcn/components/ui/scroll-area.tsx +58 -0
- package/templates/nextjs-shadcn/components/ui/select.tsx +190 -0
- package/templates/nextjs-shadcn/components/ui/separator.tsx +28 -0
- package/templates/nextjs-shadcn/components/ui/sheet.tsx +143 -0
- package/templates/nextjs-shadcn/components/ui/sidebar.tsx +726 -0
- package/templates/nextjs-shadcn/components/ui/skeleton.tsx +13 -0
- package/templates/nextjs-shadcn/components/ui/slider.tsx +63 -0
- package/templates/nextjs-shadcn/components/ui/sonner.tsx +40 -0
- package/templates/nextjs-shadcn/components/ui/spinner.tsx +16 -0
- package/templates/nextjs-shadcn/components/ui/switch.tsx +35 -0
- package/templates/nextjs-shadcn/components/ui/table.tsx +116 -0
- package/templates/nextjs-shadcn/components/ui/tabs.tsx +91 -0
- package/templates/nextjs-shadcn/components/ui/textarea.tsx +18 -0
- package/templates/nextjs-shadcn/components/ui/toggle-group.tsx +83 -0
- package/templates/nextjs-shadcn/components/ui/toggle.tsx +47 -0
- package/templates/nextjs-shadcn/components/ui/tooltip.tsx +57 -0
- package/templates/nextjs-shadcn/components.json +23 -0
- package/templates/nextjs-shadcn/hooks/use-mobile.ts +19 -0
- package/templates/nextjs-shadcn/lib/utils.ts +6 -0
- package/templates/nextjs-shadcn/next-env.d.ts +6 -0
- package/templates/nextjs-shadcn/next.config.ts +8 -0
- package/templates/nextjs-shadcn/open-next.config.ts +6 -0
- package/templates/nextjs-shadcn/package.json +55 -0
- package/templates/nextjs-shadcn/postcss.config.mjs +8 -0
- package/templates/nextjs-shadcn/tsconfig.json +28 -0
- package/templates/nextjs-shadcn/wrangler.jsonc +23 -0
- package/templates/resend/.jack.json +64 -0
- package/templates/resend/bun.lock +23 -0
- package/templates/resend/package.json +16 -0
- package/templates/resend/schema.sql +13 -0
- package/templates/resend/src/email.ts +165 -0
- package/templates/resend/src/index.ts +108 -0
- package/templates/resend/tsconfig.json +17 -0
- package/templates/resend/wrangler.jsonc +11 -0
- package/templates/saas/.jack.json +1 -1
- package/templates/ai-chat/public/chat.js +0 -149
|
@@ -1,49 +1,32 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { streamText, convertToCoreMessages, type Message } from "ai";
|
|
3
|
+
import { createWorkersAI } from "workers-ai-provider";
|
|
1
4
|
import { createJackAI } from "./jack-ai";
|
|
2
5
|
|
|
3
6
|
interface Env {
|
|
4
|
-
// Direct AI binding (for local dev with wrangler)
|
|
5
7
|
AI?: Ai;
|
|
6
|
-
// Jack proxy bindings (injected in jack cloud)
|
|
7
8
|
__AI_PROXY?: Fetcher;
|
|
8
9
|
__JACK_PROJECT_ID?: string;
|
|
9
10
|
__JACK_ORG_ID?: string;
|
|
10
|
-
// Assets binding
|
|
11
11
|
ASSETS: Fetcher;
|
|
12
|
+
DB: D1Database;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
function getAI(env: Env) {
|
|
15
|
-
// Prefer jack cloud proxy if available (for metering)
|
|
16
16
|
if (env.__AI_PROXY && env.__JACK_PROJECT_ID && env.__JACK_ORG_ID) {
|
|
17
17
|
return createJackAI(
|
|
18
|
-
env as Required<
|
|
18
|
+
env as Required<
|
|
19
|
+
Pick<Env, "__AI_PROXY" | "__JACK_PROJECT_ID" | "__JACK_ORG_ID">
|
|
20
|
+
>,
|
|
19
21
|
);
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
if (env.AI) {
|
|
23
|
-
return env.AI;
|
|
24
|
-
}
|
|
23
|
+
if (env.AI) return env.AI;
|
|
25
24
|
throw new Error("No AI binding available");
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
role: "user" | "assistant" | "system";
|
|
30
|
-
content: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// System prompt - customize this to change the AI's personality
|
|
34
|
-
const SYSTEM_PROMPT = `You are a helpful AI assistant built with jack (getjack.sh).
|
|
35
|
-
|
|
36
|
-
jack helps developers ship ideas fast - from "what if" to a live URL in seconds. You're running on Cloudflare's edge network, close to users worldwide.
|
|
37
|
-
|
|
38
|
-
Be concise, friendly, and helpful. If asked about jack:
|
|
39
|
-
- jack new creates projects from templates
|
|
40
|
-
- jack ship deploys to production
|
|
41
|
-
- jack open opens your app in browser
|
|
42
|
-
- Docs: https://docs.getjack.sh
|
|
27
|
+
const SYSTEM_PROMPT = `You are a helpful AI assistant. Be concise, friendly, and helpful. Keep responses short unless detail is needed.`;
|
|
43
28
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// Rate limiting: 10 requests per minute per IP
|
|
29
|
+
// Rate limiting
|
|
47
30
|
const RATE_LIMIT = 10;
|
|
48
31
|
const RATE_WINDOW_MS = 60_000;
|
|
49
32
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
@@ -51,91 +34,92 @@ const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
|
51
34
|
function checkRateLimit(ip: string): boolean {
|
|
52
35
|
const now = Date.now();
|
|
53
36
|
const entry = rateLimitMap.get(ip);
|
|
54
|
-
|
|
55
37
|
if (!entry || now >= entry.resetAt) {
|
|
56
38
|
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
|
57
39
|
return true;
|
|
58
40
|
}
|
|
59
|
-
|
|
60
|
-
if (entry.count >= RATE_LIMIT) {
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
|
|
41
|
+
if (entry.count >= RATE_LIMIT) return false;
|
|
64
42
|
entry.count++;
|
|
65
43
|
return true;
|
|
66
44
|
}
|
|
67
45
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
46
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
47
|
+
|
|
48
|
+
// Create new chat
|
|
49
|
+
app.post("/api/chat/new", async (c) => {
|
|
50
|
+
const id = crypto.randomUUID();
|
|
51
|
+
await c.env.DB.prepare("INSERT INTO chats (id) VALUES (?)").bind(id).run();
|
|
52
|
+
return c.json({ id });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Load chat history
|
|
56
|
+
app.get("/api/chat/:id", async (c) => {
|
|
57
|
+
const chatId = c.req.param("id");
|
|
58
|
+
const { results } = await c.env.DB.prepare(
|
|
59
|
+
"SELECT id, role, content, created_at FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
|
|
60
|
+
)
|
|
61
|
+
.bind(chatId)
|
|
62
|
+
.all();
|
|
63
|
+
return c.json({ messages: results || [] });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Chat endpoint with streaming
|
|
67
|
+
app.post("/api/chat", async (c) => {
|
|
68
|
+
const ip = c.req.header("cf-connecting-ip") || "unknown";
|
|
69
|
+
if (!checkRateLimit(ip)) {
|
|
70
|
+
return c.json({ error: "Too many requests. Please wait a moment." }, 429);
|
|
75
71
|
}
|
|
76
|
-
}
|
|
77
72
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// POST /api/chat - Streaming chat endpoint
|
|
88
|
-
if (request.method === "POST" && url.pathname === "/api/chat") {
|
|
89
|
-
const ip = request.headers.get("cf-connecting-ip") || "unknown";
|
|
90
|
-
|
|
91
|
-
// Check rate limit
|
|
92
|
-
if (!checkRateLimit(ip)) {
|
|
93
|
-
// Cleanup old entries occasionally
|
|
94
|
-
cleanupRateLimitMap();
|
|
95
|
-
return Response.json(
|
|
96
|
-
{ error: "Too many requests. Please wait a moment and try again." },
|
|
97
|
-
{ status: 429 },
|
|
98
|
-
);
|
|
99
|
-
}
|
|
73
|
+
const { messages, chatId } = await c.req.json<{
|
|
74
|
+
messages: Message[];
|
|
75
|
+
chatId?: string;
|
|
76
|
+
}>();
|
|
77
|
+
if (!messages || !Array.isArray(messages)) {
|
|
78
|
+
return c.json({ error: "Invalid request." }, 400);
|
|
79
|
+
}
|
|
100
80
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
} catch (err) {
|
|
134
|
-
console.error("Chat error:", err);
|
|
135
|
-
return Response.json({ error: "Something went wrong. Please try again." }, { status: 500 });
|
|
81
|
+
// Save user message to DB if we have a chatId
|
|
82
|
+
const lastUserMsg = messages.findLast((m: Message) => m.role === "user");
|
|
83
|
+
if (chatId && lastUserMsg) {
|
|
84
|
+
await c.env.DB.prepare(
|
|
85
|
+
"INSERT INTO messages (id, chat_id, role, content) VALUES (?, ?, ?, ?)",
|
|
86
|
+
)
|
|
87
|
+
.bind(
|
|
88
|
+
crypto.randomUUID(),
|
|
89
|
+
chatId,
|
|
90
|
+
"user",
|
|
91
|
+
typeof lastUserMsg.content === "string"
|
|
92
|
+
? lastUserMsg.content
|
|
93
|
+
: JSON.stringify(lastUserMsg.content),
|
|
94
|
+
)
|
|
95
|
+
.run();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ai = getAI(c.env);
|
|
99
|
+
const provider = createWorkersAI({ binding: ai as Ai });
|
|
100
|
+
|
|
101
|
+
const result = streamText({
|
|
102
|
+
model: provider("@cf/meta/llama-3.3-70b-instruct-fp8-fast"),
|
|
103
|
+
system: SYSTEM_PROMPT,
|
|
104
|
+
messages: convertToCoreMessages(messages),
|
|
105
|
+
onFinish: async ({ text }) => {
|
|
106
|
+
// Save assistant response to DB
|
|
107
|
+
if (chatId && text) {
|
|
108
|
+
await c.env.DB.prepare(
|
|
109
|
+
"INSERT INTO messages (id, chat_id, role, content) VALUES (?, ?, ?, ?)",
|
|
110
|
+
)
|
|
111
|
+
.bind(crypto.randomUUID(), chatId, "assistant", text)
|
|
112
|
+
.run();
|
|
136
113
|
}
|
|
137
|
-
}
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return result.toDataStreamResponse();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Serve static assets for non-API routes
|
|
121
|
+
app.get("*", async (c) => {
|
|
122
|
+
return c.env.ASSETS.fetch(c.req.raw);
|
|
123
|
+
});
|
|
138
124
|
|
|
139
|
-
|
|
140
|
-
},
|
|
141
|
-
};
|
|
125
|
+
export default app;
|
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
"name": "jack-template",
|
|
3
3
|
"main": "src/index.ts",
|
|
4
4
|
"compatibility_date": "2024-12-01",
|
|
5
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
5
6
|
"ai": {
|
|
6
7
|
"binding": "AI"
|
|
7
8
|
},
|
|
8
9
|
"assets": {
|
|
9
10
|
"directory": "public",
|
|
10
11
|
"binding": "ASSETS"
|
|
11
|
-
}
|
|
12
|
+
},
|
|
13
|
+
"d1_databases": [
|
|
14
|
+
{
|
|
15
|
+
"binding": "DB",
|
|
16
|
+
"database_name": "jack-template-db"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
12
19
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cron",
|
|
3
|
+
"description": "Background tasks with cron scheduling and webhook ingestion",
|
|
4
|
+
"secrets": [],
|
|
5
|
+
"capabilities": ["db"],
|
|
6
|
+
"requires": ["DB", "CRON"],
|
|
7
|
+
"intent": {
|
|
8
|
+
"keywords": [
|
|
9
|
+
"cron",
|
|
10
|
+
"background",
|
|
11
|
+
"jobs",
|
|
12
|
+
"webhook",
|
|
13
|
+
"scheduled",
|
|
14
|
+
"queue",
|
|
15
|
+
"worker",
|
|
16
|
+
"tasks"
|
|
17
|
+
],
|
|
18
|
+
"examples": [
|
|
19
|
+
"background job processor",
|
|
20
|
+
"scheduled tasks",
|
|
21
|
+
"webhook handler",
|
|
22
|
+
"job queue"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"agentContext": {
|
|
26
|
+
"summary": "A background task worker with cron scheduling, D1 job queue, and webhook ingestion.",
|
|
27
|
+
"full_text": "## Project Structure\n\n- `src/index.ts` - Hono API with cron handler, webhook endpoint, and job status routes\n- `src/jobs.ts` - Job queue: create, process, retry with D1 backend\n- `src/webhooks.ts` - Webhook ingestion with HMAC-SHA256 signature verification\n- `schema.sql` - D1 schema (jobs, webhook_events)\n\n## Cron\n\nThe `POST /__scheduled` route runs on a cron schedule (default: every 5 minutes). It processes pending jobs and retries failed ones.\n\n## Jobs\n\n```typescript\n// Create a job\nawait createJob(db, { type: 'process-order', payload: { orderId: '123' } });\n\n// Jobs are processed automatically by cron\n// Failed jobs retry up to 3 times with exponential backoff\n```\n\n## Webhooks\n\n```\nPOST /webhook\nX-Signature: sha256=abc123...\nContent-Type: application/json\n\n{ \"event\": \"order.completed\", \"data\": { ... } }\n```\n\nWebhooks are verified using HMAC-SHA256, logged to D1, and can create jobs for async processing.\n\n## Endpoints\n\n- `POST /__scheduled` - Cron handler (called by scheduler)\n- `POST /webhook` - Inbound webhook receiver with signature verification\n- `GET /jobs` - List recent jobs with status\n- `GET /health` - Health check\n\n## Environment Variables\n\n- `WEBHOOK_SECRET` - HMAC signing secret for webhook verification (auto-generated)\n\n## Resources\n\n- [Hono Documentation](https://hono.dev)"
|
|
28
|
+
},
|
|
29
|
+
"hooks": {
|
|
30
|
+
"preCreate": [
|
|
31
|
+
{
|
|
32
|
+
"action": "require",
|
|
33
|
+
"source": "secret",
|
|
34
|
+
"key": "WEBHOOK_SECRET",
|
|
35
|
+
"message": "Generating webhook signing secret...",
|
|
36
|
+
"onMissing": "generate",
|
|
37
|
+
"generateCommand": "openssl rand -hex 32"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"postDeploy": [
|
|
41
|
+
{
|
|
42
|
+
"action": "clipboard",
|
|
43
|
+
"text": "{{url}}",
|
|
44
|
+
"message": "Deploy URL copied to clipboard"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"action": "shell",
|
|
48
|
+
"command": "curl -s {{url}}/health | head -c 200",
|
|
49
|
+
"message": "Testing worker health..."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"action": "box",
|
|
53
|
+
"title": "Background worker live: {{name}}",
|
|
54
|
+
"lines": [
|
|
55
|
+
"{{url}}",
|
|
56
|
+
"",
|
|
57
|
+
"Endpoints:",
|
|
58
|
+
" POST {{url}}/webhook — inbound webhook receiver",
|
|
59
|
+
" GET {{url}}/jobs — job queue status",
|
|
60
|
+
"",
|
|
61
|
+
"Cron: runs every 5 minutes"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "jack-template",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"hono": "^4.6.0",
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@cloudflare/workers-types": "^4.20241205.0",
|
|
12
|
+
"typescript": "^5.0.0",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
"packages": {
|
|
17
|
+
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260212.0", "", {}, "sha512-ZK+e8T/2tWBCrE8PoAi9oqTxcBen9Apq+dxbsy1R5LFVdB6M4pY+oP49OFuHTTezrvNXbyvmzbf/vjtrCPGdNg=="],
|
|
18
|
+
|
|
19
|
+
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
|
20
|
+
|
|
21
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jack-template",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "wrangler dev",
|
|
7
|
+
"deploy": "wrangler deploy"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"hono": "^4.6.0"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@cloudflare/workers-types": "^4.20241205.0",
|
|
14
|
+
"typescript": "^5.0.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
type TEXT NOT NULL,
|
|
4
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
5
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
6
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
7
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
8
|
+
last_error TEXT,
|
|
9
|
+
run_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
10
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
11
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS webhook_events (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
source TEXT NOT NULL DEFAULT 'unknown',
|
|
17
|
+
event_type TEXT,
|
|
18
|
+
payload TEXT NOT NULL,
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'received',
|
|
20
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at ON jobs(status, run_at);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_events_created ON webhook_events(created_at);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { createJob, getPendingJobs, processJob, retryFailedJobs } from "./jobs";
|
|
4
|
+
import { logWebhookEvent, verifyWebhookSignature } from "./webhooks";
|
|
5
|
+
|
|
6
|
+
type Bindings = {
|
|
7
|
+
DB: D1Database;
|
|
8
|
+
WEBHOOK_SECRET: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const app = new Hono<{ Bindings: Bindings }>();
|
|
12
|
+
|
|
13
|
+
app.use("/*", cors());
|
|
14
|
+
|
|
15
|
+
app.get("/", (c) => {
|
|
16
|
+
return c.json({
|
|
17
|
+
message: "Background worker running",
|
|
18
|
+
name: "jack-template",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
app.get("/health", (c) => {
|
|
23
|
+
return c.json({ status: "ok", timestamp: Date.now() });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Cron handler - called by scheduler on POST /__scheduled
|
|
27
|
+
app.post("/__scheduled", async (c) => {
|
|
28
|
+
const db = c.env.DB;
|
|
29
|
+
|
|
30
|
+
// Retry failed jobs that haven't exceeded max attempts
|
|
31
|
+
const retried = await retryFailedJobs(db);
|
|
32
|
+
|
|
33
|
+
// Get pending jobs that are ready to run
|
|
34
|
+
const jobs = await getPendingJobs(db, 10);
|
|
35
|
+
let processed = 0;
|
|
36
|
+
|
|
37
|
+
for (const job of jobs) {
|
|
38
|
+
await processJob(db, job);
|
|
39
|
+
processed++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return c.json({ processed, retried });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Webhook ingestion endpoint
|
|
46
|
+
app.post("/webhook", async (c) => {
|
|
47
|
+
const db = c.env.DB;
|
|
48
|
+
const body = await c.req.text();
|
|
49
|
+
const signature = c.req.header("X-Signature") || "";
|
|
50
|
+
|
|
51
|
+
// Verify webhook signature
|
|
52
|
+
const valid = await verifyWebhookSignature(
|
|
53
|
+
body,
|
|
54
|
+
signature,
|
|
55
|
+
c.env.WEBHOOK_SECRET,
|
|
56
|
+
);
|
|
57
|
+
if (!valid) {
|
|
58
|
+
return c.json({ error: "Invalid signature" }, 401);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse and log the event
|
|
62
|
+
let parsed: Record<string, unknown>;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(body);
|
|
65
|
+
} catch {
|
|
66
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const eventType = (parsed.event as string) || "unknown";
|
|
70
|
+
const source = (parsed.source as string) || "unknown";
|
|
71
|
+
|
|
72
|
+
const eventId = await logWebhookEvent(db, {
|
|
73
|
+
source,
|
|
74
|
+
eventType,
|
|
75
|
+
payload: body,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Create a job from the webhook event for async processing
|
|
79
|
+
await createJob(db, {
|
|
80
|
+
type: `webhook.${eventType}`,
|
|
81
|
+
payload: { webhookEventId: eventId, data: parsed.data || {} },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return c.json({ received: true, id: eventId });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// List recent jobs
|
|
88
|
+
app.get("/jobs", async (c) => {
|
|
89
|
+
const db = c.env.DB;
|
|
90
|
+
|
|
91
|
+
const { results } = await db
|
|
92
|
+
.prepare(
|
|
93
|
+
"SELECT * FROM jobs ORDER BY created_at DESC LIMIT 50",
|
|
94
|
+
)
|
|
95
|
+
.all();
|
|
96
|
+
|
|
97
|
+
return c.json({ jobs: results });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Get a single job by ID
|
|
101
|
+
app.get("/jobs/:id", async (c) => {
|
|
102
|
+
const db = c.env.DB;
|
|
103
|
+
const id = c.req.param("id");
|
|
104
|
+
|
|
105
|
+
const job = await db
|
|
106
|
+
.prepare("SELECT * FROM jobs WHERE id = ?")
|
|
107
|
+
.bind(id)
|
|
108
|
+
.first();
|
|
109
|
+
|
|
110
|
+
if (!job) {
|
|
111
|
+
return c.json({ error: "Job not found" }, 404);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return c.json({ job });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export default app;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export interface Job {
|
|
2
|
+
id: string;
|
|
3
|
+
type: string;
|
|
4
|
+
payload: string;
|
|
5
|
+
status: string;
|
|
6
|
+
attempts: number;
|
|
7
|
+
max_attempts: number;
|
|
8
|
+
last_error: string | null;
|
|
9
|
+
run_at: number;
|
|
10
|
+
created_at: number;
|
|
11
|
+
updated_at: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CreateJobInput {
|
|
15
|
+
type: string;
|
|
16
|
+
payload?: Record<string, unknown>;
|
|
17
|
+
runAt?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Create a new job in the queue
|
|
21
|
+
export async function createJob(
|
|
22
|
+
db: D1Database,
|
|
23
|
+
input: CreateJobInput,
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
const id = crypto.randomUUID();
|
|
26
|
+
const now = Math.floor(Date.now() / 1000);
|
|
27
|
+
const runAt = input.runAt || now;
|
|
28
|
+
const payload = JSON.stringify(input.payload || {});
|
|
29
|
+
|
|
30
|
+
await db
|
|
31
|
+
.prepare(
|
|
32
|
+
"INSERT INTO jobs (id, type, payload, status, attempts, max_attempts, run_at, created_at, updated_at) VALUES (?, ?, ?, 'pending', 0, 3, ?, ?, ?)",
|
|
33
|
+
)
|
|
34
|
+
.bind(id, input.type, payload, runAt, now, now)
|
|
35
|
+
.run();
|
|
36
|
+
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Get pending jobs that are ready to run
|
|
41
|
+
export async function getPendingJobs(
|
|
42
|
+
db: D1Database,
|
|
43
|
+
limit = 10,
|
|
44
|
+
): Promise<Job[]> {
|
|
45
|
+
const now = Math.floor(Date.now() / 1000);
|
|
46
|
+
|
|
47
|
+
const { results } = await db
|
|
48
|
+
.prepare(
|
|
49
|
+
"SELECT * FROM jobs WHERE status = 'pending' AND run_at <= ? ORDER BY run_at ASC LIMIT ?",
|
|
50
|
+
)
|
|
51
|
+
.bind(now, limit)
|
|
52
|
+
.all<Job>();
|
|
53
|
+
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Process a single job
|
|
58
|
+
// Add your own processing logic in the switch statement below
|
|
59
|
+
export async function processJob(db: D1Database, job: Job): Promise<void> {
|
|
60
|
+
const now = Math.floor(Date.now() / 1000);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Mark as running
|
|
64
|
+
await db
|
|
65
|
+
.prepare(
|
|
66
|
+
"UPDATE jobs SET status = 'running', attempts = attempts + 1, updated_at = ? WHERE id = ?",
|
|
67
|
+
)
|
|
68
|
+
.bind(now, job.id)
|
|
69
|
+
.run();
|
|
70
|
+
|
|
71
|
+
// Process based on job type
|
|
72
|
+
const payload = JSON.parse(job.payload);
|
|
73
|
+
|
|
74
|
+
switch (job.type) {
|
|
75
|
+
case "example-task":
|
|
76
|
+
// Replace with your own logic
|
|
77
|
+
console.log(`Processing example task: ${JSON.stringify(payload)}`);
|
|
78
|
+
break;
|
|
79
|
+
|
|
80
|
+
default:
|
|
81
|
+
// Webhook-created jobs and other types
|
|
82
|
+
if (job.type.startsWith("webhook.")) {
|
|
83
|
+
console.log(`Processing webhook job: ${job.type}`, payload);
|
|
84
|
+
} else {
|
|
85
|
+
console.log(`Unknown job type: ${job.type}`);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Mark as completed
|
|
91
|
+
await db
|
|
92
|
+
.prepare(
|
|
93
|
+
"UPDATE jobs SET status = 'completed', updated_at = ? WHERE id = ?",
|
|
94
|
+
)
|
|
95
|
+
.bind(Math.floor(Date.now() / 1000), job.id)
|
|
96
|
+
.run();
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
99
|
+
|
|
100
|
+
// Mark as failed
|
|
101
|
+
await db
|
|
102
|
+
.prepare(
|
|
103
|
+
"UPDATE jobs SET status = 'failed', last_error = ?, updated_at = ? WHERE id = ?",
|
|
104
|
+
)
|
|
105
|
+
.bind(errorMessage, Math.floor(Date.now() / 1000), job.id)
|
|
106
|
+
.run();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Retry failed jobs that haven't exceeded max attempts
|
|
111
|
+
// Uses exponential backoff: 2^attempts * 60 seconds
|
|
112
|
+
export async function retryFailedJobs(db: D1Database): Promise<number> {
|
|
113
|
+
const now = Math.floor(Date.now() / 1000);
|
|
114
|
+
|
|
115
|
+
const { results } = await db
|
|
116
|
+
.prepare(
|
|
117
|
+
"SELECT id, attempts FROM jobs WHERE status = 'failed' AND attempts < max_attempts",
|
|
118
|
+
)
|
|
119
|
+
.all<{ id: string; attempts: number }>();
|
|
120
|
+
|
|
121
|
+
let retried = 0;
|
|
122
|
+
|
|
123
|
+
for (const job of results) {
|
|
124
|
+
// Exponential backoff: 2^attempts * 60 seconds
|
|
125
|
+
const backoffSeconds = Math.pow(2, job.attempts) * 60;
|
|
126
|
+
const nextRunAt = now + backoffSeconds;
|
|
127
|
+
|
|
128
|
+
await db
|
|
129
|
+
.prepare(
|
|
130
|
+
"UPDATE jobs SET status = 'pending', run_at = ?, updated_at = ? WHERE id = ?",
|
|
131
|
+
)
|
|
132
|
+
.bind(nextRunAt, now, job.id)
|
|
133
|
+
.run();
|
|
134
|
+
|
|
135
|
+
retried++;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return retried;
|
|
139
|
+
}
|