@donkeylabs/server 0.3.0 → 0.4.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.
Files changed (49) hide show
  1. package/LICENSE +1 -1
  2. package/docs/api-client.md +7 -7
  3. package/docs/cache.md +1 -74
  4. package/docs/core-services.md +4 -116
  5. package/docs/cron.md +1 -1
  6. package/docs/errors.md +2 -2
  7. package/docs/events.md +3 -98
  8. package/docs/handlers.md +13 -48
  9. package/docs/logger.md +3 -58
  10. package/docs/middleware.md +2 -2
  11. package/docs/plugins.md +13 -64
  12. package/docs/project-structure.md +4 -142
  13. package/docs/rate-limiter.md +4 -136
  14. package/docs/router.md +6 -14
  15. package/docs/sse.md +1 -99
  16. package/docs/sveltekit-adapter.md +420 -0
  17. package/package.json +8 -11
  18. package/registry.d.ts +15 -14
  19. package/src/core/cache.ts +0 -75
  20. package/src/core/cron.ts +3 -96
  21. package/src/core/errors.ts +78 -11
  22. package/src/core/events.ts +1 -47
  23. package/src/core/index.ts +0 -4
  24. package/src/core/jobs.ts +0 -112
  25. package/src/core/logger.ts +12 -79
  26. package/src/core/rate-limiter.ts +29 -108
  27. package/src/core/sse.ts +1 -84
  28. package/src/core.ts +13 -104
  29. package/src/generator/index.ts +566 -0
  30. package/src/generator/zod-to-ts.ts +114 -0
  31. package/src/handlers.ts +14 -110
  32. package/src/index.ts +30 -24
  33. package/src/middleware.ts +2 -5
  34. package/src/registry.ts +4 -0
  35. package/src/router.ts +47 -1
  36. package/src/server.ts +618 -332
  37. package/README.md +0 -254
  38. package/cli/commands/dev.ts +0 -134
  39. package/cli/commands/generate.ts +0 -605
  40. package/cli/commands/init.ts +0 -205
  41. package/cli/commands/interactive.ts +0 -417
  42. package/cli/commands/plugin.ts +0 -192
  43. package/cli/commands/route.ts +0 -195
  44. package/cli/donkeylabs +0 -2
  45. package/cli/index.ts +0 -114
  46. package/docs/svelte-frontend.md +0 -324
  47. package/docs/testing.md +0 -438
  48. package/mcp/donkeylabs-mcp +0 -3238
  49. package/mcp/server.ts +0 -3238
@@ -1,205 +0,0 @@
1
- /**
2
- * @donkeylabs/server - Init Command
3
- *
4
- * Initialize a new project by copying from examples/starter.
5
- */
6
-
7
- import { mkdir, writeFile, readFile, readdir, copyFile, unlink } from "node:fs/promises";
8
- import { join, resolve, dirname } from "node:path";
9
- import { existsSync } from "node:fs";
10
- import { fileURLToPath } from "node:url";
11
- import pc from "picocolors";
12
- import prompts from "prompts";
13
-
14
- interface InitOptions {
15
- projectName: string;
16
- useDatabase: boolean;
17
- }
18
-
19
- export async function initCommand(args: string[]) {
20
- const projectDir = args[0] || ".";
21
- const targetDir = resolve(process.cwd(), projectDir);
22
-
23
- console.log(pc.bold("\nInitializing @donkeylabs/server project...\n"));
24
-
25
- // Check for existing files
26
- if (existsSync(targetDir)) {
27
- const files = await readdir(targetDir);
28
- const hasConflicts = files.some(
29
- (f) => f === "src" || f === "donkeylabs.config.ts"
30
- );
31
-
32
- if (hasConflicts) {
33
- const { overwrite } = await prompts({
34
- type: "confirm",
35
- name: "overwrite",
36
- message: "Directory contains existing files. Overwrite?",
37
- initial: false,
38
- });
39
-
40
- if (!overwrite) {
41
- console.log(pc.yellow("Cancelled."));
42
- return;
43
- }
44
- }
45
- }
46
-
47
- // Ask for project configuration
48
- const responses = await prompts([
49
- {
50
- type: "text",
51
- name: "projectName",
52
- message: "Project name:",
53
- initial: projectDir === "." ? "my-server" : projectDir,
54
- },
55
- {
56
- type: "confirm",
57
- name: "useDatabase",
58
- message: "Set up SQLite database?",
59
- initial: true,
60
- },
61
- ]);
62
-
63
- if (!responses.projectName) {
64
- console.log(pc.yellow("Cancelled."));
65
- return;
66
- }
67
-
68
- const options: InitOptions = {
69
- projectName: responses.projectName,
70
- useDatabase: responses.useDatabase ?? true,
71
- };
72
-
73
- // Find the examples/starter directory
74
- const __filename = fileURLToPath(import.meta.url);
75
- const __dirname = dirname(__filename);
76
- const starterDir = resolve(__dirname, "../../examples/starter");
77
- const docsDir = resolve(__dirname, "../../docs");
78
-
79
- if (!existsSync(starterDir)) {
80
- console.error(pc.red("Error: examples/starter not found"));
81
- return;
82
- }
83
-
84
- // Copy starter project
85
- await copyDirectory(starterDir, targetDir);
86
- console.log(pc.green(" Copied:"), "project files");
87
-
88
- // Replace placeholders in files
89
- await replaceInFile(
90
- join(targetDir, "package.json"),
91
- "{{PROJECT_NAME}}",
92
- options.projectName
93
- );
94
- await replaceInFile(
95
- join(targetDir, "CLAUDE.md"),
96
- "{{PROJECT_NAME}}",
97
- options.projectName
98
- );
99
-
100
- // Rename .gitignore.template to .gitignore
101
- const gitignoreTemplate = join(targetDir, ".gitignore.template");
102
- if (existsSync(gitignoreTemplate)) {
103
- const content = await readFile(gitignoreTemplate, "utf-8");
104
- await writeFile(join(targetDir, ".gitignore"), content);
105
- await unlink(gitignoreTemplate);
106
- }
107
-
108
- // If not using database, replace db.ts with dummy driver
109
- if (!options.useDatabase) {
110
- await writeFile(join(targetDir, "src/db.ts"), generateDummyDb());
111
- }
112
-
113
- // Copy docs
114
- await mkdir(join(targetDir, "docs"), { recursive: true });
115
- await copyDocs(docsDir, join(targetDir, "docs"));
116
- console.log(pc.green(" Copied:"), "docs/");
117
-
118
- // Print next steps
119
- console.log(`
120
- ${pc.bold(pc.green("Success!"))} Project initialized.
121
-
122
- ${pc.bold("Next steps:")}
123
- 1. Install dependencies:
124
- ${pc.cyan("bun install")}
125
- ${options.useDatabase ? `
126
- 2. Set up your database:
127
- ${pc.cyan("cp .env.example .env")}
128
- ` : ""}
129
- ${options.useDatabase ? "3" : "2"}. Start development:
130
- ${pc.cyan("bun run dev")}
131
-
132
- Run tests:
133
- ${pc.cyan("bun test")}
134
- `);
135
- }
136
-
137
- async function copyDirectory(src: string, dest: string) {
138
- await mkdir(dest, { recursive: true });
139
- const entries = await readdir(src, { withFileTypes: true });
140
-
141
- for (const entry of entries) {
142
- const srcPath = join(src, entry.name);
143
- const destPath = join(dest, entry.name);
144
-
145
- if (entry.isDirectory()) {
146
- await copyDirectory(srcPath, destPath);
147
- } else {
148
- await copyFile(srcPath, destPath);
149
- }
150
- }
151
- }
152
-
153
- async function replaceInFile(filePath: string, search: string, replace: string) {
154
- if (!existsSync(filePath)) return;
155
- const content = await readFile(filePath, "utf-8");
156
- await writeFile(filePath, content.replaceAll(search, replace));
157
- }
158
-
159
- async function copyDocs(src: string, dest: string) {
160
- const docs = [
161
- "plugins.md",
162
- "router.md",
163
- "handlers.md",
164
- "core-services.md",
165
- "errors.md",
166
- "cache.md",
167
- "logger.md",
168
- "events.md",
169
- "jobs.md",
170
- "cron.md",
171
- "sse.md",
172
- "rate-limiter.md",
173
- "middleware.md",
174
- "api-client.md",
175
- "svelte-frontend.md",
176
- ];
177
-
178
- for (const doc of docs) {
179
- const srcPath = join(src, doc);
180
- if (existsSync(srcPath)) {
181
- await copyFile(srcPath, join(dest, doc));
182
- }
183
- }
184
- }
185
-
186
- function generateDummyDb(): string {
187
- return `import {
188
- Kysely,
189
- DummyDriver,
190
- SqliteAdapter,
191
- SqliteIntrospector,
192
- SqliteQueryCompiler,
193
- } from "kysely";
194
-
195
- // No database configured - using dummy driver for type compatibility
196
- export const db = new Kysely<any>({
197
- dialect: {
198
- createAdapter: () => new SqliteAdapter(),
199
- createDriver: () => new DummyDriver(),
200
- createIntrospector: (db) => new SqliteIntrospector(db),
201
- createQueryCompiler: () => new SqliteQueryCompiler(),
202
- },
203
- });
204
- `;
205
- }
@@ -1,417 +0,0 @@
1
- /**
2
- * Interactive CLI Menu
3
- *
4
- * Full interactive experience with context-aware menus
5
- */
6
-
7
- import prompts from "prompts";
8
- import pc from "picocolors";
9
- import { readdir, writeFile, mkdir } from "node:fs/promises";
10
- import { join, basename } from "node:path";
11
- import { existsSync } from "node:fs";
12
- import { exec } from "node:child_process";
13
- import { promisify } from "node:util";
14
-
15
- const execAsync = promisify(exec);
16
-
17
- export async function interactiveCommand() {
18
- console.clear();
19
- console.log(pc.magenta(pc.bold("\n @donkeylabs/server CLI\n")));
20
-
21
- // Detect context - are we in a plugin directory?
22
- const cwd = process.cwd();
23
- const pathParts = cwd.split("/");
24
- const parentDir = pathParts[pathParts.length - 2];
25
- const currentDir = pathParts[pathParts.length - 1];
26
-
27
- let contextPlugin: string | null = null;
28
- if (parentDir === "plugins" && currentDir && existsSync(join(cwd, "index.ts"))) {
29
- contextPlugin = currentDir;
30
- }
31
-
32
- // Run appropriate menu loop
33
- if (contextPlugin) {
34
- await pluginMenuLoop(contextPlugin);
35
- } else {
36
- await globalMenuLoop();
37
- }
38
- }
39
-
40
- // ============================================
41
- // Plugin Context Menu (Inside plugins/<name>/)
42
- // ============================================
43
-
44
- async function pluginMenuLoop(pluginName: string) {
45
- while (true) {
46
- console.log(pc.cyan(`\n Context: Plugin ${pc.bold(`'${pluginName}'`)}\n`));
47
-
48
- const response = await prompts({
49
- type: "select",
50
- name: "action",
51
- message: "What would you like to do?",
52
- choices: [
53
- { title: pc.yellow("1.") + " Generate Schema Types", value: "gen-types" },
54
- { title: pc.yellow("2.") + " Create Migration", value: "migration" },
55
- { title: pc.gray("─".repeat(35)), value: "separator", disabled: true },
56
- { title: pc.blue("←") + " Back to Global Menu", value: "global" },
57
- { title: pc.red("×") + " Exit", value: "exit" },
58
- ],
59
- });
60
-
61
- if (!response.action || response.action === "exit") {
62
- console.log(pc.gray("\nGoodbye!\n"));
63
- process.exit(0);
64
- }
65
-
66
- if (response.action === "global") {
67
- console.clear();
68
- console.log(pc.magenta(pc.bold("\n @donkeylabs/server CLI\n")));
69
- await globalMenuLoop();
70
- return;
71
- }
72
-
73
- console.log(""); // spacing
74
-
75
- switch (response.action) {
76
- case "gen-types":
77
- await runCommand(`bun scripts/generate-types.ts ${pluginName}`);
78
- break;
79
- case "migration":
80
- await createMigration(pluginName);
81
- break;
82
- }
83
-
84
- await pressEnterToContinue();
85
- }
86
- }
87
-
88
- // ============================================
89
- // Global Root Menu
90
- // ============================================
91
-
92
- async function globalMenuLoop() {
93
- while (true) {
94
- console.log(pc.cyan("\n Context: Project Root\n"));
95
-
96
- const choices = [
97
- { title: pc.yellow("1.") + " Add Route", value: "add-route" },
98
- { title: pc.yellow("2.") + " Create New Plugin", value: "new-plugin" },
99
- { title: pc.yellow("3.") + " Initialize New Project", value: "init" },
100
- { title: pc.gray("─".repeat(35)), value: "separator1", disabled: true },
101
- { title: pc.yellow("4.") + " Generate Types", value: "generate" },
102
- { title: pc.yellow("5.") + " Generate Registry", value: "gen-registry" },
103
- { title: pc.yellow("6.") + " Generate Server Context", value: "gen-server" },
104
- { title: pc.gray("─".repeat(35)), value: "separator2", disabled: true },
105
- { title: pc.red("×") + " Exit", value: "exit" },
106
- ];
107
-
108
- const response = await prompts({
109
- type: "select",
110
- name: "action",
111
- message: "Select a command:",
112
- choices,
113
- });
114
-
115
- if (!response.action || response.action === "exit") {
116
- console.log(pc.gray("\nGoodbye!\n"));
117
- process.exit(0);
118
- }
119
-
120
- console.log(""); // spacing
121
-
122
- switch (response.action) {
123
- case "add-route":
124
- await addRoute();
125
- break;
126
- case "new-plugin":
127
- const { pluginCommand } = await import("./plugin");
128
- await pluginCommand(["create"]);
129
- break;
130
- case "init":
131
- const { initCommand } = await import("./init");
132
- await initCommand([]);
133
- break;
134
- case "generate":
135
- const { generateCommand } = await import("./generate");
136
- await generateCommand([]);
137
- break;
138
- case "gen-registry":
139
- await runCommand("bun scripts/generate-registry.ts");
140
- break;
141
- case "gen-server":
142
- await runCommand("bun scripts/generate-server.ts");
143
- break;
144
- }
145
-
146
- await pressEnterToContinue();
147
- }
148
- }
149
-
150
- // ============================================
151
- // Commands
152
- // ============================================
153
-
154
- async function addRoute() {
155
- const routesDir = join(process.cwd(), "src/routes");
156
-
157
- // Find existing routers (namespace directories)
158
- let existingRouters: string[] = [];
159
- if (existsSync(routesDir)) {
160
- const entries = await readdir(routesDir, { withFileTypes: true });
161
- existingRouters = entries
162
- .filter((e) => e.isDirectory() && !e.name.startsWith("."))
163
- .map((e) => e.name);
164
- }
165
-
166
- // Choose router
167
- const routerChoices = [
168
- ...existingRouters.map((r) => ({ title: r, value: r })),
169
- { title: pc.green("+ Create new router"), value: "__new__" },
170
- ];
171
-
172
- const routerRes = await prompts({
173
- type: "select",
174
- name: "router",
175
- message: "Select router (namespace):",
176
- choices: routerChoices,
177
- });
178
-
179
- if (!routerRes.router) return;
180
-
181
- let routerName = routerRes.router;
182
- if (routerName === "__new__") {
183
- const newRouterRes = await prompts({
184
- type: "text",
185
- name: "name",
186
- message: "Router name (e.g. users, orders):",
187
- validate: (v) =>
188
- /^[a-z][a-z0-9-]*$/.test(v) ? true : "Use lowercase letters, numbers, and hyphens",
189
- });
190
- if (!newRouterRes.name) return;
191
- routerName = newRouterRes.name;
192
- }
193
-
194
- // Get route name
195
- const routeRes = await prompts({
196
- type: "text",
197
- name: "name",
198
- message: "Route name (e.g. create, get-by-id):",
199
- validate: (v) =>
200
- /^[a-z][a-z0-9-]*$/.test(v) ? true : "Use lowercase letters, numbers, and hyphens",
201
- });
202
-
203
- if (!routeRes.name) return;
204
-
205
- const routeName = routeRes.name;
206
- const routePath = join(routesDir, routerName, routeName);
207
-
208
- // Create directory structure
209
- await mkdir(join(routePath, "models"), { recursive: true });
210
- await mkdir(join(routePath, "tests"), { recursive: true });
211
-
212
- // Generate PascalCase names
213
- const pascalRouter = toPascalCase(routerName);
214
- const pascalRoute = toPascalCase(routeName);
215
- const camelRoute = routeName.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
216
-
217
- // Create model.ts with Model class
218
- const modelContent = `import { z } from "zod";
219
-
220
- // After running \`donkeylabs generate\`, use typed Handler:
221
- // import type { Handler } from "@donkeylabs/server";
222
- // import type { ${pascalRouter} } from "$server/routes";
223
- // export class ${pascalRoute}Model implements Handler<${pascalRouter}.${pascalRoute}> { ... }
224
-
225
- // Input/Output schemas
226
- export const Input = z.object({
227
- // Define your input schema here
228
- });
229
-
230
- export const Output = z.object({
231
- success: z.boolean(),
232
- });
233
-
234
- export type Input = z.infer<typeof Input>;
235
- export type Output = z.infer<typeof Output>;
236
-
237
- /**
238
- * Model class with handler logic.
239
- * After gen:types, implement Handler<${pascalRouter}.${pascalRoute}> for full typing.
240
- */
241
- export class ${pascalRoute}Model {
242
- constructor(private ctx: any) {}
243
-
244
- handle(input: Input): Output {
245
- return {
246
- success: true,
247
- };
248
- }
249
- }
250
- `;
251
- await writeFile(join(routePath, "models/model.ts"), modelContent);
252
-
253
- // Create index.ts (route definition)
254
- const routeIndexContent = `// Route definition
255
- // After gen:types, use: Route<${pascalRouter}.${pascalRoute}> for full typing
256
- import type { AppRoute } from "@donkeylabs/server";
257
- import { Input, Output, ${pascalRoute}Model } from "./models/model";
258
-
259
- export const ${camelRoute}Route: AppRoute = {
260
- input: Input,
261
- output: Output,
262
- handle: async (input, ctx) => {
263
- const model = new ${pascalRoute}Model(ctx);
264
- return model.handle(input);
265
- },
266
- };
267
- `;
268
- await writeFile(join(routePath, "index.ts"), routeIndexContent);
269
-
270
- // Create unit.test.ts
271
- const unitTestContent = `import { describe, it, expect } from "bun:test";
272
- import { ${pascalRoute}Model, Input } from "../models/model";
273
-
274
- describe("${routerName}.${routeName} model", () => {
275
- it("should return success", () => {
276
- const input = Input.parse({});
277
- const ctx = {} as any;
278
- const model = new ${pascalRoute}Model(ctx);
279
- const result = model.handle(input);
280
- expect(result.success).toBe(true);
281
- });
282
- });
283
- `;
284
- await writeFile(join(routePath, "tests/unit.test.ts"), unitTestContent);
285
-
286
- // Create integ.test.ts
287
- const integTestContent = `import { describe, it, expect } from "bun:test";
288
-
289
- const BASE_URL = process.env.TEST_SERVER_URL || "http://localhost:3000";
290
-
291
- describe("${routerName}.${routeName} (integration)", () => {
292
- // Server must be running for integration tests
293
- it("POST /${routerName}.${routeName} returns success", async () => {
294
- try {
295
- const res = await fetch(\`\${BASE_URL}/${routerName}.${routeName}\`, {
296
- method: "POST",
297
- headers: { "Content-Type": "application/json" },
298
- body: JSON.stringify({}),
299
- });
300
- expect(res.ok).toBe(true);
301
- } catch (e: any) {
302
- if (e.code === "ConnectionRefused") {
303
- console.log("Skipping: Server not running");
304
- return;
305
- }
306
- throw e;
307
- }
308
- });
309
- });
310
- `;
311
- await writeFile(join(routePath, "tests/integ.test.ts"), integTestContent);
312
-
313
- // Check if router index exists, if not create it
314
- const routerIndexPath = join(routesDir, routerName, "index.ts");
315
- if (!existsSync(routerIndexPath)) {
316
- const routerIndexContent = `import { createRouter } from "@donkeylabs/server";
317
- import { ${camelRoute}Route } from "./${routeName}";
318
-
319
- export const ${camelRoute}Router = createRouter("${routerName}")
320
- .route("${routeName}").typed(${camelRoute}Route);
321
- `;
322
- await writeFile(routerIndexPath, routerIndexContent);
323
- console.log(pc.green(`Created router: src/routes/${routerName}/index.ts`));
324
- } else {
325
- console.log(pc.yellow(`\nNote: Add the route to src/routes/${routerName}/index.ts:`));
326
- console.log(pc.gray(` import { ${camelRoute}Route } from "./${routeName}";`));
327
- console.log(pc.gray(` .route("${routeName}").typed(${camelRoute}Route)`));
328
- }
329
-
330
- console.log(pc.green(`\nCreated route: src/routes/${routerName}/${routeName}/`));
331
- console.log(pc.gray(` - index.ts`));
332
- console.log(pc.gray(` - models/model.ts`));
333
- console.log(pc.gray(` - tests/unit.test.ts`));
334
- console.log(pc.gray(` - tests/integ.test.ts`));
335
- console.log(pc.cyan(`\nRun ${pc.bold("donkeylabs generate")} to update types.`));
336
- }
337
-
338
- function toPascalCase(str: string): string {
339
- return str
340
- .replace(/-([a-z])/g, (_, c) => c.toUpperCase())
341
- .replace(/^./, (c) => c.toUpperCase());
342
- }
343
-
344
- async function createMigration(pluginName: string) {
345
- const nameRes = await prompts({
346
- type: "text",
347
- name: "migName",
348
- message: "Migration name (e.g. add_comments):",
349
- validate: (v) =>
350
- /^[a-z0-9_]+$/.test(v) ? true : "Use lowercase letters, numbers, and underscores",
351
- });
352
-
353
- if (!nameRes.migName) return;
354
-
355
- // Determine migrations directory
356
- const cwd = process.cwd();
357
- const isPluginDir = basename(join(cwd, "..")) === "plugins";
358
- const migrationsDir = isPluginDir
359
- ? join(cwd, "migrations")
360
- : join(process.cwd(), "src/plugins", pluginName, "migrations");
361
-
362
- // Generate sequential number
363
- let nextNum = 1;
364
- try {
365
- const files = await readdir(migrationsDir);
366
- const nums = files
367
- .map((f) => parseInt(f.split("_")[0] || "", 10))
368
- .filter((n) => !isNaN(n));
369
- if (nums.length > 0) {
370
- nextNum = Math.max(...nums) + 1;
371
- }
372
- } catch {}
373
-
374
- const filename = `${String(nextNum).padStart(3, "0")}_${nameRes.migName}.ts`;
375
- const content = `import type { Kysely } from "kysely";
376
-
377
- export async function up(db: Kysely<any>): Promise<void> {
378
- // await db.schema.createTable("...").execute();
379
- }
380
-
381
- export async function down(db: Kysely<any>): Promise<void> {
382
- // await db.schema.dropTable("...").execute();
383
- }
384
- `;
385
-
386
- if (!existsSync(migrationsDir)) {
387
- await mkdir(migrationsDir, { recursive: true });
388
- }
389
-
390
- await writeFile(join(migrationsDir, filename), content);
391
- console.log(pc.green(`Created migration: ${filename}`));
392
- }
393
-
394
- // ============================================
395
- // Helpers
396
- // ============================================
397
-
398
- async function runCommand(cmd: string) {
399
- console.log(pc.gray(`> ${cmd}\n`));
400
- try {
401
- const { stdout, stderr } = await execAsync(cmd);
402
- if (stdout) console.log(stdout);
403
- if (stderr) console.error(pc.yellow(stderr));
404
- } catch (e: any) {
405
- console.error(pc.red("Command failed:"), e.message);
406
- }
407
- }
408
-
409
- async function pressEnterToContinue() {
410
- await prompts({
411
- type: "invisible",
412
- name: "continue",
413
- message: pc.gray("Press Enter to continue..."),
414
- });
415
- console.clear();
416
- console.log(pc.magenta(pc.bold("\n @donkeylabs/server CLI\n")));
417
- }