@donkeylabs/server 1.1.10 → 1.1.12
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/CLAUDE.md +1029 -307
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -1,313 +1,932 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
3
|
-
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
|
|
4
|
-
alwaysApply:
|
|
2
|
+
description: DonkeyLabs SvelteKit project with type-safe API, plugins, and Svelte 5.
|
|
3
|
+
globs: "*.ts, *.tsx, *.svelte, *.html, *.css, *.js, *.jsx, package.json"
|
|
4
|
+
alwaysApply: true
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
#
|
|
7
|
+
# DonkeyLabs Project
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
This is a **SvelteKit + @donkeylabs/server** full-stack application with type-safe APIs, database plugins, and Svelte 5 frontend.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
##
|
|
14
|
-
|
|
15
|
-
**IMPORTANT: Follow these guidelines when working with this codebase.**
|
|
13
|
+
## CRITICAL RULES
|
|
16
14
|
|
|
17
15
|
### 1. Use MCP Tools First
|
|
18
16
|
|
|
19
|
-
When the `donkeylabs` MCP server is available, **
|
|
17
|
+
When the `donkeylabs` MCP server is available, **ALWAYS use MCP tools** instead of writing code manually:
|
|
20
18
|
|
|
21
|
-
| Task |
|
|
22
|
-
|
|
19
|
+
| Task | MCP Tool |
|
|
20
|
+
|------|----------|
|
|
23
21
|
| Create a plugin | `create_plugin` |
|
|
24
22
|
| Add a route | `add_route` |
|
|
25
23
|
| Add database migration | `add_migration` |
|
|
26
24
|
| Add service method | `add_service_method` |
|
|
27
25
|
| Generate types | `generate_types` |
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
### 2. Database Migrations - KYSELY ONLY
|
|
28
|
+
|
|
29
|
+
**CRITICAL: Migrations MUST use Kysely schema builder. NEVER use raw SQL.**
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// ✅ CORRECT - Kysely schema builder
|
|
33
|
+
import type { Kysely } from "kysely";
|
|
34
|
+
|
|
35
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
36
|
+
await db.schema
|
|
37
|
+
.createTable("users")
|
|
38
|
+
.ifNotExists()
|
|
39
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
40
|
+
.addColumn("email", "text", (col) => col.notNull().unique())
|
|
41
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
42
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
43
|
+
.execute();
|
|
44
|
+
|
|
45
|
+
await db.schema
|
|
46
|
+
.createIndex("idx_users_email")
|
|
47
|
+
.ifNotExists()
|
|
48
|
+
.on("users")
|
|
49
|
+
.column("email")
|
|
50
|
+
.execute();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
54
|
+
await db.schema.dropTable("users").ifExists().execute();
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// ❌ WRONG - Never do this
|
|
60
|
+
import { sql } from "kysely";
|
|
30
61
|
|
|
31
|
-
|
|
62
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
63
|
+
await sql`CREATE TABLE users (id TEXT PRIMARY KEY)`.execute(db); // NO!
|
|
64
|
+
await db.executeQuery(sql`ALTER TABLE...`); // NO!
|
|
65
|
+
}
|
|
66
|
+
```
|
|
32
67
|
|
|
33
|
-
|
|
68
|
+
**Kysely schema builder methods:**
|
|
69
|
+
- `createTable()`, `dropTable()`, `alterTable()`
|
|
70
|
+
- `addColumn()`, `dropColumn()`, `renameColumn()`
|
|
71
|
+
- `createIndex()`, `dropIndex()`
|
|
72
|
+
- Column modifiers: `.primaryKey()`, `.notNull()`, `.unique()`, `.defaultTo()`, `.references()`
|
|
73
|
+
|
|
74
|
+
### 3. Frontend - Svelte 5 & shadcn-svelte ONLY
|
|
75
|
+
|
|
76
|
+
**UI Components:** Use **shadcn-svelte** exclusively. Never use other UI libraries.
|
|
77
|
+
|
|
78
|
+
```svelte
|
|
79
|
+
<!-- ✅ CORRECT - shadcn-svelte components -->
|
|
80
|
+
<script lang="ts">
|
|
81
|
+
import { Button } from "$lib/components/ui/button";
|
|
82
|
+
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
|
83
|
+
import { Input } from "$lib/components/ui/input";
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<Card>
|
|
87
|
+
<CardHeader>
|
|
88
|
+
<CardTitle>My Card</CardTitle>
|
|
89
|
+
</CardHeader>
|
|
90
|
+
<CardContent>
|
|
91
|
+
<Input placeholder="Enter text" />
|
|
92
|
+
<Button onclick={handleClick}>Submit</Button>
|
|
93
|
+
</CardContent>
|
|
94
|
+
</Card>
|
|
95
|
+
```
|
|
34
96
|
|
|
35
|
-
|
|
36
|
-
|---------|------------|
|
|
37
|
-
| Testing | [docs/testing.md](docs/testing.md) - Test harness, unit & integration tests |
|
|
38
|
-
| Database queries | [docs/database.md](docs/database.md) - Use Kysely, NOT raw SQL |
|
|
39
|
-
| Creating plugins | [docs/plugins.md](docs/plugins.md) - Includes plugin vs route decision |
|
|
40
|
-
| Adding routes | [docs/router.md](docs/router.md) |
|
|
41
|
-
| Migrations | [docs/database.md](docs/database.md) - Use Kysely schema builder |
|
|
42
|
-
| Middleware | [docs/middleware.md](docs/middleware.md) |
|
|
43
|
-
| Background jobs | [docs/jobs.md](docs/jobs.md) |
|
|
44
|
-
| Cron tasks | [docs/cron.md](docs/cron.md) |
|
|
97
|
+
**Svelte 5 Patterns - NEVER use $effect, use `watch` from runed:**
|
|
45
98
|
|
|
46
|
-
|
|
99
|
+
```svelte
|
|
100
|
+
<!-- ✅ CORRECT - Svelte 5 runes with runed -->
|
|
101
|
+
<script lang="ts">
|
|
102
|
+
import { onMount } from "svelte";
|
|
103
|
+
import { watch } from "runed";
|
|
47
104
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
- **Migrations**: Use TypeScript migrations with Kysely schema builder (NOT `sql` tagged templates)
|
|
51
|
-
- **Type generation**: Run `donkeylabs generate` after adding plugins/migrations
|
|
52
|
-
- **Thin routes**: Keep route handlers thin; delegate business logic to plugin services
|
|
105
|
+
// Props
|
|
106
|
+
let { data } = $props();
|
|
53
107
|
|
|
54
|
-
|
|
108
|
+
// Reactive state
|
|
109
|
+
let count = $state(0);
|
|
110
|
+
let items = $state<string[]>([]);
|
|
55
111
|
|
|
56
|
-
|
|
112
|
+
// Derived values
|
|
113
|
+
let doubled = $derived(count * 2);
|
|
114
|
+
let total = $derived(items.length);
|
|
57
115
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
116
|
+
// ✅ CORRECT - Use watch from runed for reactive side effects
|
|
117
|
+
watch(
|
|
118
|
+
() => count,
|
|
119
|
+
(newCount) => {
|
|
120
|
+
console.log("Count changed to:", newCount);
|
|
121
|
+
// React to count changes
|
|
122
|
+
}
|
|
123
|
+
);
|
|
61
124
|
|
|
62
|
-
|
|
63
|
-
|
|
125
|
+
// ✅ CORRECT - Watch multiple values
|
|
126
|
+
watch(
|
|
127
|
+
() => [count, items.length],
|
|
128
|
+
([newCount, newLength]) => {
|
|
129
|
+
console.log("Values changed:", newCount, newLength);
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Lifecycle - use onMount for setup/cleanup
|
|
134
|
+
onMount(() => {
|
|
135
|
+
// Setup code here
|
|
136
|
+
fetchData();
|
|
137
|
+
|
|
138
|
+
return () => {
|
|
139
|
+
// Cleanup code here
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Event handlers
|
|
144
|
+
function handleClick() {
|
|
145
|
+
count++;
|
|
146
|
+
}
|
|
147
|
+
</script>
|
|
148
|
+
|
|
149
|
+
<!-- ✅ CORRECT - onclick not on:click -->
|
|
150
|
+
<button onclick={handleClick}>Count: {count}</button>
|
|
64
151
|
```
|
|
65
152
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
153
|
+
```svelte
|
|
154
|
+
<!-- ❌ WRONG - Never use $effect -->
|
|
155
|
+
<script lang="ts">
|
|
156
|
+
let count = $state(0);
|
|
69
157
|
|
|
70
|
-
|
|
158
|
+
// ❌ NEVER DO THIS - use watch from runed instead
|
|
159
|
+
$effect(() => {
|
|
160
|
+
console.log(count); // NO!
|
|
161
|
+
});
|
|
162
|
+
</script>
|
|
163
|
+
```
|
|
71
164
|
|
|
72
|
-
**
|
|
165
|
+
**Svelte 5 Rules:**
|
|
166
|
+
- Use `$state()` for reactive variables
|
|
167
|
+
- Use `$derived()` for computed values
|
|
168
|
+
- Use `$props()` to receive props
|
|
169
|
+
- Use `watch()` from **runed** for reactive side effects, **NEVER $effect**
|
|
170
|
+
- Use `onMount()` for lifecycle setup/cleanup
|
|
171
|
+
- Use `onclick={}` not `on:click={}`
|
|
172
|
+
- Use `{@render children()}` for slots/snippets
|
|
73
173
|
|
|
74
|
-
|
|
75
|
-
# 1. Type check - catch type errors
|
|
76
|
-
bun --bun tsc --noEmit
|
|
174
|
+
---
|
|
77
175
|
|
|
78
|
-
|
|
79
|
-
bun test
|
|
176
|
+
## Project Structure
|
|
80
177
|
|
|
81
|
-
# 3. Generate types - if you added plugins/migrations
|
|
82
|
-
donkeylabs generate
|
|
83
178
|
```
|
|
179
|
+
my-project/
|
|
180
|
+
├── src/
|
|
181
|
+
│ ├── server/ # @donkeylabs/server API
|
|
182
|
+
│ │ ├── index.ts # Server entry point
|
|
183
|
+
│ │ ├── plugins/ # Business logic plugins
|
|
184
|
+
│ │ │ └── users/
|
|
185
|
+
│ │ │ ├── index.ts # Plugin definition
|
|
186
|
+
│ │ │ └── migrations/ # Kysely migrations
|
|
187
|
+
│ │ │ └── 001_create_users_table.ts
|
|
188
|
+
│ │ └── routes/ # API route definitions
|
|
189
|
+
│ │ └── users.ts # User routes
|
|
190
|
+
│ │
|
|
191
|
+
│ ├── lib/
|
|
192
|
+
│ │ ├── api.ts # Generated typed API client (DO NOT EDIT)
|
|
193
|
+
│ │ ├── components/ui/ # shadcn-svelte components
|
|
194
|
+
│ │ └── utils/ # Utility functions
|
|
195
|
+
│ │
|
|
196
|
+
│ ├── routes/ # SvelteKit pages
|
|
197
|
+
│ │ ├── +layout.svelte
|
|
198
|
+
│ │ ├── +page.svelte
|
|
199
|
+
│ │ └── +page.server.ts
|
|
200
|
+
│ │
|
|
201
|
+
│ ├── app.html
|
|
202
|
+
│ ├── app.css
|
|
203
|
+
│ └── hooks.server.ts # SvelteKit hooks
|
|
204
|
+
│
|
|
205
|
+
├── docs/ # Documentation
|
|
206
|
+
├── .mcp.json # MCP server config
|
|
207
|
+
├── svelte.config.js
|
|
208
|
+
├── vite.config.ts
|
|
209
|
+
└── package.json
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
84
213
|
|
|
85
|
-
|
|
214
|
+
## Common Pitfalls - AVOID THESE
|
|
215
|
+
|
|
216
|
+
### 1. State from Props - Does NOT Auto-Update
|
|
217
|
+
|
|
218
|
+
```svelte
|
|
219
|
+
<!-- ❌ WRONG - This only copies initial value, won't update when data changes -->
|
|
220
|
+
<script lang="ts">
|
|
221
|
+
let { data } = $props();
|
|
222
|
+
let users = $state(data.users); // BROKEN! Won't update on navigation
|
|
223
|
+
</script>
|
|
224
|
+
|
|
225
|
+
<!-- ✅ CORRECT - Use $derived for reactive props -->
|
|
226
|
+
<script lang="ts">
|
|
227
|
+
let { data } = $props();
|
|
228
|
+
let users = $derived(data.users); // Updates when data changes
|
|
229
|
+
|
|
230
|
+
// Or if you need to mutate locally:
|
|
231
|
+
let localUsers = $state<User[]>([]);
|
|
232
|
+
watch(() => data.users, (newUsers) => {
|
|
233
|
+
localUsers = [...newUsers];
|
|
234
|
+
});
|
|
235
|
+
</script>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### 2. Loading States - Always Handle Async Properly
|
|
239
|
+
|
|
240
|
+
```svelte
|
|
241
|
+
<!-- ✅ CORRECT - Track loading state -->
|
|
242
|
+
<script lang="ts">
|
|
243
|
+
import { Button } from "$lib/components/ui/button";
|
|
244
|
+
|
|
245
|
+
let loading = $state(false);
|
|
246
|
+
let error = $state<string | null>(null);
|
|
247
|
+
let data = $state<Data | null>(null);
|
|
248
|
+
|
|
249
|
+
async function fetchData() {
|
|
250
|
+
loading = true;
|
|
251
|
+
error = null;
|
|
252
|
+
try {
|
|
253
|
+
data = await api.users.list({});
|
|
254
|
+
} catch (e) {
|
|
255
|
+
error = e instanceof Error ? e.message : "Failed to load";
|
|
256
|
+
} finally {
|
|
257
|
+
loading = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
</script>
|
|
261
|
+
|
|
262
|
+
<Button onclick={fetchData} disabled={loading}>
|
|
263
|
+
{loading ? "Loading..." : "Refresh"}
|
|
264
|
+
</Button>
|
|
265
|
+
{#if error}
|
|
266
|
+
<p class="text-destructive">{error}</p>
|
|
267
|
+
{/if}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### 3. API Client - SSR vs Browser
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
// +page.server.ts - SSR: Pass locals for direct calls (no HTTP)
|
|
274
|
+
export const load = async ({ locals }) => {
|
|
275
|
+
const api = createApi({ locals }); // ✅ Direct call
|
|
276
|
+
return { users: await api.users.list({}) };
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// +page.svelte - Browser: No locals needed (uses HTTP)
|
|
280
|
+
<script lang="ts">
|
|
281
|
+
const api = createApi(); // ✅ HTTP calls
|
|
282
|
+
|
|
283
|
+
// ❌ WRONG - Don't try to pass locals in browser
|
|
284
|
+
// const api = createApi({ locals }); // Won't work!
|
|
285
|
+
</script>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### 5. NEVER Use Raw fetch() or EventSource - Use the Generated Client
|
|
289
|
+
|
|
290
|
+
**The generated client in `$lib/api.ts` handles everything. NEVER bypass it.**
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
// ❌ WRONG - Never use raw fetch
|
|
294
|
+
const response = await fetch('/users.get', {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
body: JSON.stringify({ id: '123' })
|
|
297
|
+
});
|
|
298
|
+
const user = await response.json();
|
|
299
|
+
|
|
300
|
+
// ✅ CORRECT - Use the typed client
|
|
301
|
+
const api = createApi();
|
|
302
|
+
const user = await api.users.get({ id: '123' });
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
// ❌ WRONG - Never use raw EventSource
|
|
307
|
+
const eventSource = new EventSource('/sse?channels=notifications');
|
|
308
|
+
eventSource.onmessage = (e) => console.log(e.data);
|
|
309
|
+
|
|
310
|
+
// ✅ CORRECT - Use client.sse.subscribe()
|
|
311
|
+
const api = createApi();
|
|
312
|
+
const unsubscribe = api.sse.subscribe(
|
|
313
|
+
['notifications'],
|
|
314
|
+
(event, data) => {
|
|
315
|
+
console.log(event, data); // Typed and parsed!
|
|
316
|
+
}
|
|
317
|
+
);
|
|
318
|
+
// Later: unsubscribe();
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**The client provides:**
|
|
322
|
+
- Type safety for all inputs/outputs
|
|
323
|
+
- Automatic JSON parsing
|
|
324
|
+
- SSR direct calls (no HTTP overhead when using `{ locals }`)
|
|
325
|
+
- Auto-reconnect for SSE
|
|
326
|
+
- Proper error handling
|
|
327
|
+
|
|
328
|
+
### 4. Migration Files MUST Be Numbered Sequentially
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
src/server/plugins/users/migrations/
|
|
332
|
+
├── 001_create_users_table.ts ✅ First migration
|
|
333
|
+
├── 002_add_avatar_column.ts ✅ Second migration
|
|
334
|
+
├── 003_create_sessions_table.ts ✅ Third migration
|
|
335
|
+
└── create_something.ts ❌ WRONG - No number prefix!
|
|
336
|
+
```
|
|
86
337
|
|
|
87
338
|
---
|
|
88
339
|
|
|
89
|
-
##
|
|
340
|
+
## Schema Type Definitions
|
|
90
341
|
|
|
91
|
-
|
|
342
|
+
**Define table types alongside your migrations:**
|
|
92
343
|
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
344
|
+
```ts
|
|
345
|
+
// src/server/plugins/users/index.ts
|
|
346
|
+
import { createPlugin } from "@donkeylabs/server";
|
|
347
|
+
import type { Generated, ColumnType } from "kysely";
|
|
348
|
+
|
|
349
|
+
// Define your table schema type
|
|
350
|
+
interface UsersTable {
|
|
351
|
+
id: string;
|
|
352
|
+
email: string;
|
|
353
|
+
name: string;
|
|
354
|
+
avatar_url: string | null;
|
|
355
|
+
created_at: ColumnType<string, string | undefined, never>; // Read: string, Insert: optional, Update: never
|
|
356
|
+
updated_at: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Use in plugin
|
|
360
|
+
export const usersPlugin = createPlugin
|
|
361
|
+
.withSchema<{ users: UsersTable }>()
|
|
362
|
+
.define({
|
|
363
|
+
name: "users",
|
|
364
|
+
service: async (ctx) => ({
|
|
365
|
+
// ctx.db is now typed with users table
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Error Handling
|
|
373
|
+
|
|
374
|
+
### In Plugins - Throw Errors
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// src/server/plugins/users/index.ts
|
|
378
|
+
service: async (ctx) => ({
|
|
379
|
+
getById: async (id: string) => {
|
|
380
|
+
const user = await ctx.db
|
|
381
|
+
.selectFrom("users")
|
|
382
|
+
.where("id", "=", id)
|
|
383
|
+
.selectAll()
|
|
384
|
+
.executeTakeFirst();
|
|
385
|
+
|
|
386
|
+
if (!user) {
|
|
387
|
+
throw ctx.errors.NotFound("User not found"); // ✅ Use ctx.errors
|
|
388
|
+
}
|
|
389
|
+
return user;
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
create: async (data: CreateUserInput) => {
|
|
393
|
+
// Check for duplicates
|
|
394
|
+
const existing = await ctx.db
|
|
395
|
+
.selectFrom("users")
|
|
396
|
+
.where("email", "=", data.email)
|
|
397
|
+
.selectAll()
|
|
398
|
+
.executeTakeFirst();
|
|
399
|
+
|
|
400
|
+
if (existing) {
|
|
401
|
+
throw ctx.errors.BadRequest("Email already exists"); // ✅
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ... create user
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
98
407
|
```
|
|
99
408
|
|
|
100
|
-
|
|
409
|
+
### Available Error Types
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
ctx.errors.BadRequest(message) // 400
|
|
413
|
+
ctx.errors.Unauthorized(message) // 401
|
|
414
|
+
ctx.errors.Forbidden(message) // 403
|
|
415
|
+
ctx.errors.NotFound(message) // 404
|
|
416
|
+
ctx.errors.Conflict(message) // 409
|
|
417
|
+
ctx.errors.InternalError(message) // 500
|
|
418
|
+
```
|
|
101
419
|
|
|
102
420
|
---
|
|
103
421
|
|
|
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
|
-
│ └── plugin.ts # Plugin creation
|
|
134
|
-
├── templates/ # Templates for init and plugin commands
|
|
135
|
-
│ ├── init/ # New project templates
|
|
136
|
-
│ └── plugin/ # Plugin scaffolding templates
|
|
137
|
-
├── examples/ # Example projects
|
|
138
|
-
│ └── starter/ # Complete starter template
|
|
139
|
-
│ ├── src/index.ts
|
|
140
|
-
│ ├── src/plugins/ # Example plugins (stats with middleware)
|
|
141
|
-
│ ├── src/routes/ # Example routes with typing
|
|
142
|
-
│ └── donkeylabs.config.ts
|
|
143
|
-
├── scripts/ # Build and generation scripts
|
|
144
|
-
├── test/ # Test files
|
|
145
|
-
├── registry.d.ts # Auto-generated plugin/handler registry
|
|
146
|
-
└── context.d.ts # Auto-generated GlobalContext type
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
### Generated Files (DO NOT EDIT)
|
|
150
|
-
|
|
151
|
-
- `registry.d.ts` - Plugin and handler type registry
|
|
152
|
-
- `context.d.ts` - Server context with merged schemas
|
|
153
|
-
- `.@donkeylabs/server/` - Generated types in user projects (gitignored)
|
|
422
|
+
## Core Services (ctx.core)
|
|
423
|
+
|
|
424
|
+
Access built-in services via `ctx.core` in plugins:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
service: async (ctx) => ({
|
|
428
|
+
doSomething: async () => {
|
|
429
|
+
// Logging
|
|
430
|
+
ctx.core.logger.info("Something happened", { userId: "123" });
|
|
431
|
+
ctx.core.logger.error("Failed", { error: err.message });
|
|
432
|
+
|
|
433
|
+
// Caching
|
|
434
|
+
await ctx.core.cache.set("key", value, 60000); // 60s TTL
|
|
435
|
+
const cached = await ctx.core.cache.get("key");
|
|
436
|
+
|
|
437
|
+
// Background Jobs
|
|
438
|
+
await ctx.core.jobs.enqueue("send-email", { to: "user@example.com" });
|
|
439
|
+
|
|
440
|
+
// Events (pub/sub)
|
|
441
|
+
await ctx.core.events.emit("user.created", { userId: "123" });
|
|
442
|
+
|
|
443
|
+
// Rate Limiting
|
|
444
|
+
const { allowed } = await ctx.core.rateLimiter.check("user:123", 10, 60000);
|
|
445
|
+
|
|
446
|
+
// SSE Broadcast
|
|
447
|
+
ctx.core.sse.broadcast("notifications", "new-message", { text: "Hello" });
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
```
|
|
154
451
|
|
|
155
452
|
---
|
|
156
453
|
|
|
157
|
-
##
|
|
454
|
+
## Plugin Dependencies
|
|
158
455
|
|
|
159
|
-
|
|
456
|
+
Access other plugins via `ctx.plugins`:
|
|
160
457
|
|
|
458
|
+
```ts
|
|
459
|
+
// src/server/plugins/orders/index.ts
|
|
460
|
+
export const ordersPlugin = createPlugin
|
|
461
|
+
.withSchema<{ orders: OrdersTable }>()
|
|
462
|
+
.define({
|
|
463
|
+
name: "orders",
|
|
464
|
+
dependencies: ["users"], // Declare dependency
|
|
465
|
+
service: async (ctx) => ({
|
|
466
|
+
createOrder: async (userId: string, items: Item[]) => {
|
|
467
|
+
// Access users plugin
|
|
468
|
+
const user = await ctx.plugins.users.getById(userId); // ✅
|
|
469
|
+
if (!user) throw ctx.errors.NotFound("User not found");
|
|
470
|
+
|
|
471
|
+
// Create order...
|
|
472
|
+
},
|
|
473
|
+
}),
|
|
474
|
+
});
|
|
161
475
|
```
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Generated Files - DO NOT EDIT
|
|
480
|
+
|
|
481
|
+
These files are auto-generated and will be overwritten:
|
|
482
|
+
|
|
483
|
+
```
|
|
484
|
+
src/lib/api.ts # Typed API client - regenerated by donkeylabs generate
|
|
485
|
+
.@donkeylabs/ # Type definitions - gitignored
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**After ANY change to plugins, routes, or migrations, run:**
|
|
489
|
+
```sh
|
|
490
|
+
bunx donkeylabs generate
|
|
176
491
|
```
|
|
177
492
|
|
|
178
493
|
---
|
|
179
494
|
|
|
180
|
-
##
|
|
495
|
+
## Creating Features
|
|
181
496
|
|
|
182
|
-
### 1. Create a Plugin
|
|
497
|
+
### 1. Create a Plugin (Business Logic)
|
|
183
498
|
|
|
184
499
|
```ts
|
|
185
|
-
// src/plugins/
|
|
500
|
+
// src/server/plugins/users/index.ts
|
|
186
501
|
import { createPlugin } from "@donkeylabs/server";
|
|
187
502
|
|
|
188
|
-
export const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
503
|
+
export const usersPlugin = createPlugin
|
|
504
|
+
.withSchema<{ users: UsersTable }>()
|
|
505
|
+
.define({
|
|
506
|
+
name: "users",
|
|
507
|
+
service: async (ctx) => ({
|
|
508
|
+
getById: async (id: string) => {
|
|
509
|
+
return ctx.db
|
|
510
|
+
.selectFrom("users")
|
|
511
|
+
.where("id", "=", id)
|
|
512
|
+
.selectAll()
|
|
513
|
+
.executeTakeFirst();
|
|
514
|
+
},
|
|
515
|
+
create: async (data: { email: string; name: string }) => {
|
|
516
|
+
const id = crypto.randomUUID();
|
|
517
|
+
await ctx.db
|
|
518
|
+
.insertInto("users")
|
|
519
|
+
.values({ id, ...data, created_at: new Date().toISOString() })
|
|
520
|
+
.execute();
|
|
521
|
+
return { id };
|
|
522
|
+
},
|
|
523
|
+
}),
|
|
524
|
+
});
|
|
194
525
|
```
|
|
195
526
|
|
|
196
|
-
### 2. Create Routes
|
|
527
|
+
### 2. Create Routes (API Endpoints)
|
|
197
528
|
|
|
198
529
|
```ts
|
|
199
|
-
// src/
|
|
200
|
-
import { createRouter } from "@donkeylabs/server";
|
|
530
|
+
// src/server/routes/users.ts
|
|
531
|
+
import { createRouter, defineRoute } from "@donkeylabs/server";
|
|
201
532
|
import { z } from "zod";
|
|
202
533
|
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
534
|
+
const users = createRouter("users");
|
|
535
|
+
|
|
536
|
+
users.route("get").typed(
|
|
537
|
+
defineRoute({
|
|
538
|
+
input: z.object({ id: z.string() }),
|
|
539
|
+
output: z.object({
|
|
540
|
+
id: z.string(),
|
|
541
|
+
email: z.string(),
|
|
542
|
+
name: z.string(),
|
|
543
|
+
}).nullable(),
|
|
206
544
|
handle: async (input, ctx) => {
|
|
207
|
-
return
|
|
208
|
-
}
|
|
545
|
+
return ctx.plugins.users.getById(input.id);
|
|
546
|
+
},
|
|
547
|
+
})
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
users.route("create").typed(
|
|
551
|
+
defineRoute({
|
|
552
|
+
input: z.object({
|
|
553
|
+
email: z.string().email(),
|
|
554
|
+
name: z.string().min(1),
|
|
555
|
+
}),
|
|
556
|
+
output: z.object({ id: z.string() }),
|
|
557
|
+
handle: async (input, ctx) => {
|
|
558
|
+
return ctx.plugins.users.create(input);
|
|
559
|
+
},
|
|
560
|
+
})
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
export default users;
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
## Feature Module Pattern (Recommended for App Routes)
|
|
569
|
+
|
|
570
|
+
**For app-specific routes, use feature modules with handler classes containing business logic.**
|
|
571
|
+
|
|
572
|
+
**Plugins** = Reusable power-ups (auth, notifications, payments)
|
|
573
|
+
**Feature Modules** = App-specific handlers with business logic
|
|
574
|
+
|
|
575
|
+
### Structure
|
|
576
|
+
|
|
577
|
+
```
|
|
578
|
+
src/server/routes/orders/
|
|
579
|
+
├── index.ts # Router (thin) - just wires handlers to routes
|
|
580
|
+
├── orders.schemas.ts # Zod schemas + TypeScript types
|
|
581
|
+
├── handlers/
|
|
582
|
+
│ ├── create.handler.ts # CreateOrderHandler - contains business logic
|
|
583
|
+
│ ├── list.handler.ts # ListOrdersHandler
|
|
584
|
+
│ └── get-by-id.handler.ts # GetOrderByIdHandler
|
|
585
|
+
└── orders.test.ts # Tests for handlers
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### Handler Class (Business Logic Lives Here)
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
// handlers/create.handler.ts
|
|
592
|
+
import type { Handler, Routes, AppContext } from "$server/api";
|
|
593
|
+
|
|
594
|
+
export class CreateOrderHandler implements Handler<Routes.Orders.Create> {
|
|
595
|
+
constructor(private ctx: AppContext) {}
|
|
596
|
+
|
|
597
|
+
async handle(input: Routes.Orders.Create.Input): Promise<Routes.Orders.Create.Output> {
|
|
598
|
+
// Validate business rules
|
|
599
|
+
const user = await this.ctx.plugins.auth.getCurrentUser();
|
|
600
|
+
if (!user) throw this.ctx.errors.Unauthorized("Must be logged in");
|
|
601
|
+
|
|
602
|
+
// Database operations
|
|
603
|
+
const id = crypto.randomUUID();
|
|
604
|
+
await this.ctx.db
|
|
605
|
+
.insertInto("orders")
|
|
606
|
+
.values({ id, user_id: user.id, ...input })
|
|
607
|
+
.execute();
|
|
608
|
+
|
|
609
|
+
// Use plugins for cross-cutting concerns
|
|
610
|
+
await this.ctx.plugins.notifications.send(user.id, "Order created");
|
|
611
|
+
|
|
612
|
+
// Return result
|
|
613
|
+
return this.ctx.db
|
|
614
|
+
.selectFrom("orders")
|
|
615
|
+
.where("id", "=", id)
|
|
616
|
+
.selectAll()
|
|
617
|
+
.executeTakeFirstOrThrow();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### Router (Thin Wiring Only)
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
// index.ts
|
|
626
|
+
import { createRouter } from "@donkeylabs/server";
|
|
627
|
+
import { createOrderSchema, orderSchema, listOrdersSchema } from "./orders.schemas";
|
|
628
|
+
import { CreateOrderHandler } from "./handlers/create.handler";
|
|
629
|
+
import { ListOrdersHandler } from "./handlers/list.handler";
|
|
630
|
+
|
|
631
|
+
export const ordersRouter = createRouter("orders")
|
|
632
|
+
|
|
633
|
+
.route("create").typed({
|
|
634
|
+
input: createOrderSchema,
|
|
635
|
+
output: orderSchema,
|
|
636
|
+
handle: CreateOrderHandler,
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
.route("list").typed({
|
|
640
|
+
input: listOrdersSchema,
|
|
641
|
+
output: orderSchema.array(),
|
|
642
|
+
handle: ListOrdersHandler,
|
|
209
643
|
});
|
|
210
644
|
```
|
|
211
645
|
|
|
212
|
-
###
|
|
646
|
+
### Schemas (Validation + Types)
|
|
213
647
|
|
|
214
648
|
```ts
|
|
215
|
-
//
|
|
216
|
-
import {
|
|
217
|
-
|
|
649
|
+
// orders.schemas.ts
|
|
650
|
+
import { z } from "zod";
|
|
651
|
+
|
|
652
|
+
export const createOrderSchema = z.object({
|
|
653
|
+
items: z.array(z.object({
|
|
654
|
+
productId: z.string(),
|
|
655
|
+
quantity: z.number().int().positive(),
|
|
656
|
+
})),
|
|
657
|
+
shippingAddress: z.string(),
|
|
658
|
+
});
|
|
218
659
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
660
|
+
export const orderSchema = z.object({
|
|
661
|
+
id: z.string(),
|
|
662
|
+
status: z.enum(["pending", "paid", "shipped", "delivered"]),
|
|
663
|
+
total: z.number(),
|
|
664
|
+
createdAt: z.string(),
|
|
222
665
|
});
|
|
223
666
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
await server.start();
|
|
667
|
+
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
|
|
668
|
+
export type Order = z.infer<typeof orderSchema>;
|
|
227
669
|
```
|
|
228
670
|
|
|
229
|
-
###
|
|
671
|
+
### Testing Handlers Directly
|
|
230
672
|
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
673
|
+
```ts
|
|
674
|
+
// orders.test.ts
|
|
675
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
676
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
677
|
+
import { CreateOrderHandler } from "./handlers/create.handler";
|
|
678
|
+
|
|
679
|
+
describe("CreateOrderHandler", () => {
|
|
680
|
+
let handler: CreateOrderHandler;
|
|
681
|
+
|
|
682
|
+
beforeEach(async () => {
|
|
683
|
+
const { ctx } = await createTestHarness();
|
|
684
|
+
handler = new CreateOrderHandler(ctx);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("creates order successfully", async () => {
|
|
688
|
+
const order = await handler.handle({
|
|
689
|
+
items: [{ productId: "prod-1", quantity: 2 }],
|
|
690
|
+
shippingAddress: "123 Main St",
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
expect(order.id).toBeDefined();
|
|
694
|
+
expect(order.status).toBe("pending");
|
|
695
|
+
});
|
|
696
|
+
});
|
|
236
697
|
```
|
|
237
698
|
|
|
699
|
+
### When to Use Feature Modules vs Plugins
|
|
700
|
+
|
|
701
|
+
| Use Feature Modules | Use Plugins |
|
|
702
|
+
|---------------------|-------------|
|
|
703
|
+
| App-specific routes | Reusable across projects |
|
|
704
|
+
| Business logic for one feature | Shared services (auth, email) |
|
|
705
|
+
| CRUD operations | Database schemas with migrations |
|
|
706
|
+
| Route handlers with context | Middleware, cron jobs, events |
|
|
707
|
+
|
|
238
708
|
---
|
|
239
709
|
|
|
240
|
-
|
|
710
|
+
### Route Handler Types
|
|
241
711
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
712
|
+
**Use the right handler for each use case:**
|
|
713
|
+
|
|
714
|
+
| Handler | Use Case | Input | Output |
|
|
715
|
+
|---------|----------|-------|--------|
|
|
716
|
+
| `.typed()` | Standard JSON APIs | Zod JSON | Zod JSON |
|
|
717
|
+
| `.stream()` | File downloads, video/images | Zod | Response (binary) |
|
|
718
|
+
| `.sse()` | Real-time notifications | Zod | SSE connection |
|
|
719
|
+
| `.formData()` | File uploads | Zod fields + files | Zod JSON |
|
|
720
|
+
| `.html()` | htmx, server components | Zod | HTML string |
|
|
721
|
+
| `.raw()` | Proxies, WebSockets | Request | Response |
|
|
248
722
|
|
|
249
|
-
|
|
723
|
+
```ts
|
|
724
|
+
// Stream handler - for file downloads, video, images
|
|
725
|
+
router.route("files.download").stream({
|
|
726
|
+
input: z.object({ fileId: z.string() }),
|
|
727
|
+
handle: async (input, ctx) => {
|
|
728
|
+
const file = await ctx.plugins.storage.getFile(input.fileId);
|
|
729
|
+
return new Response(file.stream, {
|
|
730
|
+
headers: { "Content-Type": file.mimeType },
|
|
731
|
+
});
|
|
732
|
+
},
|
|
733
|
+
});
|
|
250
734
|
|
|
251
|
-
|
|
735
|
+
// SSE handler - for real-time updates with typed events
|
|
736
|
+
router.route("notifications.subscribe").sse({
|
|
737
|
+
input: z.object({ userId: z.string() }),
|
|
738
|
+
events: {
|
|
739
|
+
notification: z.object({ message: z.string(), id: z.string() }),
|
|
740
|
+
alert: z.object({ level: z.string(), text: z.string() }),
|
|
741
|
+
},
|
|
742
|
+
handle: (input, ctx) => {
|
|
743
|
+
// Return channel names to subscribe to
|
|
744
|
+
return [`user:${input.userId}`, "global"];
|
|
745
|
+
},
|
|
746
|
+
});
|
|
252
747
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
748
|
+
// FormData handler - for file uploads
|
|
749
|
+
router.route("files.upload").formData({
|
|
750
|
+
input: z.object({ folder: z.string() }),
|
|
751
|
+
files: { maxSize: 10 * 1024 * 1024, accept: ["image/*"] },
|
|
752
|
+
handle: async ({ fields, files }, ctx) => {
|
|
753
|
+
const ids = await Promise.all(
|
|
754
|
+
files.map((f) => ctx.plugins.storage.save(f, fields.folder))
|
|
755
|
+
);
|
|
756
|
+
return { ids };
|
|
757
|
+
},
|
|
758
|
+
});
|
|
259
759
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
760
|
+
// HTML handler - for htmx partials
|
|
761
|
+
router.route("partials.userCard").html({
|
|
762
|
+
input: z.object({ userId: z.string() }),
|
|
763
|
+
handle: async (input, ctx) => {
|
|
764
|
+
const user = await ctx.plugins.users.getById(input.userId);
|
|
765
|
+
return `<div class="card">${user.name}</div>`;
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
```
|
|
264
769
|
|
|
265
|
-
|
|
770
|
+
**Using stream routes in Svelte:**
|
|
771
|
+
```svelte
|
|
772
|
+
<script lang="ts">
|
|
773
|
+
const api = createApi();
|
|
774
|
+
</script>
|
|
266
775
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
bun test # Run all tests
|
|
272
|
-
bun --bun tsc --noEmit # Type check
|
|
776
|
+
<!-- Use .url() for browser src attributes -->
|
|
777
|
+
<video src={api.files.download.url({ fileId: "video-123" })} controls />
|
|
778
|
+
<img src={api.images.thumbnail.url({ id: "img-456" })} />
|
|
779
|
+
<a href={api.files.download.url({ fileId: "doc-789" })} download>Download</a>
|
|
273
780
|
```
|
|
274
781
|
|
|
275
|
-
|
|
782
|
+
### 3. Register in Server
|
|
783
|
+
|
|
784
|
+
```ts
|
|
785
|
+
// src/server/index.ts
|
|
786
|
+
import { AppServer } from "@donkeylabs/server";
|
|
787
|
+
import { usersPlugin } from "./plugins/users";
|
|
788
|
+
import usersRoutes from "./routes/users";
|
|
276
789
|
|
|
277
|
-
|
|
790
|
+
export const server = new AppServer({
|
|
791
|
+
db,
|
|
792
|
+
port: 0,
|
|
793
|
+
generateTypes: { output: "./src/lib/api.ts" },
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
server.registerPlugin(usersPlugin);
|
|
797
|
+
server.use(usersRoutes);
|
|
798
|
+
server.handleGenerateMode();
|
|
799
|
+
```
|
|
278
800
|
|
|
279
|
-
|
|
801
|
+
### 4. Use in SvelteKit Page
|
|
280
802
|
|
|
281
803
|
```ts
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
804
|
+
// src/routes/users/+page.server.ts
|
|
805
|
+
import { createApi } from "$lib/api";
|
|
806
|
+
|
|
807
|
+
export const load = async ({ locals }) => {
|
|
808
|
+
const api = createApi({ locals }); // Direct call, no HTTP overhead
|
|
809
|
+
const users = await api.users.list({});
|
|
810
|
+
return { users };
|
|
811
|
+
};
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
```svelte
|
|
815
|
+
<!-- src/routes/users/+page.svelte -->
|
|
816
|
+
<script lang="ts">
|
|
817
|
+
import { Button } from "$lib/components/ui/button";
|
|
818
|
+
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
|
819
|
+
import { createApi } from "$lib/api";
|
|
820
|
+
|
|
821
|
+
let { data } = $props();
|
|
822
|
+
const api = createApi();
|
|
823
|
+
|
|
824
|
+
// Use $derived for SSR data (updates on navigation)
|
|
825
|
+
let users = $derived(data.users);
|
|
826
|
+
|
|
827
|
+
// For refresh, we need local state + watch pattern
|
|
828
|
+
let localUsers = $state<typeof data.users | null>(null);
|
|
829
|
+
let displayUsers = $derived(localUsers ?? users);
|
|
830
|
+
let loading = $state(false);
|
|
831
|
+
|
|
832
|
+
async function refresh() {
|
|
833
|
+
loading = true;
|
|
834
|
+
try {
|
|
835
|
+
localUsers = await api.users.list({});
|
|
836
|
+
} finally {
|
|
837
|
+
loading = false;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
</script>
|
|
841
|
+
|
|
842
|
+
<Card>
|
|
843
|
+
<CardHeader>
|
|
844
|
+
<CardTitle>Users</CardTitle>
|
|
845
|
+
</CardHeader>
|
|
846
|
+
<CardContent>
|
|
847
|
+
{#each displayUsers as user}
|
|
848
|
+
<p>{user.name} - {user.email}</p>
|
|
849
|
+
{/each}
|
|
850
|
+
<Button onclick={refresh} disabled={loading}>
|
|
851
|
+
{loading ? "Loading..." : "Refresh"}
|
|
852
|
+
</Button>
|
|
853
|
+
</CardContent>
|
|
854
|
+
</Card>
|
|
295
855
|
```
|
|
296
856
|
|
|
297
857
|
---
|
|
298
858
|
|
|
299
|
-
##
|
|
859
|
+
## Middleware - Auth, Rate Limiting, etc.
|
|
860
|
+
|
|
861
|
+
**Apply middleware to routes for cross-cutting concerns:**
|
|
300
862
|
|
|
301
863
|
```ts
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
864
|
+
// Routes with middleware chain
|
|
865
|
+
const router = createRouter("api");
|
|
866
|
+
|
|
867
|
+
// Single middleware
|
|
868
|
+
router.middleware
|
|
869
|
+
.auth({ required: true })
|
|
870
|
+
.route("protected").typed({
|
|
871
|
+
handle: async (input, ctx) => {
|
|
872
|
+
// ctx.user is set by auth middleware
|
|
873
|
+
return { userId: ctx.user.id };
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// Chained middleware (executes left to right)
|
|
878
|
+
router.middleware
|
|
879
|
+
.cors({ origin: "*" })
|
|
880
|
+
.auth({ required: true })
|
|
881
|
+
.rateLimit({ limit: 100, window: "1m" })
|
|
882
|
+
.route("admin").typed({
|
|
883
|
+
handle: async (input, ctx) => { ... },
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// Reusable middleware chain
|
|
887
|
+
const protectedRoute = router.middleware
|
|
888
|
+
.auth({ required: true })
|
|
889
|
+
.rateLimit({ limit: 1000, window: "1h" });
|
|
890
|
+
|
|
891
|
+
protectedRoute.route("users.list").typed({ ... });
|
|
892
|
+
protectedRoute.route("users.create").typed({ ... });
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
**Creating custom middleware in a plugin:**
|
|
896
|
+
|
|
897
|
+
```ts
|
|
898
|
+
// src/server/plugins/auth/index.ts
|
|
899
|
+
import { createPlugin, createMiddleware } from "@donkeylabs/server";
|
|
900
|
+
|
|
901
|
+
export const authPlugin = createPlugin.define({
|
|
902
|
+
name: "auth",
|
|
903
|
+
|
|
904
|
+
// Service MUST come before middleware
|
|
905
|
+
service: async (ctx) => ({
|
|
906
|
+
validateToken: async (token: string) => {
|
|
907
|
+
// Validation logic...
|
|
908
|
+
return { id: "user-123", role: "admin" };
|
|
909
|
+
},
|
|
910
|
+
}),
|
|
911
|
+
|
|
912
|
+
// Middleware can access its own service
|
|
913
|
+
middleware: (ctx, service) => ({
|
|
914
|
+
auth: createMiddleware<{ required?: boolean }>(
|
|
915
|
+
async (req, reqCtx, next, config) => {
|
|
916
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
917
|
+
|
|
918
|
+
if (!token && config?.required) {
|
|
919
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (token) {
|
|
923
|
+
reqCtx.user = await service.validateToken(token);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return next(); // Continue to handler
|
|
927
|
+
}
|
|
928
|
+
),
|
|
929
|
+
}),
|
|
311
930
|
});
|
|
312
931
|
```
|
|
313
932
|
|
|
@@ -315,141 +934,244 @@ export default defineConfig({
|
|
|
315
934
|
|
|
316
935
|
## Testing
|
|
317
936
|
|
|
937
|
+
**Use the test harness for plugin testing:**
|
|
938
|
+
|
|
318
939
|
```ts
|
|
940
|
+
// src/server/plugins/users/tests/unit.test.ts
|
|
941
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
319
942
|
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
320
|
-
import {
|
|
943
|
+
import { usersPlugin } from "../index";
|
|
944
|
+
|
|
945
|
+
describe("usersPlugin", () => {
|
|
946
|
+
let users: ReturnType<typeof manager.getServices>["users"];
|
|
947
|
+
let db: Awaited<ReturnType<typeof createTestHarness>>["db"];
|
|
948
|
+
|
|
949
|
+
beforeEach(async () => {
|
|
950
|
+
// Fresh in-memory DB for each test
|
|
951
|
+
const harness = await createTestHarness(usersPlugin);
|
|
952
|
+
users = harness.manager.getServices().users;
|
|
953
|
+
db = harness.db;
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("create() inserts user", async () => {
|
|
957
|
+
const user = await users.create({ email: "test@example.com", name: "Test" });
|
|
958
|
+
|
|
959
|
+
expect(user.id).toBeDefined();
|
|
960
|
+
|
|
961
|
+
// Verify in database
|
|
962
|
+
const dbUser = await db
|
|
963
|
+
.selectFrom("users")
|
|
964
|
+
.where("id", "=", user.id)
|
|
965
|
+
.selectAll()
|
|
966
|
+
.executeTakeFirst();
|
|
967
|
+
|
|
968
|
+
expect(dbUser?.email).toBe("test@example.com");
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
```
|
|
321
972
|
|
|
322
|
-
|
|
973
|
+
**With plugin dependencies:**
|
|
323
974
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
975
|
+
```ts
|
|
976
|
+
import { ordersPlugin } from "../plugins/orders";
|
|
977
|
+
import { usersPlugin } from "../plugins/users";
|
|
978
|
+
|
|
979
|
+
// ordersPlugin depends on usersPlugin
|
|
980
|
+
const { manager } = await createTestHarness(ordersPlugin, [usersPlugin]);
|
|
981
|
+
|
|
982
|
+
const orders = manager.getServices().orders;
|
|
983
|
+
const users = manager.getServices().users;
|
|
327
984
|
```
|
|
328
985
|
|
|
329
986
|
---
|
|
330
987
|
|
|
331
|
-
##
|
|
988
|
+
## Generated API Client - Full Capabilities
|
|
989
|
+
|
|
990
|
+
**ALWAYS use `createApi()` from `$lib/api.ts`. NEVER use raw fetch/EventSource.**
|
|
991
|
+
|
|
992
|
+
### Basic API Calls
|
|
332
993
|
|
|
333
994
|
```ts
|
|
334
|
-
|
|
335
|
-
import { createPlugin, AppServer, createRouter } from "@donkeylabs/server";
|
|
995
|
+
import { createApi } from "$lib/api";
|
|
336
996
|
|
|
337
|
-
|
|
338
|
-
import { RpcClient } from "@donkeylabs/server/client";
|
|
997
|
+
const api = createApi();
|
|
339
998
|
|
|
340
|
-
//
|
|
341
|
-
|
|
999
|
+
// All calls are typed - input and output
|
|
1000
|
+
const user = await api.users.get({ id: "123" });
|
|
1001
|
+
const result = await api.users.create({ email: "a@b.com", name: "Test" });
|
|
342
1002
|
```
|
|
343
1003
|
|
|
344
|
-
|
|
1004
|
+
### SSE (Server-Sent Events)
|
|
345
1005
|
|
|
346
|
-
|
|
1006
|
+
```ts
|
|
1007
|
+
// Subscribe to channels with auto-reconnect
|
|
1008
|
+
const unsubscribe = api.sse.subscribe(
|
|
1009
|
+
["notifications", "alerts"], // Channel names
|
|
1010
|
+
(eventType, data) => {
|
|
1011
|
+
// eventType: "cron-event", "job-completed", "manual", etc.
|
|
1012
|
+
// data: Already parsed JSON
|
|
1013
|
+
console.log(eventType, data);
|
|
1014
|
+
},
|
|
1015
|
+
{ reconnect: true } // Auto-reconnect on disconnect (default: true)
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
// Cleanup in onMount return or when done
|
|
1019
|
+
onMount(() => {
|
|
1020
|
+
const unsub = api.sse.subscribe([...], callback);
|
|
1021
|
+
return () => unsub(); // Cleanup on unmount
|
|
1022
|
+
});
|
|
1023
|
+
```
|
|
347
1024
|
|
|
348
|
-
###
|
|
349
|
-
1. Run `donkeylabs generate` or `bun run gen:registry`
|
|
350
|
-
2. Restart TypeScript language server (Cmd+Shift+P > "Restart TS Server")
|
|
1025
|
+
### File Uploads (FormData)
|
|
351
1026
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
1027
|
+
```ts
|
|
1028
|
+
// If a route uses .formData() handler
|
|
1029
|
+
const result = await api.files.upload(
|
|
1030
|
+
{ folder: "avatars", userId: "123" }, // Typed fields
|
|
1031
|
+
[selectedFile] // File objects
|
|
1032
|
+
);
|
|
1033
|
+
```
|
|
355
1034
|
|
|
356
|
-
###
|
|
357
|
-
1. Make sure `service` comes BEFORE `middleware` in plugin definition
|
|
358
|
-
2. Run `donkeylabs generate` to regenerate types
|
|
359
|
-
3. Restart TypeScript language server
|
|
1035
|
+
### Streaming Responses
|
|
360
1036
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1037
|
+
```ts
|
|
1038
|
+
// For routes that return streams (video, large files, etc.)
|
|
1039
|
+
const response = await api.media.stream({ videoId: "abc" });
|
|
1040
|
+
// response is raw Response - handle as needed
|
|
364
1041
|
|
|
365
|
-
|
|
1042
|
+
// Or get URL for <video>, <img>, <a download>
|
|
1043
|
+
const videoUrl = api.media.streamUrl({ videoId: "abc" });
|
|
1044
|
+
// Use in: <video src={videoUrl}>
|
|
1045
|
+
```
|
|
366
1046
|
|
|
367
|
-
|
|
1047
|
+
### HTML Responses
|
|
368
1048
|
|
|
369
|
-
|
|
1049
|
+
```ts
|
|
1050
|
+
// For routes that return HTML
|
|
1051
|
+
const html = await api.reports.render({ reportId: "123" });
|
|
1052
|
+
// html is a string
|
|
1053
|
+
```
|
|
370
1054
|
|
|
371
|
-
|
|
372
|
-
|-----|------------|
|
|
373
|
-
| `Bun.serve()` | express, fastify |
|
|
374
|
-
| `bun:sqlite` | better-sqlite3 |
|
|
375
|
-
| `Bun.redis` | ioredis |
|
|
376
|
-
| `Bun.sql` | pg, postgres.js |
|
|
377
|
-
| `WebSocket` | ws |
|
|
378
|
-
| `Bun.file()` | fs.readFile |
|
|
379
|
-
| `Bun.$\`cmd\`` | execa |
|
|
1055
|
+
### SSR vs Browser - The Client Handles It
|
|
380
1056
|
|
|
381
|
-
|
|
1057
|
+
```ts
|
|
1058
|
+
// +page.server.ts - Direct calls, no HTTP
|
|
1059
|
+
export const load = async ({ locals }) => {
|
|
1060
|
+
const api = createApi({ locals }); // Uses locals.handleRoute
|
|
1061
|
+
return { data: await api.users.list({}) };
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
// +page.svelte - HTTP calls automatically
|
|
1065
|
+
const api = createApi(); // Uses fetch internally
|
|
1066
|
+
```
|
|
382
1067
|
|
|
383
1068
|
---
|
|
384
1069
|
|
|
385
|
-
##
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
1070
|
+
## Database Queries with Kysely
|
|
1071
|
+
|
|
1072
|
+
**Always use Kysely query builder, never raw SQL:**
|
|
1073
|
+
|
|
1074
|
+
```ts
|
|
1075
|
+
// ✅ SELECT
|
|
1076
|
+
const user = await ctx.db
|
|
1077
|
+
.selectFrom("users")
|
|
1078
|
+
.where("id", "=", id)
|
|
1079
|
+
.selectAll()
|
|
1080
|
+
.executeTakeFirst();
|
|
1081
|
+
|
|
1082
|
+
// ✅ INSERT
|
|
1083
|
+
await ctx.db
|
|
1084
|
+
.insertInto("users")
|
|
1085
|
+
.values({ id, email, name })
|
|
1086
|
+
.execute();
|
|
1087
|
+
|
|
1088
|
+
// ✅ UPDATE
|
|
1089
|
+
await ctx.db
|
|
1090
|
+
.updateTable("users")
|
|
1091
|
+
.set({ name: newName })
|
|
1092
|
+
.where("id", "=", id)
|
|
1093
|
+
.execute();
|
|
1094
|
+
|
|
1095
|
+
// ✅ DELETE
|
|
1096
|
+
await ctx.db
|
|
1097
|
+
.deleteFrom("users")
|
|
1098
|
+
.where("id", "=", id)
|
|
1099
|
+
.execute();
|
|
1100
|
+
|
|
1101
|
+
// ✅ JOIN
|
|
1102
|
+
const orders = await ctx.db
|
|
1103
|
+
.selectFrom("orders")
|
|
1104
|
+
.innerJoin("users", "users.id", "orders.user_id")
|
|
1105
|
+
.select(["orders.id", "orders.total", "users.name"])
|
|
1106
|
+
.execute();
|
|
1107
|
+
```
|
|
410
1108
|
|
|
411
1109
|
---
|
|
412
1110
|
|
|
413
|
-
##
|
|
1111
|
+
## Commands
|
|
1112
|
+
|
|
1113
|
+
```sh
|
|
1114
|
+
bun run dev # Start development server
|
|
1115
|
+
bun run build # Build for production
|
|
1116
|
+
bun test # Run tests
|
|
1117
|
+
bun --bun tsc --noEmit # Type check
|
|
1118
|
+
|
|
1119
|
+
# After adding plugins/routes/migrations:
|
|
1120
|
+
bunx donkeylabs generate # Regenerate types
|
|
1121
|
+
```
|
|
414
1122
|
|
|
415
|
-
|
|
1123
|
+
---
|
|
416
1124
|
|
|
417
|
-
|
|
1125
|
+
## MCP Tools Available
|
|
418
1126
|
|
|
419
1127
|
| Tool | Description |
|
|
420
1128
|
|------|-------------|
|
|
1129
|
+
| `get_project_info` | Get project structure overview |
|
|
421
1130
|
| `create_plugin` | Create a new plugin with correct structure |
|
|
422
|
-
| `
|
|
423
|
-
| `
|
|
424
|
-
| `
|
|
425
|
-
| `
|
|
426
|
-
| `
|
|
427
|
-
| `
|
|
428
|
-
|
|
429
|
-
### Configuration
|
|
430
|
-
|
|
431
|
-
Add to your Claude Code MCP settings:
|
|
432
|
-
|
|
433
|
-
```json
|
|
434
|
-
{
|
|
435
|
-
"mcpServers": {
|
|
436
|
-
"donkeylabs": {
|
|
437
|
-
"command": "bun",
|
|
438
|
-
"args": ["packages/mcp/src/server.ts"]
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
```
|
|
1131
|
+
| `add_migration` | Create a Kysely migration file |
|
|
1132
|
+
| `add_service_method` | Add method to plugin service |
|
|
1133
|
+
| `create_router` | Create a new route file |
|
|
1134
|
+
| `add_route` | Add route to existing router |
|
|
1135
|
+
| `generate_types` | Regenerate TypeScript types |
|
|
1136
|
+
| `list_plugins` | List all plugins and methods |
|
|
443
1137
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
### Example Usage
|
|
1138
|
+
---
|
|
447
1139
|
|
|
448
|
-
|
|
1140
|
+
## Detailed Documentation
|
|
1141
|
+
|
|
1142
|
+
**For advanced topics, read the corresponding file in `docs/`:**
|
|
1143
|
+
|
|
1144
|
+
| Topic | File | When to Read |
|
|
1145
|
+
|-------|------|--------------|
|
|
1146
|
+
| All handler types | `docs/handlers.md` | Creating stream, SSE, formData, HTML, or raw routes |
|
|
1147
|
+
| Middleware | `docs/middleware.md` | Creating custom auth, rate limiting, CORS |
|
|
1148
|
+
| Database & Migrations | `docs/database.md` | Complex Kysely queries, transactions, joins |
|
|
1149
|
+
| Plugins | `docs/plugins.md` | Plugin lifecycle, dependencies, init hooks |
|
|
1150
|
+
| Testing | `docs/testing.md` | Test harness, mocking, integration tests |
|
|
1151
|
+
| Background Jobs | `docs/jobs.md` | Async job processing, retries |
|
|
1152
|
+
| Cron Tasks | `docs/cron.md` | Scheduled tasks |
|
|
1153
|
+
| SSE | `docs/sse.md` | Server-sent events, broadcasting |
|
|
1154
|
+
| Workflows | `docs/workflows.md` | Step functions, parallel execution, state machines |
|
|
1155
|
+
| Router | `docs/router.md` | Route definitions, prefixes, nesting |
|
|
1156
|
+
| Errors | `docs/errors.md` | Custom error types, error handling |
|
|
1157
|
+
| SvelteKit Adapter | `docs/sveltekit-adapter.md` | Hooks, SSR integration, API client |
|
|
449
1158
|
|
|
450
|
-
|
|
451
|
-
Tool: create_plugin
|
|
452
|
-
Args: { "name": "notifications", "hasSchema": true, "dependencies": ["auth"] }
|
|
1159
|
+
---
|
|
453
1160
|
|
|
454
|
-
|
|
455
|
-
|
|
1161
|
+
## Key Reminders
|
|
1162
|
+
|
|
1163
|
+
1. **MCP First**: Always use MCP tools when available
|
|
1164
|
+
2. **Kysely Only**: Never raw SQL in migrations or queries
|
|
1165
|
+
3. **shadcn-svelte**: Only UI library for components
|
|
1166
|
+
4. **No $effect**: Use `watch` from **runed** for reactive effects, `onMount` for lifecycle
|
|
1167
|
+
5. **$derived for props**: Never `$state(data.x)` - use `$derived(data.x)` for reactive props
|
|
1168
|
+
6. **Loading states**: Always track loading/error states for async operations
|
|
1169
|
+
7. **Thin Routes**: Keep handlers thin, business logic in plugins
|
|
1170
|
+
8. **ctx.errors**: Use `ctx.errors.NotFound()`, etc. for proper error responses
|
|
1171
|
+
9. **Number migrations**: Always prefix with 001_, 002_, etc.
|
|
1172
|
+
10. **Generate Types**: Run `bunx donkeylabs generate` after any plugin/route/migration changes
|
|
1173
|
+
11. **SSR vs Browser**: Pass `{ locals }` in +page.server.ts, nothing in +page.svelte
|
|
1174
|
+
12. **Never raw fetch**: ALWAYS use `createApi()` client - never `fetch()` or `new EventSource()`
|
|
1175
|
+
13. **Right handler type**: Use `.typed()` for JSON, `.stream()` for files, `.sse()` for real-time, `.formData()` for uploads
|
|
1176
|
+
14. **Auth via middleware**: Use `router.middleware.auth({ required: true })` for protected routes
|
|
1177
|
+
15. **Test with harness**: Use `createTestHarness(plugin)` for isolated in-memory testing
|