@donkeylabs/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +141 -0
- package/package.json +51 -0
- package/src/commands/generate.ts +585 -0
- package/src/commands/init.ts +201 -0
- package/src/commands/interactive.ts +223 -0
- package/src/commands/plugin.ts +205 -0
- package/src/index.ts +108 -0
- package/templates/starter/.env.example +3 -0
- package/templates/starter/.gitignore.template +4 -0
- package/templates/starter/CLAUDE.md +144 -0
- package/templates/starter/donkeylabs.config.ts +6 -0
- package/templates/starter/package.json +21 -0
- package/templates/starter/src/client.test.ts +7 -0
- package/templates/starter/src/db.ts +9 -0
- package/templates/starter/src/index.ts +48 -0
- package/templates/starter/src/plugins/stats/index.ts +105 -0
- package/templates/starter/src/routes/health/index.ts +5 -0
- package/templates/starter/src/routes/health/ping/index.ts +13 -0
- package/templates/starter/src/routes/health/ping/models/model.ts +23 -0
- package/templates/starter/src/routes/health/ping/schema.ts +14 -0
- package/templates/starter/src/routes/health/ping/tests/integ.test.ts +20 -0
- package/templates/starter/src/routes/health/ping/tests/unit.test.ts +21 -0
- package/templates/starter/src/test-ctx.ts +24 -0
- package/templates/starter/tsconfig.json +27 -0
- package/templates/sveltekit-app/.env.example +3 -0
- package/templates/sveltekit-app/README.md +103 -0
- package/templates/sveltekit-app/donkeylabs.config.ts +10 -0
- package/templates/sveltekit-app/package.json +36 -0
- package/templates/sveltekit-app/src/app.css +40 -0
- package/templates/sveltekit-app/src/app.html +12 -0
- package/templates/sveltekit-app/src/hooks.server.ts +4 -0
- package/templates/sveltekit-app/src/lib/api.ts +134 -0
- package/templates/sveltekit-app/src/lib/components/ui/badge/badge.svelte +30 -0
- package/templates/sveltekit-app/src/lib/components/ui/badge/index.ts +3 -0
- package/templates/sveltekit-app/src/lib/components/ui/button/button.svelte +48 -0
- package/templates/sveltekit-app/src/lib/components/ui/button/index.ts +9 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-content.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-description.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-footer.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-header.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-title.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card.svelte +21 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/index.ts +21 -0
- package/templates/sveltekit-app/src/lib/components/ui/index.ts +4 -0
- package/templates/sveltekit-app/src/lib/components/ui/input/index.ts +2 -0
- package/templates/sveltekit-app/src/lib/components/ui/input/input.svelte +20 -0
- package/templates/sveltekit-app/src/lib/utils/index.ts +6 -0
- package/templates/sveltekit-app/src/routes/+layout.svelte +8 -0
- package/templates/sveltekit-app/src/routes/+page.server.ts +25 -0
- package/templates/sveltekit-app/src/routes/+page.svelte +401 -0
- package/templates/sveltekit-app/src/server/index.ts +263 -0
- package/templates/sveltekit-app/static/robots.txt +3 -0
- package/templates/sveltekit-app/svelte.config.js +18 -0
- package/templates/sveltekit-app/tsconfig.json +25 -0
- package/templates/sveltekit-app/vite.config.ts +7 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { browser } from '$app/environment';
|
|
3
|
+
import { onMount } from 'svelte';
|
|
4
|
+
import { Button } from '$lib/components/ui/button';
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
|
6
|
+
import { Input } from '$lib/components/ui/input';
|
|
7
|
+
import { Badge } from '$lib/components/ui/badge';
|
|
8
|
+
import { createApi, type CronTask } from '$lib/api';
|
|
9
|
+
|
|
10
|
+
let { data } = $props();
|
|
11
|
+
|
|
12
|
+
// Create typed API client (browser mode - no locals)
|
|
13
|
+
const api = createApi();
|
|
14
|
+
|
|
15
|
+
// SSE Events state
|
|
16
|
+
let events = $state<Array<{ id: number; message: string; timestamp: string; source?: string }>>([]);
|
|
17
|
+
let sseConnected = $state(false);
|
|
18
|
+
let sseClients = $state({ total: 0, byChannel: 0 });
|
|
19
|
+
|
|
20
|
+
// Counter state
|
|
21
|
+
let count = $state(data.count);
|
|
22
|
+
let counterLoading = $state(false);
|
|
23
|
+
|
|
24
|
+
// Cache state
|
|
25
|
+
let cacheKey = $state('demo-key');
|
|
26
|
+
let cacheValue = $state('Hello World');
|
|
27
|
+
let cacheTTL = $state(30000);
|
|
28
|
+
let cacheResult = $state<any>(null);
|
|
29
|
+
let cacheKeys = $state<string[]>([]);
|
|
30
|
+
|
|
31
|
+
// Jobs state
|
|
32
|
+
let jobMessage = $state('Test job');
|
|
33
|
+
let jobDelay = $state(0);
|
|
34
|
+
let jobStats = $state({ pending: 0, running: 0, completed: 0 });
|
|
35
|
+
let lastJobId = $state<string | null>(null);
|
|
36
|
+
|
|
37
|
+
// Rate Limiter state
|
|
38
|
+
let rateLimitKey = $state('demo');
|
|
39
|
+
let rateLimitMax = $state(5);
|
|
40
|
+
let rateLimitWindow = $state(60000);
|
|
41
|
+
let rateLimitResult = $state<any>(null);
|
|
42
|
+
|
|
43
|
+
// Cron state
|
|
44
|
+
let cronTasks = $state<CronTask[]>([]);
|
|
45
|
+
|
|
46
|
+
// Events (pub/sub) state
|
|
47
|
+
let eventName = $state('demo.test');
|
|
48
|
+
let eventData = $state('{"hello": "world"}');
|
|
49
|
+
|
|
50
|
+
// Counter actions - using typed client
|
|
51
|
+
async function counterAction(action: 'get' | 'increment' | 'decrement' | 'reset') {
|
|
52
|
+
counterLoading = true;
|
|
53
|
+
const result = await api.counter[action]();
|
|
54
|
+
count = result.count;
|
|
55
|
+
counterLoading = false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Cache actions - using typed client
|
|
59
|
+
async function cacheSet() {
|
|
60
|
+
await api.cache.set({ key: cacheKey, value: cacheValue, ttl: cacheTTL });
|
|
61
|
+
cacheResult = { action: 'set', success: true };
|
|
62
|
+
refreshCacheKeys();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function cacheGet() {
|
|
66
|
+
cacheResult = await api.cache.get({ key: cacheKey });
|
|
67
|
+
refreshCacheKeys();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function cacheDelete() {
|
|
71
|
+
await api.cache.delete({ key: cacheKey });
|
|
72
|
+
cacheResult = { action: 'deleted', success: true };
|
|
73
|
+
refreshCacheKeys();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function refreshCacheKeys() {
|
|
77
|
+
const result = await api.cache.keys();
|
|
78
|
+
cacheKeys = result.keys || [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Jobs actions - using typed client
|
|
82
|
+
async function enqueueJob() {
|
|
83
|
+
const result = await api.jobs.enqueue({
|
|
84
|
+
name: 'demo-job',
|
|
85
|
+
data: { message: jobMessage },
|
|
86
|
+
delay: jobDelay > 0 ? jobDelay : undefined
|
|
87
|
+
});
|
|
88
|
+
lastJobId = result.jobId;
|
|
89
|
+
refreshJobStats();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function refreshJobStats() {
|
|
93
|
+
jobStats = await api.jobs.stats();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Rate limiter actions - using typed client
|
|
97
|
+
async function checkRateLimit() {
|
|
98
|
+
rateLimitResult = await api.ratelimit.check({
|
|
99
|
+
key: rateLimitKey,
|
|
100
|
+
limit: rateLimitMax,
|
|
101
|
+
window: rateLimitWindow
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function resetRateLimit() {
|
|
106
|
+
await api.ratelimit.reset({ key: rateLimitKey });
|
|
107
|
+
rateLimitResult = { reset: true, message: 'Rate limit reset' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cron actions - using typed client
|
|
111
|
+
async function refreshCronTasks() {
|
|
112
|
+
const result = await api.cron.list();
|
|
113
|
+
cronTasks = result.tasks;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Events (pub/sub) actions - using typed client
|
|
117
|
+
async function emitEvent() {
|
|
118
|
+
try {
|
|
119
|
+
const parsedData = JSON.parse(eventData);
|
|
120
|
+
await api.events.emit({ event: eventName, data: parsedData });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error('Invalid JSON:', e);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// SSE actions - using typed client
|
|
127
|
+
async function manualBroadcast() {
|
|
128
|
+
await api.sseRoutes.broadcast({
|
|
129
|
+
channel: 'events',
|
|
130
|
+
event: 'manual',
|
|
131
|
+
data: {
|
|
132
|
+
id: Date.now(),
|
|
133
|
+
message: 'Manual broadcast!',
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
source: 'manual'
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function refreshSSEClients() {
|
|
141
|
+
sseClients = await api.sseRoutes.clients();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onMount(() => {
|
|
145
|
+
if (!browser) return;
|
|
146
|
+
|
|
147
|
+
// Initial data fetches
|
|
148
|
+
refreshCacheKeys();
|
|
149
|
+
refreshJobStats();
|
|
150
|
+
refreshCronTasks();
|
|
151
|
+
refreshSSEClients();
|
|
152
|
+
|
|
153
|
+
// SSE subscription using the typed client
|
|
154
|
+
const unsubscribe = api.sse.subscribe(['events'], (eventType, eventData) => {
|
|
155
|
+
// Handle all event types
|
|
156
|
+
if (['cron-event', 'job-completed', 'internal-event', 'manual'].includes(eventType)) {
|
|
157
|
+
// Simple prepend - CSS handles animation via :first-child or key-based animation
|
|
158
|
+
events = [{ ...eventData }, ...events].slice(0, 15);
|
|
159
|
+
|
|
160
|
+
if (eventType === 'job-completed') {
|
|
161
|
+
refreshJobStats();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Track connection status
|
|
167
|
+
const checkConnection = setInterval(() => {
|
|
168
|
+
// The SSE subscribe auto-reconnects, so we just refresh clients
|
|
169
|
+
refreshSSEClients().then(() => {
|
|
170
|
+
sseConnected = sseClients.byChannel > 0;
|
|
171
|
+
});
|
|
172
|
+
refreshJobStats();
|
|
173
|
+
}, 5000);
|
|
174
|
+
|
|
175
|
+
// Set connected after initial subscribe
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
sseConnected = true;
|
|
178
|
+
refreshSSEClients();
|
|
179
|
+
}, 500);
|
|
180
|
+
|
|
181
|
+
return () => {
|
|
182
|
+
unsubscribe();
|
|
183
|
+
clearInterval(checkConnection);
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
function getSourceColor(source?: string): "default" | "secondary" | "destructive" | "outline" | "success" {
|
|
188
|
+
switch (source) {
|
|
189
|
+
case 'cron': return 'default';
|
|
190
|
+
case 'manual': return 'secondary';
|
|
191
|
+
case 'events': return 'outline';
|
|
192
|
+
default: return 'success';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getSourceLabel(source?: string) {
|
|
197
|
+
switch (source) {
|
|
198
|
+
case 'cron': return 'CRON';
|
|
199
|
+
case 'manual': return 'MANUAL';
|
|
200
|
+
case 'events': return 'PUB/SUB';
|
|
201
|
+
default: return 'JOB';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
</script>
|
|
205
|
+
|
|
206
|
+
<div class="min-h-screen bg-background">
|
|
207
|
+
<div class="container mx-auto max-w-7xl py-8 px-4">
|
|
208
|
+
<!-- Header -->
|
|
209
|
+
<div class="text-center mb-8">
|
|
210
|
+
<h1 class="text-3xl font-bold tracking-tight">@donkeylabs/server Demo</h1>
|
|
211
|
+
<p class="text-muted-foreground mt-2">SvelteKit Adapter — All Core Services</p>
|
|
212
|
+
<Badge variant="outline" class="mt-3">
|
|
213
|
+
SSR: {data.isSSR ? 'Yes' : 'No'} | Loaded: {data.loadedAt}
|
|
214
|
+
</Badge>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<!-- Grid of feature cards -->
|
|
218
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
|
|
219
|
+
<!-- Counter / RPC Demo -->
|
|
220
|
+
<Card>
|
|
221
|
+
<CardHeader>
|
|
222
|
+
<CardTitle>RPC Routes</CardTitle>
|
|
223
|
+
<CardDescription>Type-safe API calls with Zod validation</CardDescription>
|
|
224
|
+
</CardHeader>
|
|
225
|
+
<CardContent>
|
|
226
|
+
<div class="text-center py-4">
|
|
227
|
+
<span class="text-5xl font-bold text-primary">{count}</span>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="flex gap-2 justify-center">
|
|
230
|
+
<Button variant="outline" size="icon" onclick={() => counterAction('decrement')} disabled={counterLoading}>−</Button>
|
|
231
|
+
<Button variant="secondary" onclick={() => counterAction('get')} disabled={counterLoading}>Refresh</Button>
|
|
232
|
+
<Button variant="outline" size="icon" onclick={() => counterAction('increment')} disabled={counterLoading}>+</Button>
|
|
233
|
+
<Button variant="ghost" onclick={() => counterAction('reset')} disabled={counterLoading}>Reset</Button>
|
|
234
|
+
</div>
|
|
235
|
+
</CardContent>
|
|
236
|
+
</Card>
|
|
237
|
+
|
|
238
|
+
<!-- Cache Demo -->
|
|
239
|
+
<Card>
|
|
240
|
+
<CardHeader>
|
|
241
|
+
<CardTitle>Cache</CardTitle>
|
|
242
|
+
<CardDescription>In-memory caching with TTL support</CardDescription>
|
|
243
|
+
</CardHeader>
|
|
244
|
+
<CardContent class="space-y-3">
|
|
245
|
+
<div class="flex gap-2">
|
|
246
|
+
<Input bind:value={cacheKey} placeholder="Key" class="flex-1" />
|
|
247
|
+
<Input bind:value={cacheValue} placeholder="Value" class="flex-1" />
|
|
248
|
+
</div>
|
|
249
|
+
<div class="flex gap-2">
|
|
250
|
+
<Button onclick={cacheSet} size="sm">Set</Button>
|
|
251
|
+
<Button onclick={cacheGet} size="sm" variant="secondary">Get</Button>
|
|
252
|
+
<Button onclick={cacheDelete} size="sm" variant="outline">Delete</Button>
|
|
253
|
+
</div>
|
|
254
|
+
{#if cacheResult}
|
|
255
|
+
<pre class="text-xs bg-muted p-2 rounded-md overflow-auto">{JSON.stringify(cacheResult, null, 2)}</pre>
|
|
256
|
+
{/if}
|
|
257
|
+
<p class="text-xs text-muted-foreground">
|
|
258
|
+
Keys ({cacheKeys.length}): {cacheKeys.length > 0 ? cacheKeys.join(', ') : 'none'}
|
|
259
|
+
</p>
|
|
260
|
+
</CardContent>
|
|
261
|
+
</Card>
|
|
262
|
+
|
|
263
|
+
<!-- Jobs Demo -->
|
|
264
|
+
<Card>
|
|
265
|
+
<CardHeader>
|
|
266
|
+
<CardTitle>Background Jobs</CardTitle>
|
|
267
|
+
<CardDescription>Async job queue with optional delay</CardDescription>
|
|
268
|
+
</CardHeader>
|
|
269
|
+
<CardContent class="space-y-3">
|
|
270
|
+
<div class="flex gap-2">
|
|
271
|
+
<Input bind:value={jobMessage} placeholder="Job message" class="flex-1" />
|
|
272
|
+
<Input bind:value={jobDelay} type="number" placeholder="Delay" class="w-20" />
|
|
273
|
+
</div>
|
|
274
|
+
<div class="flex gap-2">
|
|
275
|
+
<Button onclick={enqueueJob} size="sm">Enqueue</Button>
|
|
276
|
+
<Button onclick={refreshJobStats} size="sm" variant="outline">Refresh</Button>
|
|
277
|
+
</div>
|
|
278
|
+
{#if lastJobId}
|
|
279
|
+
<p class="text-xs text-muted-foreground">Last Job: <code class="bg-muted px-1 rounded">{lastJobId}</code></p>
|
|
280
|
+
{/if}
|
|
281
|
+
<div class="flex gap-3 text-xs text-muted-foreground">
|
|
282
|
+
<span>Pending: {jobStats.pending}</span>
|
|
283
|
+
<span>Running: {jobStats.running}</span>
|
|
284
|
+
<span>Done: {jobStats.completed}</span>
|
|
285
|
+
</div>
|
|
286
|
+
</CardContent>
|
|
287
|
+
</Card>
|
|
288
|
+
|
|
289
|
+
<!-- Rate Limiter Demo -->
|
|
290
|
+
<Card>
|
|
291
|
+
<CardHeader>
|
|
292
|
+
<CardTitle>Rate Limiter</CardTitle>
|
|
293
|
+
<CardDescription>Sliding window rate limiting</CardDescription>
|
|
294
|
+
</CardHeader>
|
|
295
|
+
<CardContent class="space-y-3">
|
|
296
|
+
<div class="flex gap-2">
|
|
297
|
+
<Input bind:value={rateLimitKey} placeholder="Key" class="flex-1" />
|
|
298
|
+
<Input bind:value={rateLimitMax} type="number" placeholder="Limit" class="w-16" />
|
|
299
|
+
<Input bind:value={rateLimitWindow} type="number" placeholder="Window" class="w-20" />
|
|
300
|
+
</div>
|
|
301
|
+
<div class="flex gap-2">
|
|
302
|
+
<Button onclick={checkRateLimit} size="sm">Check</Button>
|
|
303
|
+
<Button onclick={resetRateLimit} size="sm" variant="outline">Reset</Button>
|
|
304
|
+
</div>
|
|
305
|
+
{#if rateLimitResult}
|
|
306
|
+
<pre class="text-xs p-2 rounded-md overflow-auto {rateLimitResult.allowed === false ? 'bg-destructive/10 text-destructive' : 'bg-muted'}">{JSON.stringify(rateLimitResult, null, 2)}</pre>
|
|
307
|
+
{/if}
|
|
308
|
+
</CardContent>
|
|
309
|
+
</Card>
|
|
310
|
+
|
|
311
|
+
<!-- Cron Demo -->
|
|
312
|
+
<Card>
|
|
313
|
+
<CardHeader>
|
|
314
|
+
<CardTitle>Cron Jobs</CardTitle>
|
|
315
|
+
<CardDescription>Scheduled tasks with cron expressions</CardDescription>
|
|
316
|
+
</CardHeader>
|
|
317
|
+
<CardContent class="space-y-3">
|
|
318
|
+
<Button onclick={refreshCronTasks} size="sm" variant="outline">Refresh Tasks</Button>
|
|
319
|
+
{#if cronTasks.length > 0}
|
|
320
|
+
<ul class="space-y-2">
|
|
321
|
+
{#each cronTasks as task}
|
|
322
|
+
<li class="flex items-center gap-2 text-sm">
|
|
323
|
+
<span class="font-medium">{task.name}</span>
|
|
324
|
+
<code class="text-xs bg-muted px-1 rounded">{task.expression}</code>
|
|
325
|
+
<Badge variant={task.enabled ? 'success' : 'secondary'} class="text-xs">
|
|
326
|
+
{task.enabled ? 'Active' : 'Paused'}
|
|
327
|
+
</Badge>
|
|
328
|
+
</li>
|
|
329
|
+
{/each}
|
|
330
|
+
</ul>
|
|
331
|
+
{:else}
|
|
332
|
+
<p class="text-sm text-muted-foreground italic">No scheduled tasks</p>
|
|
333
|
+
{/if}
|
|
334
|
+
</CardContent>
|
|
335
|
+
</Card>
|
|
336
|
+
|
|
337
|
+
<!-- Events (Pub/Sub) Demo -->
|
|
338
|
+
<Card>
|
|
339
|
+
<CardHeader>
|
|
340
|
+
<CardTitle>Events (Pub/Sub)</CardTitle>
|
|
341
|
+
<CardDescription>Internal event system with wildcards</CardDescription>
|
|
342
|
+
</CardHeader>
|
|
343
|
+
<CardContent class="space-y-3">
|
|
344
|
+
<Input bind:value={eventName} placeholder="Event name (e.g., demo.test)" />
|
|
345
|
+
<textarea
|
|
346
|
+
bind:value={eventData}
|
|
347
|
+
placeholder="JSON data"
|
|
348
|
+
rows="2"
|
|
349
|
+
class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
350
|
+
></textarea>
|
|
351
|
+
<Button onclick={emitEvent} size="sm">Emit Event</Button>
|
|
352
|
+
<p class="text-xs text-muted-foreground italic">Events matching "demo.*" broadcast to SSE</p>
|
|
353
|
+
</CardContent>
|
|
354
|
+
</Card>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<!-- SSE Events Stream - Full Width -->
|
|
358
|
+
<Card>
|
|
359
|
+
<CardHeader class="flex flex-row items-center justify-between">
|
|
360
|
+
<div>
|
|
361
|
+
<CardTitle>Live Events (SSE)</CardTitle>
|
|
362
|
+
<CardDescription>Real-time server → client push</CardDescription>
|
|
363
|
+
</div>
|
|
364
|
+
<div class="flex items-center gap-2">
|
|
365
|
+
<span class="relative flex h-3 w-3">
|
|
366
|
+
{#if sseConnected}
|
|
367
|
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
|
368
|
+
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
|
369
|
+
{:else}
|
|
370
|
+
<span class="relative inline-flex rounded-full h-3 w-3 bg-gray-400"></span>
|
|
371
|
+
{/if}
|
|
372
|
+
</span>
|
|
373
|
+
<span class="text-sm text-muted-foreground">
|
|
374
|
+
{sseConnected ? 'Connected' : 'Disconnected'} ({sseClients.byChannel} clients)
|
|
375
|
+
</span>
|
|
376
|
+
</div>
|
|
377
|
+
</CardHeader>
|
|
378
|
+
<CardContent>
|
|
379
|
+
<div class="flex gap-2 mb-4">
|
|
380
|
+
<Button onclick={manualBroadcast} size="sm">Manual Broadcast</Button>
|
|
381
|
+
<Button onclick={refreshSSEClients} size="sm" variant="outline">Refresh Clients</Button>
|
|
382
|
+
</div>
|
|
383
|
+
{#if events.length === 0}
|
|
384
|
+
<p class="text-sm text-muted-foreground italic">Waiting for events... (cron broadcasts every 5s)</p>
|
|
385
|
+
{:else}
|
|
386
|
+
<ul class="space-y-2 max-h-80 overflow-y-auto">
|
|
387
|
+
{#each events as event}
|
|
388
|
+
<li
|
|
389
|
+
class="flex items-center gap-3 p-3 rounded-lg border bg-muted/50 animate-in slide-in-from-left-2 duration-300"
|
|
390
|
+
>
|
|
391
|
+
<Badge variant={getSourceColor(event.source)}>{getSourceLabel(event.source)}</Badge>
|
|
392
|
+
<span class="flex-1 text-sm font-medium">{event.message}</span>
|
|
393
|
+
<span class="text-xs text-muted-foreground">{new Date(event.timestamp).toLocaleTimeString()}</span>
|
|
394
|
+
</li>
|
|
395
|
+
{/each}
|
|
396
|
+
</ul>
|
|
397
|
+
{/if}
|
|
398
|
+
</CardContent>
|
|
399
|
+
</Card>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Server entry for @donkeylabs/adapter-sveltekit
|
|
2
|
+
import { AppServer, createPlugin, createRouter } from "@donkeylabs/server";
|
|
3
|
+
import { Kysely } from "kysely";
|
|
4
|
+
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// Simple in-memory database
|
|
9
|
+
const db = new Kysely<{}>({
|
|
10
|
+
dialect: new BunSqliteDialect({ database: new Database(":memory:") }),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Random event messages for SSE demo
|
|
14
|
+
const eventMessages = [
|
|
15
|
+
"User logged in",
|
|
16
|
+
"New order placed",
|
|
17
|
+
"Payment received",
|
|
18
|
+
"Item shipped",
|
|
19
|
+
"Review submitted",
|
|
20
|
+
"Comment added",
|
|
21
|
+
"File uploaded",
|
|
22
|
+
"Task completed",
|
|
23
|
+
"Alert triggered",
|
|
24
|
+
"Sync finished",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Demo plugin with all core service integrations
|
|
28
|
+
const demoPlugin = createPlugin.define({
|
|
29
|
+
name: "demo",
|
|
30
|
+
service: async (ctx) => {
|
|
31
|
+
let counter = 0;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
// Counter
|
|
35
|
+
getCounter: () => counter,
|
|
36
|
+
increment: () => ++counter,
|
|
37
|
+
decrement: () => --counter,
|
|
38
|
+
reset: () => { counter = 0; return counter; },
|
|
39
|
+
|
|
40
|
+
// Cache helpers
|
|
41
|
+
cacheSet: async (key: string, value: any, ttl?: number) => {
|
|
42
|
+
await ctx.core.cache.set(key, value, ttl);
|
|
43
|
+
return { success: true };
|
|
44
|
+
},
|
|
45
|
+
cacheGet: async (key: string) => {
|
|
46
|
+
const value = await ctx.core.cache.get(key);
|
|
47
|
+
const exists = await ctx.core.cache.has(key);
|
|
48
|
+
return { value, exists };
|
|
49
|
+
},
|
|
50
|
+
cacheDelete: async (key: string) => {
|
|
51
|
+
await ctx.core.cache.delete(key);
|
|
52
|
+
return { success: true };
|
|
53
|
+
},
|
|
54
|
+
cacheKeys: async () => {
|
|
55
|
+
const keys = await ctx.core.cache.keys();
|
|
56
|
+
return { keys, size: keys.length };
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
// Jobs helpers
|
|
60
|
+
enqueueJob: async (name: string, data: any, delay?: number) => {
|
|
61
|
+
let jobId: string;
|
|
62
|
+
if (delay && delay > 0) {
|
|
63
|
+
const runAt = new Date(Date.now() + delay);
|
|
64
|
+
jobId = await ctx.core.jobs.schedule(name, data, runAt);
|
|
65
|
+
} else {
|
|
66
|
+
jobId = await ctx.core.jobs.enqueue(name, data);
|
|
67
|
+
}
|
|
68
|
+
return { jobId };
|
|
69
|
+
},
|
|
70
|
+
getJobStats: async () => {
|
|
71
|
+
const pending = await ctx.core.jobs.getByName("demo-job", "pending");
|
|
72
|
+
const running = await ctx.core.jobs.getByName("demo-job", "running");
|
|
73
|
+
const completed = await ctx.core.jobs.getByName("demo-job", "completed");
|
|
74
|
+
return {
|
|
75
|
+
pending: pending.length,
|
|
76
|
+
running: running.length,
|
|
77
|
+
completed: completed.length,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Cron helpers
|
|
82
|
+
getCronTasks: () => ctx.core.cron.list().map(t => ({
|
|
83
|
+
id: t.id,
|
|
84
|
+
name: t.name,
|
|
85
|
+
expression: t.expression,
|
|
86
|
+
enabled: t.enabled,
|
|
87
|
+
lastRun: t.lastRun?.toISOString(),
|
|
88
|
+
nextRun: t.nextRun?.toISOString(),
|
|
89
|
+
})),
|
|
90
|
+
|
|
91
|
+
// Rate limiter helpers
|
|
92
|
+
checkRateLimit: async (key: string, limit: number, window: number) => {
|
|
93
|
+
return ctx.core.rateLimiter.check(key, limit, window);
|
|
94
|
+
},
|
|
95
|
+
resetRateLimit: async (key: string) => {
|
|
96
|
+
await ctx.core.rateLimiter.reset(key);
|
|
97
|
+
return { success: true };
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// Events helpers (internal pub/sub)
|
|
101
|
+
emitEvent: async (event: string, data: any) => {
|
|
102
|
+
await ctx.core.events.emit(event, data);
|
|
103
|
+
return { success: true };
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// SSE broadcast
|
|
107
|
+
broadcast: (channel: string, event: string, data: any) => {
|
|
108
|
+
ctx.core.sse.broadcast(channel, event, data);
|
|
109
|
+
return { success: true };
|
|
110
|
+
},
|
|
111
|
+
getSSEClients: () => ({
|
|
112
|
+
total: ctx.core.sse.getClients().length,
|
|
113
|
+
byChannel: ctx.core.sse.getClientsByChannel("events").length,
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
init: async (ctx) => {
|
|
118
|
+
// Register job handler for demo
|
|
119
|
+
ctx.core.jobs.register("demo-job", async (data) => {
|
|
120
|
+
ctx.core.logger.info("Demo job executed", { data });
|
|
121
|
+
// Broadcast job completion via SSE
|
|
122
|
+
ctx.core.sse.broadcast("events", "job-completed", {
|
|
123
|
+
id: Date.now(),
|
|
124
|
+
message: `Job completed: ${data.message || "No message"}`,
|
|
125
|
+
timestamp: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Schedule cron job to broadcast SSE events every 5 seconds
|
|
130
|
+
ctx.core.cron.schedule("*/5 * * * * *", () => {
|
|
131
|
+
const message = eventMessages[Math.floor(Math.random() * eventMessages.length)];
|
|
132
|
+
ctx.core.sse.broadcast("events", "cron-event", {
|
|
133
|
+
id: Date.now(),
|
|
134
|
+
message,
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
source: "cron",
|
|
137
|
+
});
|
|
138
|
+
}, { name: "sse-broadcaster" });
|
|
139
|
+
|
|
140
|
+
// Listen for internal events and broadcast to SSE
|
|
141
|
+
ctx.core.events.on("demo.*", (data) => {
|
|
142
|
+
ctx.core.sse.broadcast("events", "internal-event", {
|
|
143
|
+
id: Date.now(),
|
|
144
|
+
message: `Internal event: ${JSON.stringify(data)}`,
|
|
145
|
+
timestamp: new Date().toISOString(),
|
|
146
|
+
source: "events",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
ctx.core.logger.info("Demo plugin initialized with all core services");
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Create routes
|
|
155
|
+
const api = createRouter("api");
|
|
156
|
+
|
|
157
|
+
// Counter routes
|
|
158
|
+
api.route("counter.get").typed({
|
|
159
|
+
handle: async (_input, ctx) => ({ count: ctx.plugins.demo.getCounter() }),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
api.route("counter.increment").typed({
|
|
163
|
+
handle: async (_input, ctx) => ({ count: ctx.plugins.demo.increment() }),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
api.route("counter.decrement").typed({
|
|
167
|
+
handle: async (_input, ctx) => ({ count: ctx.plugins.demo.decrement() }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
api.route("counter.reset").typed({
|
|
171
|
+
handle: async (_input, ctx) => ({ count: ctx.plugins.demo.reset() }),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Cache routes
|
|
175
|
+
api.route("cache.set").typed({
|
|
176
|
+
input: z.object({
|
|
177
|
+
key: z.string(),
|
|
178
|
+
value: z.any(),
|
|
179
|
+
ttl: z.number().optional()
|
|
180
|
+
}),
|
|
181
|
+
handle: async (input, ctx) => ctx.plugins.demo.cacheSet(input.key, input.value, input.ttl),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
api.route("cache.get").typed({
|
|
185
|
+
input: z.object({ key: z.string() }),
|
|
186
|
+
handle: async (input, ctx) => ctx.plugins.demo.cacheGet(input.key),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
api.route("cache.delete").typed({
|
|
190
|
+
input: z.object({ key: z.string() }),
|
|
191
|
+
handle: async (input, ctx) => ctx.plugins.demo.cacheDelete(input.key),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
api.route("cache.keys").typed({
|
|
195
|
+
handle: async (_input, ctx) => ctx.plugins.demo.cacheKeys(),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Jobs routes
|
|
199
|
+
api.route("jobs.enqueue").typed({
|
|
200
|
+
input: z.object({
|
|
201
|
+
name: z.string().default("demo-job"),
|
|
202
|
+
data: z.any().default({}),
|
|
203
|
+
delay: z.number().optional()
|
|
204
|
+
}),
|
|
205
|
+
handle: async (input, ctx) => ctx.plugins.demo.enqueueJob(input.name, input.data, input.delay),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
api.route("jobs.stats").typed({
|
|
209
|
+
handle: async (_input, ctx) => ctx.plugins.demo.getJobStats(),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Cron routes
|
|
213
|
+
api.route("cron.list").typed({
|
|
214
|
+
handle: async (_input, ctx) => ({ tasks: ctx.plugins.demo.getCronTasks() }),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Rate limiter routes
|
|
218
|
+
api.route("ratelimit.check").typed({
|
|
219
|
+
input: z.object({
|
|
220
|
+
key: z.string().default("demo"),
|
|
221
|
+
limit: z.number().default(5),
|
|
222
|
+
window: z.number().default(60000)
|
|
223
|
+
}),
|
|
224
|
+
handle: async (input, ctx) => ctx.plugins.demo.checkRateLimit(input.key, input.limit, input.window),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
api.route("ratelimit.reset").typed({
|
|
228
|
+
input: z.object({ key: z.string().default("demo") }),
|
|
229
|
+
handle: async (input, ctx) => ctx.plugins.demo.resetRateLimit(input.key),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Events routes (internal pub/sub)
|
|
233
|
+
api.route("events.emit").typed({
|
|
234
|
+
input: z.object({
|
|
235
|
+
event: z.string().default("demo.test"),
|
|
236
|
+
data: z.any().default({ test: true })
|
|
237
|
+
}),
|
|
238
|
+
handle: async (input, ctx) => ctx.plugins.demo.emitEvent(input.event, input.data),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// SSE routes
|
|
242
|
+
api.route("sse.broadcast").typed({
|
|
243
|
+
input: z.object({
|
|
244
|
+
channel: z.string().default("events"),
|
|
245
|
+
event: z.string().default("manual"),
|
|
246
|
+
data: z.any()
|
|
247
|
+
}),
|
|
248
|
+
handle: async (input, ctx) => ctx.plugins.demo.broadcast(input.channel, input.event, input.data),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
api.route("sse.clients").typed({
|
|
252
|
+
handle: async (_input, ctx) => ctx.plugins.demo.getSSEClients(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Create server
|
|
256
|
+
export const server = new AppServer({
|
|
257
|
+
db,
|
|
258
|
+
port: 0, // Port managed by adapter
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Register plugin and routes
|
|
262
|
+
server.registerPlugin(demoPlugin);
|
|
263
|
+
server.use(api);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import adapter from '@donkeylabs/adapter-sveltekit';
|
|
2
|
+
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
3
|
+
|
|
4
|
+
/** @type {import('@sveltejs/kit').Config} */
|
|
5
|
+
const config = {
|
|
6
|
+
preprocess: vitePreprocess(),
|
|
7
|
+
|
|
8
|
+
kit: {
|
|
9
|
+
adapter: adapter({
|
|
10
|
+
serverEntry: './src/server/index.ts',
|
|
11
|
+
}),
|
|
12
|
+
alias: {
|
|
13
|
+
$server: '.@donkeylabs/server',
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default config;
|