@donkeylabs/server 2.0.37 → 2.2.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.
@@ -0,0 +1,293 @@
1
+ # Swift Adapter
2
+
3
+ `@donkeylabs/adapter-swift` generates a typed Swift Package (SPM) from @donkeylabs/server routes. The generated package includes Codable models, async/await networking, and SSE support.
4
+
5
+ ## Features
6
+
7
+ - **Typed Models** - Zod schemas become Codable Swift structs and enums
8
+ - **Async/Await** - URLSession-based networking with Swift concurrency
9
+ - **All Handler Types** - typed, raw, stream, SSE, formData, and html
10
+ - **SSE Support** - Real-time streaming with typed events and auto-reconnect
11
+ - **API Versioning** - Optional `X-API-Version` header support
12
+ - **SPM Ready** - Generates a complete Swift Package with Package.swift
13
+
14
+ ---
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ bun add @donkeylabs/adapter-swift
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Generation
25
+
26
+ ### CLI
27
+
28
+ ```bash
29
+ donkeylabs generate --adapter swift --output ./swift-client
30
+ ```
31
+
32
+ ### Configuration
33
+
34
+ In `donkeylabs.config.ts`:
35
+
36
+ ```ts
37
+ export default {
38
+ plugins: ["./src/plugins/*"],
39
+ routes: "./src/routes",
40
+ swift: {
41
+ packageName: "MyApi", // SPM package name (default: "ApiClient")
42
+ platforms: { // Minimum platform versions
43
+ iOS: "15.0",
44
+ macOS: "12.0",
45
+ },
46
+ apiVersion: "2.0", // Default X-API-Version header
47
+ },
48
+ };
49
+ ```
50
+
51
+ ### Programmatic
52
+
53
+ ```ts
54
+ import { generateClient } from "@donkeylabs/adapter-swift";
55
+
56
+ await generateClient(config, routes, "./output");
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Generated Structure
62
+
63
+ ```
64
+ MyApi/
65
+ ├── Package.swift
66
+ └── Sources/
67
+ └── MyApi/
68
+ ├── ApiClient.swift # Main client class
69
+ ├── ApiClient+Routes.swift # Route method extensions
70
+ ├── Routes.swift # Route name constants
71
+ ├── ApiClientBase.swift # URLSession networking runtime
72
+ ├── ApiError.swift # Error types
73
+ ├── SSEConnection.swift # SSE streaming runtime
74
+ ├── AnyCodable.swift # Dynamic JSON wrapper
75
+ └── Models/
76
+ ├── UsersModels.swift # Per-namespace model files
77
+ └── OrdersModels.swift
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Usage in Swift
83
+
84
+ ### Setup
85
+
86
+ ```swift
87
+ import MyApi
88
+
89
+ let client = ApiClient(
90
+ baseURL: URL(string: "https://api.example.com")!,
91
+ apiVersion: "2.0"
92
+ )
93
+ ```
94
+
95
+ ### Typed Routes
96
+
97
+ ```swift
98
+ // Input/output are fully typed Codable structs
99
+ let result = try await client.create(CreateInput(name: "Alice", email: "alice@example.com"))
100
+ print(result.id) // String
101
+
102
+ let users = try await client.list()
103
+ for user in users {
104
+ print(user.name)
105
+ }
106
+ ```
107
+
108
+ ### Raw Routes
109
+
110
+ ```swift
111
+ let (data, response) = try await client.upload(
112
+ method: "POST",
113
+ body: fileData,
114
+ headers: ["Content-Type": "application/octet-stream"]
115
+ )
116
+ ```
117
+
118
+ ### SSE Routes
119
+
120
+ ```swift
121
+ let connection = client.stream()
122
+
123
+ // Typed event handling
124
+ connection.on("notification") { (event: NotificationEvent) in
125
+ print(event.message)
126
+ }
127
+
128
+ // Start listening
129
+ Task {
130
+ await connection.connect()
131
+ }
132
+
133
+ // Later: disconnect
134
+ connection.close()
135
+ ```
136
+
137
+ ### Stream Routes
138
+
139
+ ```swift
140
+ let (bytes, response) = try await client.download()
141
+ for try await chunk in bytes {
142
+ // Process streaming data
143
+ }
144
+ ```
145
+
146
+ ### FormData Routes
147
+
148
+ ```swift
149
+ let result = try await client.upload(
150
+ fields: UploadInput(title: "Photo"),
151
+ files: [
152
+ FormFile(name: "file", filename: "photo.jpg", data: imageData, mimeType: "image/jpeg")
153
+ ]
154
+ )
155
+ ```
156
+
157
+ ### HTML Routes
158
+
159
+ ```swift
160
+ let html: String = try await client.home()
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Type Mapping
166
+
167
+ Zod schemas are converted to Swift types:
168
+
169
+ | Zod | Swift |
170
+ |-----|-------|
171
+ | `z.string()` | `String` |
172
+ | `z.number()` | `Double` |
173
+ | `z.boolean()` | `Bool` |
174
+ | `z.date()` | `Date` |
175
+ | `z.bigint()` | `Int64` |
176
+ | `z.any()` / `z.unknown()` | `AnyCodable` |
177
+ | `z.object({...})` | `struct: Codable, Sendable` |
178
+ | `z.array(T)` | `[SwiftType]` |
179
+ | `z.optional()` / `z.nullable()` | `SwiftType?` |
180
+ | `z.enum(["a", "b"])` | `enum: String, Codable, Sendable` |
181
+ | `z.union([A, B])` | `enum` with associated values |
182
+ | `z.record(K, V)` | `[String: SwiftType]` |
183
+ | `z.tuple([A, B])` | `struct` with `_0`, `_1` fields |
184
+ | `z.literal("x")` | `String` / `Int` / `Bool` |
185
+
186
+ ### Nested Types
187
+
188
+ Objects generate named structs. Nested objects use the parent type name as prefix:
189
+
190
+ ```
191
+ z.object({ address: z.object({ street: z.string() }) })
192
+ ```
193
+
194
+ Generates:
195
+ ```swift
196
+ public struct CreateInput: Codable, Sendable {
197
+ public let address: CreateInputAddress
198
+ }
199
+
200
+ public struct CreateInputAddress: Codable, Sendable {
201
+ public let street: String
202
+ }
203
+ ```
204
+
205
+ ### CodingKeys
206
+
207
+ When JSON property names differ from valid Swift identifiers, CodingKeys are generated automatically:
208
+
209
+ ```swift
210
+ public struct Item: Codable, Sendable {
211
+ public let createdAt: String
212
+
213
+ enum CodingKeys: String, CodingKey {
214
+ case createdAt = "created_at"
215
+ }
216
+ }
217
+ ```
218
+
219
+ ---
220
+
221
+ ## Error Handling
222
+
223
+ The generated client uses `ApiError` for all error cases:
224
+
225
+ ```swift
226
+ do {
227
+ let user = try await client.get(GetInput(id: "123"))
228
+ } catch let error as ApiError {
229
+ switch error {
230
+ case .server(let status, let code, let message, let details):
231
+ print("Server error \(status): \(code) - \(message)")
232
+ case .validation(let issues):
233
+ for issue in issues {
234
+ print("Validation: \(issue)")
235
+ }
236
+ case .invalidResponse:
237
+ print("Could not decode response")
238
+ case .networkError(let underlying):
239
+ print("Network: \(underlying.localizedDescription)")
240
+ }
241
+ }
242
+ ```
243
+
244
+ ---
245
+
246
+ ## API Versioning
247
+
248
+ Set a default API version in the client:
249
+
250
+ ```swift
251
+ // All requests include X-API-Version: 2.0
252
+ let client = ApiClient(
253
+ baseURL: URL(string: "https://api.example.com")!,
254
+ apiVersion: "2.0"
255
+ )
256
+ ```
257
+
258
+ The version is sent as the `X-API-Version` header on every request. See [Versioning](versioning.md) for server-side configuration.
259
+
260
+ ---
261
+
262
+ ## Route Constants
263
+
264
+ All route names are available as static constants:
265
+
266
+ ```swift
267
+ // Generated Routes namespace
268
+ Routes.Users.list // "users.list"
269
+ Routes.Users.create // "users.create"
270
+ Routes.Auth.login // "auth.login"
271
+ ```
272
+
273
+ ---
274
+
275
+ ## Adding to an Xcode Project
276
+
277
+ 1. Generate the Swift package:
278
+ ```bash
279
+ donkeylabs generate --adapter swift --output ./ios/ApiClient
280
+ ```
281
+
282
+ 2. In Xcode: File > Add Package Dependencies > Add Local > select the generated folder
283
+
284
+ 3. Import and use:
285
+ ```swift
286
+ import MyApi
287
+ ```
288
+
289
+ ### Minimum Requirements
290
+
291
+ - iOS 15.0+ / macOS 12.0+
292
+ - Swift 5.9+
293
+ - Xcode 15+
@@ -0,0 +1,351 @@
1
+ # API Versioning
2
+
3
+ Router-level API versioning with semver support. Routes declare a version, the server resolves the best match via the `X-API-Version` header, and deprecated versions send standard sunset headers.
4
+
5
+ ## Quick Start
6
+
7
+ ```ts
8
+ import { createRouter, AppServer } from "@donkeylabs/server";
9
+ import { z } from "zod";
10
+
11
+ // Version 1
12
+ const usersV1 = createRouter("users", { version: "1.0.0" });
13
+ usersV1.route("list").typed({
14
+ output: z.object({ users: z.array(z.object({ id: z.string(), name: z.string() })) }),
15
+ handle: async (_input, ctx) => {
16
+ const users = await ctx.db.selectFrom("users").select(["id", "name"]).execute();
17
+ return { users };
18
+ },
19
+ });
20
+
21
+ // Version 2 - adds email field
22
+ const usersV2 = createRouter("users", { version: "2.0.0" });
23
+ usersV2.route("list").typed({
24
+ output: z.object({ users: z.array(z.object({ id: z.string(), name: z.string(), email: z.string() })) }),
25
+ handle: async (_input, ctx) => {
26
+ const users = await ctx.db.selectFrom("users").select(["id", "name", "email"]).execute();
27
+ return { users };
28
+ },
29
+ });
30
+
31
+ const server = new AppServer({ db, versioning: { defaultBehavior: "latest" } });
32
+ server.use(usersV1);
33
+ server.use(usersV2);
34
+ ```
35
+
36
+ **Client requests:**
37
+ ```sh
38
+ # Get latest version (v2)
39
+ curl -X POST http://localhost:3000/users.list
40
+
41
+ # Request specific version
42
+ curl -X POST http://localhost:3000/users.list -H "X-API-Version: 1"
43
+
44
+ # Request minor version
45
+ curl -X POST http://localhost:3000/users.list -H "X-API-Version: 2.0"
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Server Configuration
51
+
52
+ Configure versioning behavior in `AppServer`:
53
+
54
+ ```ts
55
+ const server = new AppServer({
56
+ db,
57
+ versioning: {
58
+ // What to do when no X-API-Version header is sent
59
+ defaultBehavior: "latest", // "latest" | "unversioned" | "error"
60
+
61
+ // Echo the resolved version back in the response header
62
+ echoVersion: true,
63
+
64
+ // Custom header name (default: "X-API-Version")
65
+ headerName: "X-API-Version",
66
+ },
67
+ });
68
+ ```
69
+
70
+ ### Default Behavior Options
71
+
72
+ | Value | Behavior |
73
+ |-------|----------|
74
+ | `"latest"` | No version header = use highest registered version (default) |
75
+ | `"unversioned"` | No version header = only match routes without a version |
76
+ | `"error"` | No version header = return `VERSION_REQUIRED` error |
77
+
78
+ ---
79
+
80
+ ## Router Options
81
+
82
+ ### Setting a Version
83
+
84
+ ```ts
85
+ const router = createRouter("users", { version: "2.1.0" });
86
+ ```
87
+
88
+ Version strings follow semver format: `major.minor.patch`. All three components are optional:
89
+
90
+ ```ts
91
+ createRouter("users", { version: "2" }); // Interpreted as 2.0.0
92
+ createRouter("users", { version: "2.1" }); // Interpreted as 2.1.0
93
+ createRouter("users", { version: "2.1.3" }); // Exact
94
+ ```
95
+
96
+ ### Deprecating a Version
97
+
98
+ ```ts
99
+ const usersV1 = createRouter("users", {
100
+ version: "1.0.0",
101
+ deprecated: {
102
+ sunsetDate: "2025-06-01",
103
+ message: "Use v2 for expanded user fields",
104
+ successor: "2.0.0",
105
+ },
106
+ });
107
+ ```
108
+
109
+ When a deprecated version is resolved, the response includes standard headers:
110
+
111
+ ```
112
+ Sunset: 2025-06-01
113
+ Deprecation: true
114
+ X-Deprecation-Notice: Use v2 for expanded user fields. Upgrade to 2.0.0.
115
+ ```
116
+
117
+ ### Child Router Inheritance
118
+
119
+ Child routers inherit the parent's version unless they override it:
120
+
121
+ ```ts
122
+ const api = createRouter("api", { version: "2.0.0" });
123
+
124
+ // Inherits version 2.0.0
125
+ const users = api.router("users");
126
+
127
+ // Overrides with its own version
128
+ const legacy = api.router("legacy");
129
+ // (set version on the legacy router directly if needed)
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Version Resolution
135
+
136
+ The `X-API-Version` header supports flexible matching:
137
+
138
+ | Header Value | Matches |
139
+ |-------------|---------|
140
+ | `"2"` | Highest 2.x.x version |
141
+ | `"2.1"` | Highest 2.1.x version |
142
+ | `"2.1.3"` | Exact match only |
143
+ | `"2.x"` | Highest 2.x.x (wildcard) |
144
+ | `"2.1.x"` | Highest 2.1.x (wildcard) |
145
+ | (none) | Depends on `defaultBehavior` config |
146
+
147
+ ### Resolution Algorithm
148
+
149
+ 1. Parse the requested version string
150
+ 2. Filter registered versions that satisfy the request
151
+ 3. Sort by semver (highest first)
152
+ 4. Return the highest match, or `null` if none
153
+
154
+ ```ts
155
+ // Registered: 1.0.0, 2.0.0, 2.1.0, 3.0.0
156
+ // Request "2" → resolves to 2.1.0 (highest 2.x)
157
+ // Request "2.0" → resolves to 2.0.0 (exact minor)
158
+ // Request "3" → resolves to 3.0.0
159
+ // Request "4" → null (no match)
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Backward Compatibility
165
+
166
+ Unversioned routers continue to work exactly as before:
167
+
168
+ ```ts
169
+ // No version = always matches when no X-API-Version header is sent
170
+ const router = createRouter("health");
171
+ router.route("ping").typed({
172
+ handle: async () => ({ ok: true }),
173
+ });
174
+ ```
175
+
176
+ Unversioned routes are stored in a separate fast-path map and are never affected by version resolution. Existing applications require **zero changes** to adopt versioning.
177
+
178
+ ---
179
+
180
+ ## Client Usage
181
+
182
+ ### TypeScript Client
183
+
184
+ ```ts
185
+ import { createApiClient } from "./client";
186
+
187
+ // Pin client to a specific API version
188
+ const api = createApiClient({
189
+ baseUrl: "http://localhost:3000",
190
+ apiVersion: "2",
191
+ });
192
+
193
+ // All requests include X-API-Version: 2
194
+ const users = await api.users.list();
195
+ ```
196
+
197
+ ### Test Harness
198
+
199
+ ```ts
200
+ const { api } = await createTestHarness(server);
201
+
202
+ // Call with a specific version
203
+ const result = await api.call("users.list", {}, { version: "1" });
204
+ ```
205
+
206
+ ### `callRoute` (Server-Side)
207
+
208
+ ```ts
209
+ // Call a specific version from within the server
210
+ const result = await server.callRoute("users.list", input, ip, { version: "2" });
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Semver Utilities
216
+
217
+ The versioning module exports utilities for working with semver strings:
218
+
219
+ ```ts
220
+ import { parseSemVer, compareSemVer, resolveVersion } from "@donkeylabs/server";
221
+
222
+ // Parse a version string
223
+ const v = parseSemVer("2.1.3");
224
+ // { major: 2, minor: 1, patch: 3, raw: "2.1.3" }
225
+
226
+ // Compare two versions
227
+ compareSemVer(parseSemVer("1.0.0")!, parseSemVer("2.0.0")!); // -1
228
+
229
+ // Resolve best match from a list
230
+ const versions = ["1.0.0", "2.0.0", "2.1.0"].map(v => parseSemVer(v)!);
231
+ resolveVersion(versions, "2"); // Returns 2.1.0
232
+ ```
233
+
234
+ ### Exported Types
235
+
236
+ ```ts
237
+ interface SemVer {
238
+ major: number;
239
+ minor: number;
240
+ patch: number;
241
+ raw: string;
242
+ }
243
+
244
+ interface VersioningConfig {
245
+ defaultBehavior?: "latest" | "unversioned" | "error";
246
+ echoVersion?: boolean;
247
+ headerName?: string;
248
+ }
249
+
250
+ interface DeprecationInfo {
251
+ sunsetDate?: string;
252
+ message?: string;
253
+ successor?: string;
254
+ }
255
+
256
+ interface RouterOptions {
257
+ version?: string;
258
+ deprecated?: DeprecationInfo;
259
+ }
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Code Generation
265
+
266
+ When running `donkeylabs generate`, versioned routes include version metadata in `RouteInfo`:
267
+
268
+ ```ts
269
+ {
270
+ name: "users.list",
271
+ handler: "typed",
272
+ version: "2.0.0",
273
+ deprecated: false,
274
+ // ...
275
+ }
276
+ ```
277
+
278
+ This metadata is available to all adapters (TypeScript, SvelteKit, Swift) for client generation.
279
+
280
+ ---
281
+
282
+ ## Real-World Example
283
+
284
+ ### Multi-Version API with Deprecation
285
+
286
+ ```ts
287
+ import { createRouter, AppServer } from "@donkeylabs/server";
288
+ import { z } from "zod";
289
+
290
+ // V1 - deprecated, sunset June 2025
291
+ const v1 = createRouter("orders", {
292
+ version: "1.0.0",
293
+ deprecated: {
294
+ sunsetDate: "2025-06-01",
295
+ message: "V1 returns flat totals. Use V2 for itemized pricing.",
296
+ successor: "2.0.0",
297
+ },
298
+ });
299
+
300
+ v1.route("get").typed({
301
+ input: z.object({ id: z.string() }),
302
+ output: z.object({ id: z.string(), total: z.number() }),
303
+ handle: async (input, ctx) => {
304
+ const order = await ctx.plugins.orders.get(input.id);
305
+ return { id: order.id, total: order.total };
306
+ },
307
+ });
308
+
309
+ // V2 - current
310
+ const v2 = createRouter("orders", { version: "2.0.0" });
311
+
312
+ v2.route("get").typed({
313
+ input: z.object({ id: z.string() }),
314
+ output: z.object({
315
+ id: z.string(),
316
+ items: z.array(z.object({ name: z.string(), price: z.number(), qty: z.number() })),
317
+ subtotal: z.number(),
318
+ tax: z.number(),
319
+ total: z.number(),
320
+ }),
321
+ handle: async (input, ctx) => {
322
+ return ctx.plugins.orders.getDetailed(input.id);
323
+ },
324
+ });
325
+
326
+ // Server
327
+ const server = new AppServer({
328
+ db,
329
+ versioning: { defaultBehavior: "latest", echoVersion: true },
330
+ });
331
+
332
+ server.use(v1);
333
+ server.use(v2);
334
+ ```
335
+
336
+ ```sh
337
+ # V1 response includes deprecation headers
338
+ curl -X POST http://localhost:3000/orders.get \
339
+ -H "X-API-Version: 1" \
340
+ -d '{"id": "order-123"}'
341
+ # Response headers:
342
+ # X-API-Version: 1.0.0
343
+ # Sunset: 2025-06-01
344
+ # Deprecation: true
345
+ # X-Deprecation-Notice: V1 returns flat totals. Use V2 for itemized pricing. Upgrade to 2.0.0.
346
+
347
+ # V2 response (default)
348
+ curl -X POST http://localhost:3000/orders.get \
349
+ -d '{"id": "order-123"}'
350
+ # Response header: X-API-Version: 2.0.0
351
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.37",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -75,7 +75,8 @@
75
75
  "@aws-sdk/s3-request-presigner": "^3.0.0",
76
76
  "@playwright/test": "^1.40.0",
77
77
  "pg": "^8.0.0",
78
- "mysql2": "^3.0.0"
78
+ "mysql2": "^3.0.0",
79
+ "ioredis": "^5.0.0"
79
80
  },
80
81
  "peerDependenciesMeta": {
81
82
  "@aws-sdk/client-s3": {
@@ -92,6 +93,9 @@
92
93
  },
93
94
  "mysql2": {
94
95
  "optional": true
96
+ },
97
+ "ioredis": {
98
+ "optional": true
95
99
  }
96
100
  },
97
101
  "dependencies": {