@getjack/jack 0.1.34 → 0.1.35
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/README.md +6 -6
- package/package.json +1 -1
- package/src/commands/down.ts +39 -7
- package/src/commands/link.ts +2 -4
- package/src/commands/logs.ts +2 -4
- package/src/commands/mcp.ts +12 -10
- package/src/commands/services.ts +4 -2
- package/src/commands/sync.ts +5 -6
- package/src/lib/auth/client.ts +5 -2
- package/src/lib/binding-validator.ts +39 -3
- package/src/lib/build-helper.ts +18 -19
- package/src/lib/control-plane.ts +1 -0
- package/src/lib/do-config.ts +110 -0
- package/src/lib/do-export-validator.ts +26 -0
- package/src/lib/jsonc-edit.ts +292 -0
- package/src/lib/managed-deploy.ts +36 -1
- package/src/lib/project-link.ts +37 -0
- package/src/lib/project-operations.ts +13 -38
- package/src/lib/resources.ts +4 -5
- package/src/lib/schema.ts +8 -12
- package/src/lib/services/db-create.ts +2 -2
- package/src/lib/services/db-execute.ts +9 -6
- package/src/lib/services/db-list.ts +6 -4
- package/src/lib/services/endpoint-test.ts +275 -0
- package/src/lib/services/project-delete.ts +190 -0
- package/src/lib/services/project-environment.ts +457 -0
- package/src/lib/services/storage-config.ts +7 -309
- package/src/lib/services/storage-create.ts +2 -1
- package/src/lib/services/storage-delete.ts +3 -2
- package/src/lib/services/storage-info.ts +2 -1
- package/src/lib/services/storage-list.ts +6 -3
- package/src/lib/services/vectorize-config.ts +7 -264
- package/src/lib/services/vectorize-create.ts +2 -1
- package/src/lib/services/vectorize-delete.ts +6 -4
- package/src/lib/services/vectorize-list.ts +6 -3
- package/src/lib/storage/index.ts +21 -23
- package/src/lib/telemetry.ts +1 -0
- package/src/lib/wrangler-config.ts +43 -312
- package/src/lib/zip-packager.ts +28 -0
- package/src/mcp/test-utils.ts +31 -0
- package/src/mcp/tools/index.ts +271 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/types.ts +4 -0
- package/templates/AI-BINDINGS.md +34 -76
- package/templates/CLAUDE.md +1 -1
- package/templates/ai-chat/src/index.ts +7 -14
- package/templates/ai-chat/src/jack-ai.ts +0 -6
- package/templates/chat/.jack.json +45 -0
- package/templates/chat/bun.lock +1588 -0
- package/templates/chat/components.json +23 -0
- package/templates/chat/index.html +12 -0
- package/templates/chat/package.json +41 -0
- package/templates/chat/src/chat-agent.ts +61 -0
- package/templates/chat/src/client/app.tsx +189 -0
- package/templates/chat/src/client/chat.tsx +222 -0
- package/templates/chat/src/client/components/prompt-kit/chat-container.tsx +47 -0
- package/templates/chat/src/client/components/prompt-kit/loader.tsx +33 -0
- package/templates/chat/src/client/components/prompt-kit/markdown.tsx +84 -0
- package/templates/chat/src/client/components/prompt-kit/message.tsx +54 -0
- package/templates/chat/src/client/components/prompt-kit/prompt-suggestion.tsx +20 -0
- package/templates/chat/src/client/components/prompt-kit/reasoning.tsx +134 -0
- package/templates/chat/src/client/components/prompt-kit/scroll-button.tsx +28 -0
- package/templates/chat/src/client/components/ui/button.tsx +38 -0
- package/templates/chat/src/client/lib/utils.ts +6 -0
- package/templates/chat/src/client/main.tsx +11 -0
- package/templates/chat/src/client/styles.css +125 -0
- package/templates/chat/src/index.ts +25 -0
- package/templates/chat/src/jack-ai.ts +94 -0
- package/templates/chat/tsconfig.json +18 -0
- package/templates/chat/vite.config.ts +14 -0
- package/templates/chat/wrangler.jsonc +18 -0
- package/templates/cron/.jack.json +18 -28
- package/templates/cron/schema.sql +10 -20
- package/templates/cron/src/admin.ts +321 -0
- package/templates/cron/src/index.ts +151 -81
- package/templates/cron/src/monitor.ts +124 -0
- package/templates/semantic-search/src/index.ts +5 -43
- package/templates/semantic-search/src/jack-ai.ts +0 -6
- package/templates/telegram-bot/.jack.json +56 -0
- package/templates/telegram-bot/bun.lock +41 -0
- package/templates/telegram-bot/package.json +16 -0
- package/templates/telegram-bot/src/index.ts +236 -0
- package/templates/telegram-bot/src/jack-ai.ts +100 -0
- package/templates/telegram-bot/tsconfig.json +11 -0
- package/templates/telegram-bot/wrangler.jsonc +8 -0
- package/templates/cron/src/jobs.ts +0 -139
- package/templates/cron/src/webhooks.ts +0 -95
- package/templates/semantic-search/src/jack-vectorize.ts +0 -169
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Bot, webhookCallback } from "grammy";
|
|
2
|
+
import { createJackAI } from "./jack-ai";
|
|
3
|
+
|
|
4
|
+
const MODEL = "@cf/meta/llama-3.1-8b-instruct";
|
|
5
|
+
const MAX_MESSAGE_LENGTH = 4000;
|
|
6
|
+
|
|
7
|
+
interface Env {
|
|
8
|
+
BOT_TOKEN: string;
|
|
9
|
+
WEBHOOK_SECRET: string;
|
|
10
|
+
AI?: Ai;
|
|
11
|
+
__AI_PROXY?: Fetcher;
|
|
12
|
+
__JACK_PROJECT_ID?: string;
|
|
13
|
+
__JACK_ORG_ID?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type AIRunner = {
|
|
17
|
+
run: (model: string, inputs: unknown) => Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function getAI(env: Env): AIRunner {
|
|
21
|
+
if (env.__AI_PROXY && env.__JACK_PROJECT_ID && env.__JACK_ORG_ID) {
|
|
22
|
+
return createJackAI(
|
|
23
|
+
env as Required<Pick<Env, "__AI_PROXY" | "__JACK_PROJECT_ID" | "__JACK_ORG_ID">>,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (env.AI) return env.AI as unknown as AIRunner;
|
|
27
|
+
throw new Error("No AI binding available");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function askAI(env: Env, question: string): Promise<string> {
|
|
31
|
+
const ai = getAI(env);
|
|
32
|
+
const result = (await ai.run(MODEL, {
|
|
33
|
+
messages: [
|
|
34
|
+
{
|
|
35
|
+
role: "system",
|
|
36
|
+
content:
|
|
37
|
+
"You are a helpful Telegram bot powered by getjack.org. Keep answers concise and clear. Use plain text, not markdown.",
|
|
38
|
+
},
|
|
39
|
+
{ role: "user", content: question },
|
|
40
|
+
],
|
|
41
|
+
})) as { response?: string };
|
|
42
|
+
|
|
43
|
+
return result.response ?? "Sorry, I couldn't generate a response.";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function sendChunked(
|
|
47
|
+
ctx: { reply: (text: string) => Promise<unknown> },
|
|
48
|
+
text: string,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
if (text.length <= MAX_MESSAGE_LENGTH) {
|
|
51
|
+
await ctx.reply(text);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const chunks: string[] = [];
|
|
56
|
+
let remaining = text;
|
|
57
|
+
while (remaining.length > 0) {
|
|
58
|
+
if (remaining.length <= MAX_MESSAGE_LENGTH) {
|
|
59
|
+
chunks.push(remaining);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
let splitAt = remaining.lastIndexOf("\n", MAX_MESSAGE_LENGTH);
|
|
63
|
+
if (splitAt < MAX_MESSAGE_LENGTH / 2) {
|
|
64
|
+
splitAt = remaining.lastIndexOf(" ", MAX_MESSAGE_LENGTH);
|
|
65
|
+
}
|
|
66
|
+
if (splitAt < MAX_MESSAGE_LENGTH / 2) {
|
|
67
|
+
splitAt = MAX_MESSAGE_LENGTH;
|
|
68
|
+
}
|
|
69
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
70
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const chunk of chunks) {
|
|
74
|
+
await ctx.reply(chunk);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function getBotUsername(env: Env): Promise<string | null> {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`https://api.telegram.org/bot${env.BOT_TOKEN}/getMe`);
|
|
81
|
+
const data = (await res.json()) as {
|
|
82
|
+
ok: boolean;
|
|
83
|
+
result?: { username?: string };
|
|
84
|
+
};
|
|
85
|
+
return data.ok ? (data.result?.username ?? null) : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function registerWebhook(env: Env, url: string): Promise<Response> {
|
|
92
|
+
const [webhookRes, username] = await Promise.all([
|
|
93
|
+
fetch(`https://api.telegram.org/bot${env.BOT_TOKEN}/setWebhook`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "Content-Type": "application/json" },
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
url,
|
|
98
|
+
secret_token: env.WEBHOOK_SECRET,
|
|
99
|
+
allowed_updates: ["message", "callback_query"],
|
|
100
|
+
}),
|
|
101
|
+
}),
|
|
102
|
+
getBotUsername(env),
|
|
103
|
+
]);
|
|
104
|
+
const data = (await webhookRes.json()) as {
|
|
105
|
+
ok: boolean;
|
|
106
|
+
description?: string;
|
|
107
|
+
};
|
|
108
|
+
const botUrl = username ? `https://t.me/${username}` : null;
|
|
109
|
+
if (data.ok) {
|
|
110
|
+
return new Response(JSON.stringify({ registered: true, url, botUrl, username }), {
|
|
111
|
+
headers: { "Content-Type": "application/json" },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return new Response(JSON.stringify({ registered: false, error: data.description }), {
|
|
115
|
+
status: 500,
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default {
|
|
121
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
122
|
+
const url = new URL(request.url);
|
|
123
|
+
|
|
124
|
+
if (url.pathname === "/register-webhook") {
|
|
125
|
+
return registerWebhook(env, url.origin);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (url.pathname === "/bot-link") {
|
|
129
|
+
const username = await getBotUsername(env);
|
|
130
|
+
if (username) {
|
|
131
|
+
return new Response(`https://t.me/${username}`, {
|
|
132
|
+
headers: { "Content-Type": "text/plain" },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return new Response("", { status: 404 });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (request.method !== "POST") {
|
|
139
|
+
const username = await getBotUsername(env);
|
|
140
|
+
const botLink = username
|
|
141
|
+
? `<p><a href="https://t.me/${username}">Open @${username} in Telegram</a> and send <code>/start</code></p>`
|
|
142
|
+
: `<p>Open your bot in Telegram and send <code>/start</code></p>`;
|
|
143
|
+
return new Response(
|
|
144
|
+
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>jack-template</title>
|
|
145
|
+
<style>body{font-family:system-ui;max-width:480px;margin:60px auto;padding:0 20px}
|
|
146
|
+
a{color:#0088cc}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style></head>
|
|
147
|
+
<body><h2>jack-template</h2>${botLink}
|
|
148
|
+
<p>Commands: <code>/start</code> <code>/help</code> <code>/ask</code> <code>/status</code></p></body></html>`,
|
|
149
|
+
{ headers: { "Content-Type": "text/html;charset=utf-8" } },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token");
|
|
154
|
+
if (secret !== env.WEBHOOK_SECRET) {
|
|
155
|
+
return new Response("Unauthorized", { status: 403 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const bot = new Bot(env.BOT_TOKEN);
|
|
159
|
+
|
|
160
|
+
bot.command("start", async (ctx) => {
|
|
161
|
+
await ctx.reply(
|
|
162
|
+
[
|
|
163
|
+
"Hey! I'm an AI-powered bot.",
|
|
164
|
+
"",
|
|
165
|
+
"Commands:",
|
|
166
|
+
"/ask <question> - Ask me anything",
|
|
167
|
+
"/status - Bot info",
|
|
168
|
+
"/help - Show this message",
|
|
169
|
+
"",
|
|
170
|
+
"You can also reply to any of my messages to continue the conversation.",
|
|
171
|
+
].join("\n"),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
bot.command("help", async (ctx) => {
|
|
176
|
+
await ctx.reply(
|
|
177
|
+
[
|
|
178
|
+
"Available commands:",
|
|
179
|
+
"",
|
|
180
|
+
"/ask <question> - Ask the AI a question",
|
|
181
|
+
"/status - Show bot status and info",
|
|
182
|
+
"/help - Show this help message",
|
|
183
|
+
"",
|
|
184
|
+
"Tip: Reply to any of my messages to ask a follow-up question.",
|
|
185
|
+
].join("\n"),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
bot.command("status", async (ctx) => {
|
|
190
|
+
const cf = (request as Request & { cf?: Record<string, string> }).cf;
|
|
191
|
+
const region = cf?.colo ?? "unknown";
|
|
192
|
+
const country = cf?.country ?? "unknown";
|
|
193
|
+
|
|
194
|
+
await ctx.reply(
|
|
195
|
+
[
|
|
196
|
+
"Bot Status",
|
|
197
|
+
"",
|
|
198
|
+
`Region: ${region} (${country})`,
|
|
199
|
+
`Time: ${new Date().toISOString()}`,
|
|
200
|
+
`Runtime: Edge`,
|
|
201
|
+
`AI Model: ${MODEL}`,
|
|
202
|
+
].join("\n"),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
bot.command("ask", async (ctx) => {
|
|
207
|
+
const question = ctx.match;
|
|
208
|
+
if (!question) {
|
|
209
|
+
await ctx.reply(
|
|
210
|
+
"Usage: /ask <your question>\n\nExample: /ask What is the meaning of life?",
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const answer = await askAI(env, question);
|
|
216
|
+
await sendChunked(ctx, answer);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
bot.on("message:text", async (ctx) => {
|
|
220
|
+
// In private chats, respond to every message
|
|
221
|
+
if (ctx.chat.type === "private") {
|
|
222
|
+
const answer = await askAI(env, ctx.message.text);
|
|
223
|
+
await sendChunked(ctx, answer);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// In groups, only respond when replying to bot's message
|
|
227
|
+
const replyTo = ctx.message.reply_to_message;
|
|
228
|
+
if (replyTo?.from?.id === bot.botInfo.id) {
|
|
229
|
+
const answer = await askAI(env, ctx.message.text);
|
|
230
|
+
await sendChunked(ctx, answer);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return webhookCallback(bot, "cloudflare-mod")(request);
|
|
235
|
+
},
|
|
236
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jack AI Client - Drop-in replacement for Cloudflare AI binding.
|
|
3
|
+
*
|
|
4
|
+
* This wrapper provides the same interface as env.AI but routes calls
|
|
5
|
+
* through jack's binding proxy for metering and quota enforcement.
|
|
6
|
+
*
|
|
7
|
+
* Usage in templates:
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createJackAI } from "./jack-ai";
|
|
10
|
+
*
|
|
11
|
+
* interface Env {
|
|
12
|
+
* __AI_PROXY: Fetcher; // Service binding to binding-proxy worker
|
|
13
|
+
* __JACK_PROJECT_ID: string; // Injected by control plane
|
|
14
|
+
* __JACK_ORG_ID: string; // Injected by control plane
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* export default {
|
|
18
|
+
* async fetch(request: Request, env: Env) {
|
|
19
|
+
* const AI = createJackAI(env);
|
|
20
|
+
* const result = await AI.run("@cf/meta/llama-3.2-1b-instruct", { messages });
|
|
21
|
+
* // Works exactly like env.AI.run()
|
|
22
|
+
* }
|
|
23
|
+
* };
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The wrapper is transparent - it accepts the same parameters as env.AI.run()
|
|
27
|
+
* and returns the same response types, including streaming.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
interface JackAIEnv {
|
|
31
|
+
__AI_PROXY: Fetcher;
|
|
32
|
+
__JACK_PROJECT_ID: string;
|
|
33
|
+
__JACK_ORG_ID: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a Jack AI client that mirrors the Cloudflare AI binding interface.
|
|
38
|
+
*
|
|
39
|
+
* @param env - Worker environment with jack proxy bindings
|
|
40
|
+
* @returns AI-compatible object with run() method
|
|
41
|
+
*/
|
|
42
|
+
export function createJackAI(env: JackAIEnv): {
|
|
43
|
+
run: <T = unknown>(
|
|
44
|
+
model: string,
|
|
45
|
+
inputs: unknown,
|
|
46
|
+
options?: unknown,
|
|
47
|
+
) => Promise<T | ReadableStream>;
|
|
48
|
+
} {
|
|
49
|
+
return {
|
|
50
|
+
async run<T = unknown>(
|
|
51
|
+
model: string,
|
|
52
|
+
inputs: unknown,
|
|
53
|
+
options?: unknown,
|
|
54
|
+
): Promise<T | ReadableStream> {
|
|
55
|
+
const response = await env.__AI_PROXY.fetch("http://internal/ai/run", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"X-Jack-Project-ID": env.__JACK_PROJECT_ID,
|
|
60
|
+
"X-Jack-Org-ID": env.__JACK_ORG_ID,
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({ model, inputs, options }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Handle quota exceeded
|
|
66
|
+
if (response.status === 429) {
|
|
67
|
+
const error = await response.json();
|
|
68
|
+
const quotaError = new Error(
|
|
69
|
+
(error as { message?: string }).message || "AI quota exceeded",
|
|
70
|
+
);
|
|
71
|
+
(quotaError as Error & { code: string }).code = "AI_QUOTA_EXCEEDED";
|
|
72
|
+
(quotaError as Error & { resetIn?: number }).resetIn = (
|
|
73
|
+
error as { resetIn?: number }
|
|
74
|
+
).resetIn;
|
|
75
|
+
throw quotaError;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Handle other errors
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const error = await response.json();
|
|
81
|
+
throw new Error((error as { error?: string }).error || "AI request failed");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handle streaming response
|
|
85
|
+
const contentType = response.headers.get("Content-Type");
|
|
86
|
+
if (contentType?.includes("text/event-stream")) {
|
|
87
|
+
return response.body as ReadableStream;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Handle JSON response
|
|
91
|
+
return response.json() as Promise<T>;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Type-safe wrapper that infers return types based on model.
|
|
98
|
+
* For advanced users who want full type safety.
|
|
99
|
+
*/
|
|
100
|
+
export type JackAI = ReturnType<typeof createJackAI>;
|
|
@@ -1,139 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
// Convert an ArrayBuffer to hex string
|
|
2
|
-
function bufferToHex(buffer: ArrayBuffer): string {
|
|
3
|
-
const bytes = new Uint8Array(buffer);
|
|
4
|
-
const hexChars: string[] = [];
|
|
5
|
-
for (const byte of bytes) {
|
|
6
|
-
hexChars.push(byte.toString(16).padStart(2, "0"));
|
|
7
|
-
}
|
|
8
|
-
return hexChars.join("");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Verify HMAC-SHA256 webhook signature
|
|
12
|
-
// Expected header format: sha256=<hex-encoded-signature>
|
|
13
|
-
export async function verifyWebhookSignature(
|
|
14
|
-
payload: string,
|
|
15
|
-
signatureHeader: string,
|
|
16
|
-
secret: string,
|
|
17
|
-
): Promise<boolean> {
|
|
18
|
-
if (!signatureHeader || !secret) {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Parse the signature header
|
|
23
|
-
const parts = signatureHeader.split("=");
|
|
24
|
-
if (parts.length !== 2 || parts[0] !== "sha256") {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const receivedSignature = parts[1];
|
|
29
|
-
if (!receivedSignature) {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Import the secret as a CryptoKey
|
|
34
|
-
const encoder = new TextEncoder();
|
|
35
|
-
const key = await crypto.subtle.importKey(
|
|
36
|
-
"raw",
|
|
37
|
-
encoder.encode(secret),
|
|
38
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
39
|
-
false,
|
|
40
|
-
["sign"],
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
// Sign the payload
|
|
44
|
-
const signatureBuffer = await crypto.subtle.sign(
|
|
45
|
-
"HMAC",
|
|
46
|
-
key,
|
|
47
|
-
encoder.encode(payload),
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
// Convert to hex and compare
|
|
51
|
-
const expectedSignature = bufferToHex(signatureBuffer);
|
|
52
|
-
|
|
53
|
-
// Constant-time comparison
|
|
54
|
-
if (receivedSignature.length !== expectedSignature.length) {
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
let mismatch = 0;
|
|
59
|
-
for (let i = 0; i < receivedSignature.length; i++) {
|
|
60
|
-
mismatch |=
|
|
61
|
-
receivedSignature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return mismatch === 0;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface WebhookEventInput {
|
|
68
|
-
source?: string;
|
|
69
|
-
eventType?: string;
|
|
70
|
-
payload: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Log a webhook event to D1
|
|
74
|
-
export async function logWebhookEvent(
|
|
75
|
-
db: D1Database,
|
|
76
|
-
input: WebhookEventInput,
|
|
77
|
-
): Promise<string> {
|
|
78
|
-
const id = crypto.randomUUID();
|
|
79
|
-
const now = Math.floor(Date.now() / 1000);
|
|
80
|
-
|
|
81
|
-
await db
|
|
82
|
-
.prepare(
|
|
83
|
-
"INSERT INTO webhook_events (id, source, event_type, payload, status, created_at) VALUES (?, ?, ?, ?, 'received', ?)",
|
|
84
|
-
)
|
|
85
|
-
.bind(
|
|
86
|
-
id,
|
|
87
|
-
input.source || "unknown",
|
|
88
|
-
input.eventType || null,
|
|
89
|
-
input.payload,
|
|
90
|
-
now,
|
|
91
|
-
)
|
|
92
|
-
.run();
|
|
93
|
-
|
|
94
|
-
return id;
|
|
95
|
-
}
|