@donkeylabs/adapter-sveltekit 2.0.14 → 2.0.16

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,501 @@
1
+ /**
2
+ * Unified API client for @donkeylabs/adapter-sveltekit
3
+ *
4
+ * Auto-detects environment:
5
+ * - SSR: Direct service calls through locals (no HTTP)
6
+ * - Browser: HTTP calls to API routes
7
+ */
8
+ /**
9
+ * Type-safe SSE connection wrapper.
10
+ * Provides typed event handlers with automatic JSON parsing.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // With auto-reconnect (default)
15
+ * const connection = api.notifications.subscribe({ userId: "123" });
16
+ *
17
+ * // Disable auto-reconnect for manual control
18
+ * const connection = api.notifications.subscribe({ userId: "123" }, {
19
+ * autoReconnect: false
20
+ * });
21
+ *
22
+ * // Custom reconnect delay
23
+ * const connection = api.notifications.subscribe({ userId: "123" }, {
24
+ * reconnectDelay: 5000 // 5 seconds
25
+ * });
26
+ *
27
+ * // Typed event handler - returns unsubscribe function
28
+ * const unsubscribe = connection.on("notification", (data) => {
29
+ * console.log(data.message); // Fully typed!
30
+ * });
31
+ *
32
+ * // Later: unsubscribe from this specific handler
33
+ * unsubscribe();
34
+ *
35
+ * // Close entire connection
36
+ * connection.close();
37
+ * ```
38
+ */
39
+ export class SSEConnection {
40
+ eventSource;
41
+ handlers = new Map();
42
+ url;
43
+ options;
44
+ reconnectTimeout = null;
45
+ closed = false;
46
+ constructor(url, options = {}) {
47
+ this.url = url;
48
+ this.options = {
49
+ autoReconnect: true,
50
+ reconnectDelay: 3000,
51
+ ...options,
52
+ };
53
+ this.eventSource = this.createEventSource();
54
+ }
55
+ createEventSource() {
56
+ const es = new EventSource(this.url);
57
+ es.onopen = () => {
58
+ this.options.onConnect?.();
59
+ };
60
+ es.onerror = () => {
61
+ // Native EventSource auto-reconnects, but we want control
62
+ // Close it and handle reconnection ourselves
63
+ if (this.options.autoReconnect && !this.closed) {
64
+ this.options.onDisconnect?.();
65
+ es.close();
66
+ this.scheduleReconnect();
67
+ }
68
+ };
69
+ // Re-attach existing handlers to new EventSource
70
+ for (const [event] of this.handlers) {
71
+ es.addEventListener(event, (e) => {
72
+ this.dispatchEvent(event, e.data);
73
+ });
74
+ }
75
+ return es;
76
+ }
77
+ scheduleReconnect() {
78
+ if (this.reconnectTimeout || this.closed)
79
+ return;
80
+ this.reconnectTimeout = setTimeout(() => {
81
+ this.reconnectTimeout = null;
82
+ if (!this.closed) {
83
+ this.eventSource = this.createEventSource();
84
+ }
85
+ }, this.options.reconnectDelay);
86
+ }
87
+ dispatchEvent(event, rawData) {
88
+ const handlers = this.handlers.get(event);
89
+ if (!handlers)
90
+ return;
91
+ let data;
92
+ try {
93
+ data = JSON.parse(rawData);
94
+ }
95
+ catch {
96
+ data = rawData;
97
+ }
98
+ for (const h of handlers) {
99
+ h(data);
100
+ }
101
+ }
102
+ /**
103
+ * Register a typed event handler.
104
+ * @returns Unsubscribe function to remove this specific handler
105
+ */
106
+ on(event, handler) {
107
+ const isNewEvent = !this.handlers.has(event);
108
+ if (isNewEvent) {
109
+ this.handlers.set(event, new Set());
110
+ // Add EventSource listener for this event type
111
+ this.eventSource?.addEventListener(event, (e) => {
112
+ this.dispatchEvent(event, e.data);
113
+ });
114
+ }
115
+ this.handlers.get(event).add(handler);
116
+ // Return unsubscribe function
117
+ return () => {
118
+ const handlers = this.handlers.get(event);
119
+ if (handlers) {
120
+ handlers.delete(handler);
121
+ }
122
+ };
123
+ }
124
+ /**
125
+ * Register a typed event handler that fires only once.
126
+ * @returns Unsubscribe function to remove this specific handler
127
+ */
128
+ once(event, handler) {
129
+ const wrappedHandler = (data) => {
130
+ unsubscribe();
131
+ handler(data);
132
+ };
133
+ const unsubscribe = this.on(event, wrappedHandler);
134
+ return unsubscribe;
135
+ }
136
+ /**
137
+ * Remove all handlers for an event.
138
+ */
139
+ off(event) {
140
+ this.handlers.delete(event);
141
+ }
142
+ /**
143
+ * Register error handler.
144
+ * Note: With autoReconnect enabled, errors trigger automatic reconnection.
145
+ */
146
+ onError(handler) {
147
+ const wrappedHandler = (e) => {
148
+ handler(e);
149
+ };
150
+ if (this.eventSource) {
151
+ const existingHandler = this.eventSource.onerror;
152
+ this.eventSource.onerror = (e) => {
153
+ // Call existing handler (for reconnection logic)
154
+ if (existingHandler)
155
+ existingHandler(e);
156
+ wrappedHandler(e);
157
+ };
158
+ }
159
+ return () => {
160
+ // Can't easily remove, but handler will be replaced on reconnect
161
+ };
162
+ }
163
+ /**
164
+ * Register open handler (connection established)
165
+ */
166
+ onOpen(handler) {
167
+ if (this.eventSource) {
168
+ const existingHandler = this.eventSource.onopen;
169
+ this.eventSource.onopen = (e) => {
170
+ if (existingHandler)
171
+ existingHandler(e);
172
+ handler(e);
173
+ };
174
+ }
175
+ return () => {
176
+ if (this.eventSource)
177
+ this.eventSource.onopen = null;
178
+ };
179
+ }
180
+ /**
181
+ * Get connection state
182
+ */
183
+ get readyState() {
184
+ return this.eventSource?.readyState ?? EventSource.CLOSED;
185
+ }
186
+ /**
187
+ * Check if connected
188
+ */
189
+ get connected() {
190
+ return this.eventSource?.readyState === EventSource.OPEN;
191
+ }
192
+ /**
193
+ * Check if reconnecting
194
+ */
195
+ get reconnecting() {
196
+ return this.reconnectTimeout !== null;
197
+ }
198
+ /**
199
+ * Close the SSE connection and stop reconnection attempts
200
+ */
201
+ close() {
202
+ this.closed = true;
203
+ if (this.reconnectTimeout) {
204
+ clearTimeout(this.reconnectTimeout);
205
+ this.reconnectTimeout = null;
206
+ }
207
+ this.eventSource?.close();
208
+ this.eventSource = null;
209
+ this.handlers.clear();
210
+ }
211
+ }
212
+ /**
213
+ * Base class for unified API clients.
214
+ * Extend this class with your generated route methods.
215
+ */
216
+ export class UnifiedApiClientBase {
217
+ baseUrl;
218
+ locals;
219
+ isSSR;
220
+ customFetch;
221
+ constructor(options) {
222
+ this.baseUrl = options?.baseUrl ?? "";
223
+ this.locals = options?.locals;
224
+ this.isSSR = typeof window === "undefined";
225
+ this.customFetch = options?.fetch;
226
+ }
227
+ /**
228
+ * Make a request to an API route.
229
+ * Automatically uses direct calls in SSR (when locals.handleRoute is available), HTTP otherwise.
230
+ */
231
+ async request(route, input, options) {
232
+ // Use direct route handler if available (SSR with locals)
233
+ if (this.locals?.handleRoute) {
234
+ return this.locals.handleRoute(route, input);
235
+ }
236
+ // Fall back to HTTP (browser or SSR without locals)
237
+ return this.httpCall(route, input, options);
238
+ }
239
+ /**
240
+ * HTTP call to API endpoint (browser or SSR with event.fetch).
241
+ */
242
+ async httpCall(route, input, options) {
243
+ const url = `${this.baseUrl}/${route}`;
244
+ const fetchFn = this.customFetch ?? fetch;
245
+ const response = await fetchFn(url, {
246
+ method: "POST",
247
+ headers: {
248
+ "Content-Type": "application/json",
249
+ ...options?.headers,
250
+ },
251
+ body: JSON.stringify(input),
252
+ signal: options?.signal,
253
+ });
254
+ if (!response.ok) {
255
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
256
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
257
+ }
258
+ return response.json();
259
+ }
260
+ /**
261
+ * Make a raw request (for non-JSON endpoints like streaming).
262
+ * Returns the raw Response object without processing.
263
+ */
264
+ async rawRequest(route, init) {
265
+ const url = `${this.baseUrl}/${route}`;
266
+ const fetchFn = this.customFetch ?? fetch;
267
+ return fetchFn(url, {
268
+ method: "POST",
269
+ ...init,
270
+ });
271
+ }
272
+ /**
273
+ * Make a stream request (validated input, Response output).
274
+ * For streaming, binary data, or custom content-type responses.
275
+ *
276
+ * By default uses POST with JSON body. For browser compatibility
277
+ * (video src, image src, download links), use streamUrl() instead.
278
+ */
279
+ async streamRequest(route, input, options) {
280
+ const url = `${this.baseUrl}/${route}`;
281
+ const fetchFn = this.customFetch ?? fetch;
282
+ const response = await fetchFn(url, {
283
+ method: "POST",
284
+ headers: {
285
+ "Content-Type": "application/json",
286
+ ...options?.headers,
287
+ },
288
+ body: JSON.stringify(input),
289
+ signal: options?.signal,
290
+ });
291
+ // Unlike typed requests, we return the raw Response
292
+ // Error handling is left to the caller
293
+ return response;
294
+ }
295
+ /**
296
+ * Get the URL for a stream endpoint (for browser src attributes).
297
+ * Returns a URL with query params that can be used in:
298
+ * - <video src={url}>
299
+ * - <img src={url}>
300
+ * - <a href={url} download>
301
+ * - window.open(url)
302
+ */
303
+ streamUrl(route, input) {
304
+ let url = `${this.baseUrl}/${route}`;
305
+ if (input && typeof input === "object") {
306
+ const params = new URLSearchParams();
307
+ for (const [key, value] of Object.entries(input)) {
308
+ params.set(key, typeof value === "string" ? value : JSON.stringify(value));
309
+ }
310
+ url += `?${params.toString()}`;
311
+ }
312
+ return url;
313
+ }
314
+ /**
315
+ * Fetch a stream via GET with query params.
316
+ * Alternative to streamRequest() for cases where GET is preferred.
317
+ */
318
+ async streamGet(route, input, options) {
319
+ const url = this.streamUrl(route, input);
320
+ const fetchFn = this.customFetch ?? fetch;
321
+ return fetchFn(url, {
322
+ method: "GET",
323
+ headers: options?.headers,
324
+ signal: options?.signal,
325
+ });
326
+ }
327
+ /**
328
+ * Connect to an SSE endpoint.
329
+ * Returns a typed SSEConnection for handling server-sent events.
330
+ */
331
+ sseConnect(route, input, options) {
332
+ let url = `${this.baseUrl}/${route}`;
333
+ // Add input as query params for GET request
334
+ if (input && typeof input === "object") {
335
+ const params = new URLSearchParams();
336
+ for (const [key, value] of Object.entries(input)) {
337
+ params.set(key, typeof value === "string" ? value : JSON.stringify(value));
338
+ }
339
+ url += `?${params.toString()}`;
340
+ }
341
+ return new SSEConnection(url, options);
342
+ }
343
+ /**
344
+ * Connect to a specific SSE route endpoint.
345
+ * Alias for sseConnect() - provides compatibility with @donkeylabs/server generated clients.
346
+ * @returns SSE connection with typed event handlers
347
+ */
348
+ connectToSSERoute(route, input = {}, options) {
349
+ // Map SSEOptions to SSEConnectionOptions
350
+ const connectionOptions = options ? {
351
+ autoReconnect: options.autoReconnect,
352
+ reconnectDelay: options.reconnectDelay,
353
+ onConnect: options.onConnect,
354
+ onDisconnect: options.onDisconnect,
355
+ } : undefined;
356
+ return this.sseConnect(route, input, connectionOptions);
357
+ }
358
+ /**
359
+ * Make a formData request (file uploads with validated fields).
360
+ */
361
+ async formDataRequest(route, fields, files, options) {
362
+ const url = `${this.baseUrl}/${route}`;
363
+ const fetchFn = this.customFetch ?? fetch;
364
+ const formData = new FormData();
365
+ // Add fields
366
+ if (fields && typeof fields === "object") {
367
+ for (const [key, value] of Object.entries(fields)) {
368
+ formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
369
+ }
370
+ }
371
+ // Add files
372
+ for (const file of files) {
373
+ formData.append("file", file);
374
+ }
375
+ const response = await fetchFn(url, {
376
+ method: "POST",
377
+ headers: options?.headers,
378
+ body: formData,
379
+ signal: options?.signal,
380
+ });
381
+ if (!response.ok) {
382
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
383
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
384
+ }
385
+ return response.json();
386
+ }
387
+ /**
388
+ * Make an HTML request (returns HTML string).
389
+ */
390
+ async htmlRequest(route, input, options) {
391
+ let url = `${this.baseUrl}/${route}`;
392
+ const fetchFn = this.customFetch ?? fetch;
393
+ // Add input as query params for GET request
394
+ if (input && typeof input === "object") {
395
+ const params = new URLSearchParams();
396
+ for (const [key, value] of Object.entries(input)) {
397
+ params.set(key, typeof value === "string" ? value : JSON.stringify(value));
398
+ }
399
+ url += `?${params.toString()}`;
400
+ }
401
+ const response = await fetchFn(url, {
402
+ method: "GET",
403
+ headers: {
404
+ Accept: "text/html",
405
+ ...options?.headers,
406
+ },
407
+ signal: options?.signal,
408
+ });
409
+ if (!response.ok) {
410
+ throw new Error(`HTTP ${response.status}`);
411
+ }
412
+ return response.text();
413
+ }
414
+ /**
415
+ * SSE (Server-Sent Events) subscription.
416
+ * Only works in the browser.
417
+ */
418
+ sse = {
419
+ /**
420
+ * Subscribe to SSE channels.
421
+ * Returns a function to unsubscribe.
422
+ *
423
+ * @example
424
+ * const unsub = api.sse.subscribe(["notifications"], (event, data) => {
425
+ * console.log(event, data);
426
+ * });
427
+ * // Later: unsub();
428
+ */
429
+ subscribe: (channels, callback, options) => {
430
+ if (typeof window === "undefined") {
431
+ // SSR - return no-op
432
+ return () => { };
433
+ }
434
+ const url = `${this.baseUrl}/sse?channels=${channels.join(",")}`;
435
+ let eventSource = new EventSource(url);
436
+ let reconnectTimeout = null;
437
+ // Known event types from the server
438
+ const eventTypes = [
439
+ 'cron-event', 'job-completed', 'internal-event', 'manual', 'message',
440
+ // Workflow events
441
+ 'workflow.started', 'workflow.progress', 'workflow.completed',
442
+ 'workflow.failed', 'workflow.cancelled',
443
+ 'workflow.step.started', 'workflow.step.completed', 'workflow.step.failed',
444
+ ];
445
+ const handleMessage = (e) => {
446
+ try {
447
+ const data = JSON.parse(e.data);
448
+ callback(e.type || "message", data);
449
+ }
450
+ catch {
451
+ callback(e.type || "message", e.data);
452
+ }
453
+ };
454
+ const handleError = () => {
455
+ if (options?.reconnect !== false && eventSource) {
456
+ eventSource.close();
457
+ reconnectTimeout = setTimeout(() => {
458
+ eventSource = new EventSource(url);
459
+ // Re-attach all listeners on reconnect
460
+ eventSource.onmessage = handleMessage;
461
+ eventSource.onerror = handleError;
462
+ for (const type of eventTypes) {
463
+ eventSource.addEventListener(type, handleMessage);
464
+ }
465
+ }, 1000);
466
+ }
467
+ };
468
+ // Listen for unnamed messages
469
+ eventSource.onmessage = handleMessage;
470
+ eventSource.onerror = handleError;
471
+ // Listen for named event types (SSE sends "event: type-name")
472
+ for (const type of eventTypes) {
473
+ eventSource.addEventListener(type, handleMessage);
474
+ }
475
+ // Return unsubscribe function
476
+ return () => {
477
+ if (reconnectTimeout) {
478
+ clearTimeout(reconnectTimeout);
479
+ }
480
+ if (eventSource) {
481
+ eventSource.close();
482
+ eventSource = null;
483
+ }
484
+ };
485
+ },
486
+ };
487
+ }
488
+ /**
489
+ * Create an API client instance.
490
+ * Call with locals and fetch in SSR, without in browser.
491
+ *
492
+ * @example
493
+ * // +page.server.ts (SSR)
494
+ * const api = createApiClient({ locals, fetch });
495
+ *
496
+ * // +page.svelte (browser)
497
+ * const api = createApiClient();
498
+ */
499
+ export function createApiClient(ClientClass, options) {
500
+ return new ClientClass(options);
501
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * SvelteKit-specific client generator
3
+ *
4
+ * This generator extends the core @donkeylabs/server generator
5
+ * to produce clients that work with both SSR (direct calls) and browser (HTTP).
6
+ */
7
+ import { type RouteInfo, type ExtractedRoute, type ClientGeneratorOptions } from "@donkeylabs/server/generator";
8
+ /** SvelteKit-specific generator options */
9
+ export declare const svelteKitGeneratorOptions: ClientGeneratorOptions;
10
+ /**
11
+ * Generate a SvelteKit-compatible API client
12
+ *
13
+ * This is called by the donkeylabs CLI when adapter is set to "@donkeylabs/adapter-sveltekit"
14
+ */
15
+ export declare function generateClient(_config: Record<string, unknown>, routes: RouteInfo[] | ExtractedRoute[], outputPath: string): Promise<void>;
16
+ export { generateClientCode, zodToTypeScript, toPascalCase, toCamelCase, type RouteInfo, type ExtractedRoute, type ClientGeneratorOptions, } from "@donkeylabs/server/generator";
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generator/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAKL,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,sBAAsB,EAC5B,MAAM,8BAA8B,CAAC;AAetC,2CAA2C;AAC3C,eAAO,MAAM,yBAAyB,EAAE,sBAoCvC,CAAC;AAuRF;;;;GAIG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,MAAM,EAAE,SAAS,EAAE,GAAG,cAAc,EAAE,EACtC,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAgCf;AAGD,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,YAAY,EACZ,WAAW,EACX,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,sBAAsB,GAC5B,MAAM,8BAA8B,CAAC"}