@donkeylabs/adapter-sveltekit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/package.json +58 -0
- package/src/client/index.ts +185 -0
- package/src/generator/index.ts +80 -0
- package/src/hooks/index.ts +124 -0
- package/src/index.ts +395 -0
- package/src/vite.ts +359 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 DonkeyLabs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# @donkeylabs/adapter-sveltekit
|
|
2
|
+
|
|
3
|
+
SvelteKit adapter for `@donkeylabs/server`. Enables seamless integration between SvelteKit and your backend API with:
|
|
4
|
+
|
|
5
|
+
- **Single Bun process** serves both SvelteKit pages and API routes
|
|
6
|
+
- **Direct service calls during SSR** (no HTTP overhead)
|
|
7
|
+
- **Unified API client** works identically in SSR and browser
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @donkeylabs/adapter-sveltekit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### 1. Configure the Adapter
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// svelte.config.js
|
|
21
|
+
import adapter from "@donkeylabs/adapter-sveltekit";
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
kit: {
|
|
25
|
+
adapter: adapter({
|
|
26
|
+
serverEntry: "./src/server/index.ts",
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2. Add Vite Plugin for Development
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// vite.config.ts
|
|
36
|
+
import { sveltekit } from "@sveltejs/kit/vite";
|
|
37
|
+
import { donkeylabsDev } from "@donkeylabs/adapter-sveltekit/vite";
|
|
38
|
+
import { defineConfig } from "vite";
|
|
39
|
+
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
plugins: [
|
|
42
|
+
donkeylabsDev({ serverEntry: "./src/server/index.ts" }),
|
|
43
|
+
sveltekit(),
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 3. Set Up Server Hooks
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// src/hooks.server.ts
|
|
52
|
+
import { createHandle } from "@donkeylabs/adapter-sveltekit/hooks";
|
|
53
|
+
|
|
54
|
+
export const handle = createHandle();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 4. Generate API Client
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// donkeylabs.config.ts
|
|
61
|
+
import { defineConfig } from "@donkeylabs/server";
|
|
62
|
+
import { SvelteKitClientGenerator } from "@donkeylabs/adapter-sveltekit/generator";
|
|
63
|
+
|
|
64
|
+
export default defineConfig({
|
|
65
|
+
plugins: ["./src/server/plugins/**/index.ts"],
|
|
66
|
+
client: {
|
|
67
|
+
output: "./src/lib/api.ts",
|
|
68
|
+
generator: SvelteKitClientGenerator,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 5. Use the API Client
|
|
74
|
+
|
|
75
|
+
```svelte
|
|
76
|
+
<!-- src/routes/+page.svelte -->
|
|
77
|
+
<script lang="ts">
|
|
78
|
+
import { ApiClient } from "$lib/api";
|
|
79
|
+
|
|
80
|
+
const api = new ApiClient();
|
|
81
|
+
|
|
82
|
+
async function greet() {
|
|
83
|
+
const result = await api.greet({ name: "World" });
|
|
84
|
+
console.log(result.message);
|
|
85
|
+
}
|
|
86
|
+
</script>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
In SSR (`+page.server.ts`), pass `locals` for direct service calls:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// src/routes/+page.server.ts
|
|
93
|
+
import { ApiClient } from "$lib/api";
|
|
94
|
+
|
|
95
|
+
export async function load({ locals, fetch }) {
|
|
96
|
+
const api = new ApiClient({ locals, fetch });
|
|
97
|
+
const data = await api.getData({}); // Direct call, no HTTP!
|
|
98
|
+
return { data };
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Development Modes
|
|
103
|
+
|
|
104
|
+
### Recommended: In-Process Mode
|
|
105
|
+
|
|
106
|
+
Run with `bun --bun` for single-process development:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
bun --bun run dev
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- Single port (5173)
|
|
113
|
+
- Direct service calls during SSR
|
|
114
|
+
- Hot reload for both frontend and backend
|
|
115
|
+
|
|
116
|
+
### Fallback: Subprocess Mode
|
|
117
|
+
|
|
118
|
+
Run without `--bun` flag:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
bun run dev
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- Two processes (Vite on 5173, backend on 3001)
|
|
125
|
+
- API requests proxied to backend
|
|
126
|
+
- Use when in-process mode has compatibility issues
|
|
127
|
+
|
|
128
|
+
## Production Build
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
bun run build
|
|
132
|
+
bun build/server/entry.js
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Package Exports
|
|
136
|
+
|
|
137
|
+
| Export | Description |
|
|
138
|
+
|--------|-------------|
|
|
139
|
+
| `@donkeylabs/adapter-sveltekit` | Main adapter function |
|
|
140
|
+
| `@donkeylabs/adapter-sveltekit/client` | Unified API client base |
|
|
141
|
+
| `@donkeylabs/adapter-sveltekit/hooks` | SvelteKit hooks helpers |
|
|
142
|
+
| `@donkeylabs/adapter-sveltekit/generator` | Client code generator |
|
|
143
|
+
| `@donkeylabs/adapter-sveltekit/vite` | Vite dev plugin |
|
|
144
|
+
|
|
145
|
+
## Type Definitions
|
|
146
|
+
|
|
147
|
+
Add to your `app.d.ts`:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// src/app.d.ts
|
|
151
|
+
import type { DonkeylabsLocals } from "@donkeylabs/adapter-sveltekit/hooks";
|
|
152
|
+
|
|
153
|
+
declare global {
|
|
154
|
+
namespace App {
|
|
155
|
+
interface Locals extends DonkeylabsLocals {}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export {};
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## SSE (Server-Sent Events)
|
|
163
|
+
|
|
164
|
+
Subscribe to real-time events in the browser:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const api = new ApiClient();
|
|
168
|
+
|
|
169
|
+
const unsubscribe = api.sse.subscribe(
|
|
170
|
+
["notifications", "updates"],
|
|
171
|
+
(event, data) => {
|
|
172
|
+
console.log("Event:", event, data);
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Later: unsubscribe();
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@donkeylabs/adapter-sveltekit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "SvelteKit adapter for @donkeylabs/server - seamless SSR/browser API integration",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./src/client/index.ts",
|
|
15
|
+
"import": "./src/client/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./hooks": {
|
|
18
|
+
"types": "./src/hooks/index.ts",
|
|
19
|
+
"import": "./src/hooks/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"./generator": {
|
|
22
|
+
"types": "./src/generator/index.ts",
|
|
23
|
+
"import": "./src/generator/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./vite": {
|
|
26
|
+
"types": "./src/vite.ts",
|
|
27
|
+
"import": "./src/vite.ts"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "bun --bun tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@sveltejs/kit": "^2.0.0",
|
|
40
|
+
"@donkeylabs/server": "*"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"sveltekit",
|
|
44
|
+
"svelte",
|
|
45
|
+
"adapter",
|
|
46
|
+
"api",
|
|
47
|
+
"rpc",
|
|
48
|
+
"ssr",
|
|
49
|
+
"bun",
|
|
50
|
+
"typescript"
|
|
51
|
+
],
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/donkeylabs/server",
|
|
55
|
+
"directory": "packages/adapter-sveltekit"
|
|
56
|
+
},
|
|
57
|
+
"license": "MIT"
|
|
58
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
export interface RequestOptions {
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ClientOptions {
|
|
15
|
+
/** Base URL for HTTP calls. Defaults to empty string (relative URLs). */
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
/** SvelteKit locals object for SSR direct calls. */
|
|
18
|
+
locals?: any;
|
|
19
|
+
/** Custom fetch function. In SSR, pass event.fetch to handle relative URLs. */
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SSESubscription {
|
|
24
|
+
unsubscribe: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Base class for unified API clients.
|
|
29
|
+
* Extend this class with your generated route methods.
|
|
30
|
+
*/
|
|
31
|
+
export class UnifiedApiClientBase {
|
|
32
|
+
protected baseUrl: string;
|
|
33
|
+
protected locals?: any;
|
|
34
|
+
protected isSSR: boolean;
|
|
35
|
+
protected customFetch?: typeof fetch;
|
|
36
|
+
|
|
37
|
+
constructor(options?: ClientOptions) {
|
|
38
|
+
this.baseUrl = options?.baseUrl ?? "";
|
|
39
|
+
this.locals = options?.locals;
|
|
40
|
+
this.isSSR = typeof window === "undefined";
|
|
41
|
+
this.customFetch = options?.fetch;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Make a request to an API route.
|
|
46
|
+
* Automatically uses direct calls in SSR (when locals.handleRoute is available), HTTP otherwise.
|
|
47
|
+
*/
|
|
48
|
+
protected async request<TInput, TOutput>(
|
|
49
|
+
route: string,
|
|
50
|
+
input: TInput,
|
|
51
|
+
options?: RequestOptions
|
|
52
|
+
): Promise<TOutput> {
|
|
53
|
+
// Use direct route handler if available (SSR with locals)
|
|
54
|
+
if (this.locals?.handleRoute) {
|
|
55
|
+
return this.locals.handleRoute(route, input);
|
|
56
|
+
}
|
|
57
|
+
// Fall back to HTTP (browser or SSR without locals)
|
|
58
|
+
return this.httpCall<TInput, TOutput>(route, input, options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* HTTP call to API endpoint (browser or SSR with event.fetch).
|
|
63
|
+
*/
|
|
64
|
+
private async httpCall<TInput, TOutput>(
|
|
65
|
+
route: string,
|
|
66
|
+
input: TInput,
|
|
67
|
+
options?: RequestOptions
|
|
68
|
+
): Promise<TOutput> {
|
|
69
|
+
const url = `${this.baseUrl}/${route}`;
|
|
70
|
+
const fetchFn = this.customFetch ?? fetch;
|
|
71
|
+
|
|
72
|
+
const response = await fetchFn(url, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
...options?.headers,
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(input),
|
|
79
|
+
signal: options?.signal,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const error = await response.json().catch(() => ({ error: "Unknown error" }));
|
|
84
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return response.json();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* SSE (Server-Sent Events) subscription.
|
|
92
|
+
* Only works in the browser.
|
|
93
|
+
*/
|
|
94
|
+
sse = {
|
|
95
|
+
/**
|
|
96
|
+
* Subscribe to SSE channels.
|
|
97
|
+
* Returns a function to unsubscribe.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* const unsub = api.sse.subscribe(["notifications"], (event, data) => {
|
|
101
|
+
* console.log(event, data);
|
|
102
|
+
* });
|
|
103
|
+
* // Later: unsub();
|
|
104
|
+
*/
|
|
105
|
+
subscribe: (
|
|
106
|
+
channels: string[],
|
|
107
|
+
callback: (event: string, data: any) => void,
|
|
108
|
+
options?: { reconnect?: boolean }
|
|
109
|
+
): (() => void) => {
|
|
110
|
+
if (typeof window === "undefined") {
|
|
111
|
+
// SSR - return no-op
|
|
112
|
+
return () => {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const url = `${this.baseUrl}/sse?channels=${channels.join(",")}`;
|
|
116
|
+
let eventSource: EventSource | null = new EventSource(url);
|
|
117
|
+
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
|
|
119
|
+
// Known event types from the server
|
|
120
|
+
const eventTypes = ['cron-event', 'job-completed', 'internal-event', 'manual', 'message'];
|
|
121
|
+
|
|
122
|
+
const handleMessage = (e: MessageEvent) => {
|
|
123
|
+
try {
|
|
124
|
+
const data = JSON.parse(e.data);
|
|
125
|
+
callback(e.type || "message", data);
|
|
126
|
+
} catch {
|
|
127
|
+
callback(e.type || "message", e.data);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const handleError = () => {
|
|
132
|
+
if (options?.reconnect !== false && eventSource) {
|
|
133
|
+
eventSource.close();
|
|
134
|
+
reconnectTimeout = setTimeout(() => {
|
|
135
|
+
eventSource = new EventSource(url);
|
|
136
|
+
// Re-attach all listeners on reconnect
|
|
137
|
+
eventSource.onmessage = handleMessage;
|
|
138
|
+
eventSource.onerror = handleError;
|
|
139
|
+
for (const type of eventTypes) {
|
|
140
|
+
eventSource.addEventListener(type, handleMessage);
|
|
141
|
+
}
|
|
142
|
+
}, 1000);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Listen for unnamed messages
|
|
147
|
+
eventSource.onmessage = handleMessage;
|
|
148
|
+
eventSource.onerror = handleError;
|
|
149
|
+
|
|
150
|
+
// Listen for named event types (SSE sends "event: type-name")
|
|
151
|
+
for (const type of eventTypes) {
|
|
152
|
+
eventSource.addEventListener(type, handleMessage);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Return unsubscribe function
|
|
156
|
+
return () => {
|
|
157
|
+
if (reconnectTimeout) {
|
|
158
|
+
clearTimeout(reconnectTimeout);
|
|
159
|
+
}
|
|
160
|
+
if (eventSource) {
|
|
161
|
+
eventSource.close();
|
|
162
|
+
eventSource = null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create an API client instance.
|
|
171
|
+
* Call with locals and fetch in SSR, without in browser.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* // +page.server.ts (SSR)
|
|
175
|
+
* const api = createApiClient({ locals, fetch });
|
|
176
|
+
*
|
|
177
|
+
* // +page.svelte (browser)
|
|
178
|
+
* const api = createApiClient();
|
|
179
|
+
*/
|
|
180
|
+
export function createApiClient<T extends UnifiedApiClientBase>(
|
|
181
|
+
ClientClass: new (options?: ClientOptions) => T,
|
|
182
|
+
options?: ClientOptions
|
|
183
|
+
): T {
|
|
184
|
+
return new ClientClass(options);
|
|
185
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
|
|
8
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
generateClientFromRoutes,
|
|
12
|
+
type ExtractedRoute,
|
|
13
|
+
type ClientGeneratorOptions,
|
|
14
|
+
} from "@donkeylabs/server/generator";
|
|
15
|
+
|
|
16
|
+
/** SvelteKit-specific generator options */
|
|
17
|
+
export const svelteKitGeneratorOptions: ClientGeneratorOptions = {
|
|
18
|
+
baseImport:
|
|
19
|
+
'import { UnifiedApiClientBase, type ApiClientOptions } from "@donkeylabs/adapter-sveltekit/client";',
|
|
20
|
+
baseClass: "UnifiedApiClientBase",
|
|
21
|
+
constructorSignature: "options?: ApiClientOptions",
|
|
22
|
+
constructorBody: "super(options);",
|
|
23
|
+
factoryFunction: `/**
|
|
24
|
+
* Create an API client instance
|
|
25
|
+
*
|
|
26
|
+
* @param options.locals - Pass SvelteKit locals for SSR direct calls (no HTTP overhead)
|
|
27
|
+
* @param options.baseUrl - Override the base URL for HTTP calls
|
|
28
|
+
*
|
|
29
|
+
* @example SSR usage in +page.server.ts:
|
|
30
|
+
* \`\`\`ts
|
|
31
|
+
* export const load = async ({ locals }) => {
|
|
32
|
+
* const api = createApi({ locals });
|
|
33
|
+
* const data = await api.myRoute.get({}); // Direct call, no HTTP!
|
|
34
|
+
* return { data };
|
|
35
|
+
* };
|
|
36
|
+
* \`\`\`
|
|
37
|
+
*
|
|
38
|
+
* @example Browser usage in +page.svelte:
|
|
39
|
+
* \`\`\`svelte
|
|
40
|
+
* <script>
|
|
41
|
+
* import { createApi } from '$lib/api';
|
|
42
|
+
* const api = createApi(); // HTTP calls
|
|
43
|
+
* let data = $state(null);
|
|
44
|
+
* async function load() {
|
|
45
|
+
* data = await api.myRoute.get({});
|
|
46
|
+
* }
|
|
47
|
+
* </script>
|
|
48
|
+
* \`\`\`
|
|
49
|
+
*/
|
|
50
|
+
export function createApi(options?: ApiClientOptions) {
|
|
51
|
+
return new ApiClient(options);
|
|
52
|
+
}`,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate a SvelteKit-compatible API client
|
|
57
|
+
*
|
|
58
|
+
* This is called by the donkeylabs CLI when adapter is set to "@donkeylabs/adapter-sveltekit"
|
|
59
|
+
*/
|
|
60
|
+
export async function generateClient(
|
|
61
|
+
_config: Record<string, unknown>,
|
|
62
|
+
routes: ExtractedRoute[],
|
|
63
|
+
outputPath: string
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const code = generateClientFromRoutes(routes, svelteKitGeneratorOptions);
|
|
66
|
+
|
|
67
|
+
// Ensure output directory exists
|
|
68
|
+
const outputDir = dirname(outputPath);
|
|
69
|
+
await mkdir(outputDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
// Write the generated client
|
|
72
|
+
await writeFile(outputPath, code);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Re-export building blocks for advanced usage
|
|
76
|
+
export {
|
|
77
|
+
generateClientFromRoutes,
|
|
78
|
+
type ExtractedRoute,
|
|
79
|
+
type ClientGeneratorOptions,
|
|
80
|
+
} from "@donkeylabs/server/generator";
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvelteKit hooks helper for @donkeylabs/adapter-sveltekit
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Handle } from "@sveltejs/kit";
|
|
6
|
+
|
|
7
|
+
// Try to import dev server reference (only available in dev mode)
|
|
8
|
+
let getDevServer: (() => any) | undefined;
|
|
9
|
+
try {
|
|
10
|
+
// Dynamic import to avoid bundling vite.ts in production
|
|
11
|
+
const viteModule = await import("../vite.js");
|
|
12
|
+
getDevServer = viteModule.getDevServer;
|
|
13
|
+
} catch {
|
|
14
|
+
// Not in dev mode or vite not available
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DonkeylabsPlatform {
|
|
18
|
+
donkeylabs?: {
|
|
19
|
+
services: Record<string, any>;
|
|
20
|
+
core: {
|
|
21
|
+
logger: any;
|
|
22
|
+
cache: any;
|
|
23
|
+
events: any;
|
|
24
|
+
cron: any;
|
|
25
|
+
jobs: any;
|
|
26
|
+
sse: any;
|
|
27
|
+
rateLimiter: any;
|
|
28
|
+
db: any;
|
|
29
|
+
};
|
|
30
|
+
/** Direct route handler for SSR (no HTTP!) */
|
|
31
|
+
handleRoute: (routeName: string, input: any) => Promise<any>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DonkeylabsLocals {
|
|
36
|
+
plugins: Record<string, any>;
|
|
37
|
+
core: {
|
|
38
|
+
logger: any;
|
|
39
|
+
cache: any;
|
|
40
|
+
events: any;
|
|
41
|
+
sse: any;
|
|
42
|
+
};
|
|
43
|
+
db: any;
|
|
44
|
+
ip: string;
|
|
45
|
+
/** Direct route handler for SSR API calls */
|
|
46
|
+
handleRoute?: (routeName: string, input: any) => Promise<any>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a SvelteKit handle function that populates event.locals
|
|
51
|
+
* with @donkeylabs/server context.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // src/hooks.server.ts
|
|
55
|
+
* import { createHandle } from "@donkeylabs/adapter-sveltekit/hooks";
|
|
56
|
+
* export const handle = createHandle();
|
|
57
|
+
*/
|
|
58
|
+
export function createHandle(): Handle {
|
|
59
|
+
return async ({ event, resolve }) => {
|
|
60
|
+
const platform = event.platform as DonkeylabsPlatform | undefined;
|
|
61
|
+
|
|
62
|
+
if (platform?.donkeylabs) {
|
|
63
|
+
// Production mode: use platform.donkeylabs from adapter
|
|
64
|
+
const { services, core, handleRoute } = platform.donkeylabs;
|
|
65
|
+
|
|
66
|
+
// Populate locals with server context
|
|
67
|
+
(event.locals as DonkeylabsLocals).plugins = services;
|
|
68
|
+
(event.locals as DonkeylabsLocals).core = {
|
|
69
|
+
logger: core.logger,
|
|
70
|
+
cache: core.cache,
|
|
71
|
+
events: core.events,
|
|
72
|
+
sse: core.sse,
|
|
73
|
+
};
|
|
74
|
+
(event.locals as DonkeylabsLocals).db = core.db;
|
|
75
|
+
(event.locals as DonkeylabsLocals).ip = event.getClientAddress();
|
|
76
|
+
// Expose the direct route handler for SSR API calls
|
|
77
|
+
(event.locals as DonkeylabsLocals).handleRoute = handleRoute;
|
|
78
|
+
} else if (getDevServer) {
|
|
79
|
+
// Dev mode: use global dev server from vite plugin
|
|
80
|
+
const devServer = getDevServer();
|
|
81
|
+
if (devServer) {
|
|
82
|
+
const core = devServer.getCore();
|
|
83
|
+
const plugins = devServer.getServices();
|
|
84
|
+
|
|
85
|
+
(event.locals as DonkeylabsLocals).plugins = plugins;
|
|
86
|
+
(event.locals as DonkeylabsLocals).core = {
|
|
87
|
+
logger: core.logger,
|
|
88
|
+
cache: core.cache,
|
|
89
|
+
events: core.events,
|
|
90
|
+
sse: core.sse,
|
|
91
|
+
};
|
|
92
|
+
(event.locals as DonkeylabsLocals).db = core.db;
|
|
93
|
+
(event.locals as DonkeylabsLocals).ip = event.getClientAddress();
|
|
94
|
+
// Direct route handler for SSR
|
|
95
|
+
(event.locals as DonkeylabsLocals).handleRoute = async (routeName: string, input: any) => {
|
|
96
|
+
return devServer.callRoute(routeName, input, event.getClientAddress());
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return resolve(event);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sequence multiple handle functions together.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* import { sequence, createHandle } from "@donkeylabs/adapter-sveltekit/hooks";
|
|
110
|
+
* export const handle = sequence(createHandle(), myOtherHandle);
|
|
111
|
+
*/
|
|
112
|
+
export function sequence(...handlers: Handle[]): Handle {
|
|
113
|
+
return async ({ event, resolve }) => {
|
|
114
|
+
let resolveChain = resolve;
|
|
115
|
+
|
|
116
|
+
for (let i = handlers.length - 1; i >= 0; i--) {
|
|
117
|
+
const handler = handlers[i];
|
|
118
|
+
const next = resolveChain;
|
|
119
|
+
resolveChain = (event) => handler({ event, resolve: next });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return resolveChain(event);
|
|
123
|
+
};
|
|
124
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @donkeylabs/adapter-sveltekit
|
|
3
|
+
*
|
|
4
|
+
* SvelteKit adapter that integrates with @donkeylabs/server.
|
|
5
|
+
* - Single Bun process serves both SvelteKit pages and API routes
|
|
6
|
+
* - Direct service calls during SSR (no HTTP overhead)
|
|
7
|
+
* - Unified API client works in both SSR and browser
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Adapter, Builder } from "@sveltejs/kit";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
13
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
export interface AdapterOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Output directory for the built app.
|
|
21
|
+
* @default "build"
|
|
22
|
+
*/
|
|
23
|
+
out?: string;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Path to your @donkeylabs/server entry file.
|
|
27
|
+
* This file should export a configured AppServer instance.
|
|
28
|
+
*
|
|
29
|
+
* @example "./src/server/index.ts"
|
|
30
|
+
*/
|
|
31
|
+
serverEntry: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether to precompress static assets with gzip/brotli.
|
|
35
|
+
* @default true
|
|
36
|
+
*/
|
|
37
|
+
precompress?: boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Environment variable prefix for PORT and HOST.
|
|
41
|
+
* @default ""
|
|
42
|
+
*/
|
|
43
|
+
envPrefix?: string;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Enable development mode features.
|
|
47
|
+
* @default false
|
|
48
|
+
*/
|
|
49
|
+
development?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function adapter(options: AdapterOptions): Adapter {
|
|
53
|
+
const {
|
|
54
|
+
out = "build",
|
|
55
|
+
serverEntry,
|
|
56
|
+
precompress = true,
|
|
57
|
+
envPrefix = "",
|
|
58
|
+
development = false,
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
if (!serverEntry) {
|
|
62
|
+
throw new Error("@donkeylabs/adapter-sveltekit: serverEntry option is required");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
name: "@donkeylabs/adapter-sveltekit",
|
|
67
|
+
|
|
68
|
+
async adapt(builder: Builder) {
|
|
69
|
+
const serverDir = join(out, "server");
|
|
70
|
+
const clientDir = join(out, "client");
|
|
71
|
+
const prerenderedDir = join(out, "prerendered");
|
|
72
|
+
|
|
73
|
+
// 1. Clean and create output directories
|
|
74
|
+
builder.rimraf(out);
|
|
75
|
+
mkdirSync(serverDir, { recursive: true });
|
|
76
|
+
|
|
77
|
+
builder.log.minor("Writing SvelteKit server files...");
|
|
78
|
+
|
|
79
|
+
// 2. Write SvelteKit server files
|
|
80
|
+
builder.writeServer(serverDir);
|
|
81
|
+
|
|
82
|
+
// 3. Copy static assets and prerendered pages
|
|
83
|
+
const clientFiles = builder.writeClient(clientDir);
|
|
84
|
+
const prerenderedFiles = builder.writePrerendered(prerenderedDir);
|
|
85
|
+
|
|
86
|
+
// 4. Generate the manifest
|
|
87
|
+
const relativePath = relative(serverDir, ".");
|
|
88
|
+
builder.generateManifest({ relativePath });
|
|
89
|
+
|
|
90
|
+
// 5. Generate the unified runtime entry point
|
|
91
|
+
const serverEntryResolved = resolve(serverEntry);
|
|
92
|
+
const serverEntryRelative = relative(serverDir, serverEntryResolved);
|
|
93
|
+
|
|
94
|
+
const entryCode = generateEntryPoint({
|
|
95
|
+
serverEntryRelative,
|
|
96
|
+
envPrefix,
|
|
97
|
+
development,
|
|
98
|
+
clientDir: relative(serverDir, clientDir),
|
|
99
|
+
prerenderedDir: relative(serverDir, prerenderedDir),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
writeFileSync(join(serverDir, "entry.js"), entryCode);
|
|
103
|
+
|
|
104
|
+
// 6. Write runtime handler (inline the full implementation)
|
|
105
|
+
writeFileSync(join(serverDir, "handler.js"), generateRuntimeHandler());
|
|
106
|
+
|
|
107
|
+
// 7. Precompress if enabled
|
|
108
|
+
if (precompress) {
|
|
109
|
+
builder.log.minor("Compressing assets...");
|
|
110
|
+
await builder.compress(clientDir);
|
|
111
|
+
if (prerenderedFiles.length > 0) {
|
|
112
|
+
await builder.compress(prerenderedDir);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
builder.log.success(`Adapter output written to ${out}`);
|
|
117
|
+
builder.log.minor(`Run with: bun ${out}/server/entry.js`);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function generateEntryPoint(config: {
|
|
123
|
+
serverEntryRelative: string;
|
|
124
|
+
envPrefix: string;
|
|
125
|
+
development: boolean;
|
|
126
|
+
clientDir: string;
|
|
127
|
+
prerenderedDir: string;
|
|
128
|
+
}): string {
|
|
129
|
+
const { serverEntryRelative, envPrefix, development, clientDir, prerenderedDir } = config;
|
|
130
|
+
|
|
131
|
+
return `// Generated by @donkeylabs/adapter-sveltekit
|
|
132
|
+
import { Server } from "./index.js";
|
|
133
|
+
import { manifest } from "./manifest.js";
|
|
134
|
+
import { createUnifiedServer } from "./handler.js";
|
|
135
|
+
|
|
136
|
+
// Import user's @donkeylabs/server setup
|
|
137
|
+
const serverModule = await import("${serverEntryRelative}");
|
|
138
|
+
const donkeylabsServer = serverModule.server || serverModule.default;
|
|
139
|
+
|
|
140
|
+
if (!donkeylabsServer) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"@donkeylabs/adapter-sveltekit: Could not find server export. " +
|
|
143
|
+
"Make sure your server entry file exports 'server' or uses default export."
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Initialize @donkeylabs/server (migrations, plugins, routes)
|
|
148
|
+
await donkeylabsServer.initialize();
|
|
149
|
+
|
|
150
|
+
// Create SvelteKit server
|
|
151
|
+
const svelteServer = new Server(manifest);
|
|
152
|
+
await svelteServer.init({ env: process.env });
|
|
153
|
+
|
|
154
|
+
// Configuration
|
|
155
|
+
const port = Number(process.env.${envPrefix}PORT) || 3000;
|
|
156
|
+
const host = process.env.${envPrefix}HOST || "0.0.0.0";
|
|
157
|
+
const development = ${development} || process.env.NODE_ENV === "development";
|
|
158
|
+
|
|
159
|
+
// Start unified server
|
|
160
|
+
createUnifiedServer({
|
|
161
|
+
svelteServer,
|
|
162
|
+
donkeylabsServer,
|
|
163
|
+
port,
|
|
164
|
+
host,
|
|
165
|
+
clientDir: "${clientDir}",
|
|
166
|
+
prerenderedDir: "${prerenderedDir}",
|
|
167
|
+
development,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
console.log(\`Server running at http://\${host}:\${port}\`);
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function generateRuntimeHandler(): string {
|
|
175
|
+
// Inline the full runtime handler implementation
|
|
176
|
+
return `// Generated runtime handler by @donkeylabs/adapter-sveltekit
|
|
177
|
+
import { resolve, dirname } from "node:path";
|
|
178
|
+
import { fileURLToPath } from "node:url";
|
|
179
|
+
|
|
180
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Create a unified Bun server that handles both SvelteKit and @donkeylabs/server requests.
|
|
184
|
+
*/
|
|
185
|
+
export function createUnifiedServer(config) {
|
|
186
|
+
const {
|
|
187
|
+
svelteServer,
|
|
188
|
+
donkeylabsServer,
|
|
189
|
+
port,
|
|
190
|
+
host,
|
|
191
|
+
clientDir: clientDirRelative,
|
|
192
|
+
prerenderedDir: prerenderedDirRelative,
|
|
193
|
+
development = false,
|
|
194
|
+
} = config;
|
|
195
|
+
|
|
196
|
+
// Resolve paths relative to this script's directory
|
|
197
|
+
const clientDir = resolve(__dirname, clientDirRelative);
|
|
198
|
+
const prerenderedDir = resolve(__dirname, prerenderedDirRelative);
|
|
199
|
+
|
|
200
|
+
// Get services and core from @donkeylabs/server for injection into SvelteKit
|
|
201
|
+
const services = donkeylabsServer.getServices();
|
|
202
|
+
const core = donkeylabsServer.getCore();
|
|
203
|
+
|
|
204
|
+
Bun.serve({
|
|
205
|
+
port,
|
|
206
|
+
hostname: host,
|
|
207
|
+
|
|
208
|
+
async fetch(req, server) {
|
|
209
|
+
const url = new URL(req.url);
|
|
210
|
+
const pathname = url.pathname;
|
|
211
|
+
const ip = extractClientIP(req, server.requestIP(req)?.address);
|
|
212
|
+
|
|
213
|
+
// 1. Handle CORS preflight for API routes
|
|
214
|
+
if (req.method === "OPTIONS") {
|
|
215
|
+
return new Response(null, {
|
|
216
|
+
status: 204,
|
|
217
|
+
headers: getCorsHeaders(),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 2. API routes (POST /route.name)
|
|
222
|
+
if (req.method === "POST") {
|
|
223
|
+
const routeName = pathname.slice(1); // Remove leading /
|
|
224
|
+
|
|
225
|
+
// Check if this is a registered API route
|
|
226
|
+
if (donkeylabsServer.hasRoute(routeName)) {
|
|
227
|
+
const response = await donkeylabsServer.handleRequest(req, routeName, ip, {
|
|
228
|
+
corsHeaders: getCorsHeaders(),
|
|
229
|
+
});
|
|
230
|
+
if (response) return response;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 3. SSE endpoint
|
|
235
|
+
if (pathname === "/sse" && req.method === "GET") {
|
|
236
|
+
return handleSSE(req, core, ip);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 4. Static assets (/_app/*, etc.)
|
|
240
|
+
if (isStaticAsset(pathname)) {
|
|
241
|
+
const staticResponse = await serveStatic(clientDir, pathname);
|
|
242
|
+
if (staticResponse) return staticResponse;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 5. Prerendered pages
|
|
246
|
+
const prerenderedResponse = await servePrerendered(prerenderedDir, pathname);
|
|
247
|
+
if (prerenderedResponse) return prerenderedResponse;
|
|
248
|
+
|
|
249
|
+
// 6. SvelteKit pages
|
|
250
|
+
return handleSvelteKit(req, svelteServer, {
|
|
251
|
+
services,
|
|
252
|
+
core,
|
|
253
|
+
ip,
|
|
254
|
+
donkeylabsServer,
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isStaticAsset(pathname) {
|
|
261
|
+
return (
|
|
262
|
+
pathname.startsWith("/_app/") ||
|
|
263
|
+
pathname.startsWith("/static/") ||
|
|
264
|
+
pathname === "/favicon.ico" ||
|
|
265
|
+
pathname === "/robots.txt" ||
|
|
266
|
+
pathname === "/sitemap.xml" ||
|
|
267
|
+
/\\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|webp|avif|json|webmanifest)$/.test(pathname)
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function serveStatic(clientDir, pathname) {
|
|
272
|
+
const filePath = clientDir + pathname;
|
|
273
|
+
const file = Bun.file(filePath);
|
|
274
|
+
|
|
275
|
+
if (await file.exists()) {
|
|
276
|
+
// Check for precompressed versions
|
|
277
|
+
const brFile = Bun.file(filePath + ".br");
|
|
278
|
+
const gzFile = Bun.file(filePath + ".gz");
|
|
279
|
+
|
|
280
|
+
if (await brFile.exists()) {
|
|
281
|
+
return new Response(brFile, {
|
|
282
|
+
headers: {
|
|
283
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
284
|
+
"Content-Encoding": "br",
|
|
285
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (await gzFile.exists()) {
|
|
291
|
+
return new Response(gzFile, {
|
|
292
|
+
headers: {
|
|
293
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
294
|
+
"Content-Encoding": "gzip",
|
|
295
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return new Response(file, {
|
|
301
|
+
headers: {
|
|
302
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
303
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function servePrerendered(prerenderedDir, pathname) {
|
|
312
|
+
let filePath = prerenderedDir + pathname;
|
|
313
|
+
if (!pathname.endsWith(".html") && !pathname.includes(".")) {
|
|
314
|
+
filePath = prerenderedDir + (pathname === "/" ? "/index" : pathname) + ".html";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const file = Bun.file(filePath);
|
|
318
|
+
if (await file.exists()) {
|
|
319
|
+
return new Response(file, {
|
|
320
|
+
headers: {
|
|
321
|
+
"Content-Type": "text/html",
|
|
322
|
+
"Cache-Control": "public, max-age=0, must-revalidate",
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function handleSvelteKit(req, svelteServer, context) {
|
|
331
|
+
return svelteServer.respond(req, {
|
|
332
|
+
getClientAddress: () => context.ip,
|
|
333
|
+
platform: {
|
|
334
|
+
donkeylabs: {
|
|
335
|
+
services: context.services,
|
|
336
|
+
core: context.core,
|
|
337
|
+
// Direct route handler for SSR API calls (no HTTP!)
|
|
338
|
+
handleRoute: async (routeName, input) => {
|
|
339
|
+
return context.donkeylabsServer.callRoute(routeName, input, context.ip);
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function handleSSE(req, core, ip) {
|
|
347
|
+
const url = new URL(req.url);
|
|
348
|
+
const channels = url.searchParams.get("channels")?.split(",") || [];
|
|
349
|
+
|
|
350
|
+
if (channels.length === 0) {
|
|
351
|
+
return new Response("Missing channels parameter", { status: 400 });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const lastEventId = req.headers.get("Last-Event-ID") || undefined;
|
|
355
|
+
const { client, response } = core.sse.addClient({ lastEventId });
|
|
356
|
+
|
|
357
|
+
for (const channel of channels) {
|
|
358
|
+
core.sse.subscribe(client.id, channel);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
req.signal.addEventListener("abort", () => {
|
|
362
|
+
core.sse.removeClient(client.id);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const headers = new Headers(response.headers);
|
|
366
|
+
const corsHeaders = getCorsHeaders();
|
|
367
|
+
Object.entries(corsHeaders).forEach(([k, v]) => headers.set(k, v));
|
|
368
|
+
|
|
369
|
+
return new Response(response.body, {
|
|
370
|
+
status: response.status,
|
|
371
|
+
headers,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function extractClientIP(req, socketIP) {
|
|
376
|
+
return (
|
|
377
|
+
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
378
|
+
req.headers.get("x-real-ip") ||
|
|
379
|
+
socketIP ||
|
|
380
|
+
"127.0.0.1"
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function getCorsHeaders() {
|
|
385
|
+
return {
|
|
386
|
+
"Access-Control-Allow-Origin": "*",
|
|
387
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
388
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Re-export types
|
|
395
|
+
export type { AdapterOptions as Options };
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite plugin for @donkeylabs/adapter-sveltekit dev server integration
|
|
3
|
+
*
|
|
4
|
+
* Supports two modes:
|
|
5
|
+
* - `bun --bun run dev`: Single-process mode (in-process, one port)
|
|
6
|
+
* - `bun run dev`: Subprocess mode (two processes, proxy)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Plugin, ViteDevServer } from "vite";
|
|
10
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
|
|
14
|
+
export interface DevPluginOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Path to your @donkeylabs/server entry file.
|
|
17
|
+
* This file should export a configured AppServer instance.
|
|
18
|
+
*
|
|
19
|
+
* @example "./src/server/index.ts"
|
|
20
|
+
*/
|
|
21
|
+
serverEntry: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Port for the backend server (subprocess mode only).
|
|
25
|
+
* @default 3001
|
|
26
|
+
*/
|
|
27
|
+
backendPort?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check if running with Bun runtime (bun --bun)
|
|
31
|
+
const isBunRuntime = typeof globalThis.Bun !== "undefined";
|
|
32
|
+
|
|
33
|
+
// Global reference to the app server for SSR direct calls
|
|
34
|
+
let globalAppServer: any = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the global app server instance for SSR direct calls.
|
|
38
|
+
* This allows hooks to access the server without HTTP.
|
|
39
|
+
*/
|
|
40
|
+
export function getDevServer(): any {
|
|
41
|
+
return globalAppServer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Vite plugin that integrates @donkeylabs/server with the dev server.
|
|
46
|
+
*
|
|
47
|
+
* - With `bun --bun run dev`: Runs in-process (single port, recommended)
|
|
48
|
+
* - With `bun run dev`: Spawns subprocess (two ports, fallback)
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* // vite.config.ts
|
|
52
|
+
* import { donkeylabsDev } from "@donkeylabs/adapter-sveltekit/vite";
|
|
53
|
+
*
|
|
54
|
+
* export default defineConfig({
|
|
55
|
+
* plugins: [
|
|
56
|
+
* donkeylabsDev({ serverEntry: "./src/server/index.ts" }),
|
|
57
|
+
* sveltekit()
|
|
58
|
+
* ]
|
|
59
|
+
* });
|
|
60
|
+
*/
|
|
61
|
+
export function donkeylabsDev(options: DevPluginOptions): Plugin {
|
|
62
|
+
const { serverEntry, backendPort = 3001 } = options;
|
|
63
|
+
|
|
64
|
+
if (!serverEntry) {
|
|
65
|
+
throw new Error("donkeylabsDev: serverEntry option is required");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// State for subprocess mode
|
|
69
|
+
let backendProcess: ChildProcess | null = null;
|
|
70
|
+
let backendReady = false;
|
|
71
|
+
|
|
72
|
+
// State for in-process mode
|
|
73
|
+
let appServer: any = null;
|
|
74
|
+
let serverReady = false;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
name: "donkeylabs-dev",
|
|
78
|
+
enforce: "pre",
|
|
79
|
+
|
|
80
|
+
async configureServer(server: ViteDevServer) {
|
|
81
|
+
const serverEntryResolved = resolve(process.cwd(), serverEntry);
|
|
82
|
+
|
|
83
|
+
if (isBunRuntime) {
|
|
84
|
+
// ========== IN-PROCESS MODE (bun --bun run dev) ==========
|
|
85
|
+
// Import and initialize server directly - no subprocess, no proxy
|
|
86
|
+
console.log("[donkeylabs-dev] Starting in-process mode (Bun runtime detected)");
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const serverModule = await import(serverEntryResolved);
|
|
90
|
+
appServer = serverModule.server || serverModule.default;
|
|
91
|
+
|
|
92
|
+
if (!appServer) {
|
|
93
|
+
throw new Error("No server export found in " + serverEntry);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Initialize without starting HTTP server
|
|
97
|
+
await appServer.initialize();
|
|
98
|
+
serverReady = true;
|
|
99
|
+
// Set global reference for SSR direct calls
|
|
100
|
+
globalAppServer = appServer;
|
|
101
|
+
console.log("[donkeylabs-dev] Server initialized (in-process mode)");
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error("[donkeylabs-dev] Failed to initialize server:", err);
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Return middleware setup function
|
|
108
|
+
return () => {
|
|
109
|
+
// In-process request handler
|
|
110
|
+
const inProcessMiddleware = async (req: any, res: any, next: any) => {
|
|
111
|
+
const url = req.url || "/";
|
|
112
|
+
|
|
113
|
+
// Handle SSE
|
|
114
|
+
if (req.method === "GET" && url.startsWith("/sse")) {
|
|
115
|
+
if (!serverReady || !appServer) return next();
|
|
116
|
+
|
|
117
|
+
const fullUrl = new URL(url, "http://localhost");
|
|
118
|
+
const channels = fullUrl.searchParams.get("channels")?.split(",").filter(Boolean) || [];
|
|
119
|
+
const lastEventId = req.headers["last-event-id"] || undefined;
|
|
120
|
+
|
|
121
|
+
const { client, response } = appServer.getCore().sse.addClient({ lastEventId });
|
|
122
|
+
|
|
123
|
+
for (const channel of channels) {
|
|
124
|
+
appServer.getCore().sse.subscribe(client.id, channel);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Set SSE headers
|
|
128
|
+
res.writeHead(200, {
|
|
129
|
+
"Content-Type": "text/event-stream",
|
|
130
|
+
"Cache-Control": "no-cache",
|
|
131
|
+
"Connection": "keep-alive",
|
|
132
|
+
"Access-Control-Allow-Origin": "*",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Stream SSE data
|
|
136
|
+
const reader = response.body?.getReader();
|
|
137
|
+
if (reader) {
|
|
138
|
+
const pump = async () => {
|
|
139
|
+
try {
|
|
140
|
+
while (true) {
|
|
141
|
+
const { done, value } = await reader.read();
|
|
142
|
+
if (done) break;
|
|
143
|
+
res.write(value);
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Connection closed
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
pump();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
req.on("close", () => {
|
|
153
|
+
appServer.getCore().sse.removeClient(client.id);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return; // Don't call next()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Handle API routes (POST)
|
|
160
|
+
if (req.method === "POST" && /^\/[a-zA-Z][a-zA-Z0-9_.]*$/.test(url)) {
|
|
161
|
+
if (!serverReady || !appServer) return next();
|
|
162
|
+
|
|
163
|
+
const routeName = url.slice(1);
|
|
164
|
+
if (!appServer.hasRoute(routeName)) return next();
|
|
165
|
+
|
|
166
|
+
// Collect body
|
|
167
|
+
let body = "";
|
|
168
|
+
req.on("data", (chunk: any) => (body += chunk));
|
|
169
|
+
req.on("end", async () => {
|
|
170
|
+
try {
|
|
171
|
+
const input = body ? JSON.parse(body) : {};
|
|
172
|
+
const ip = req.socket?.remoteAddress || "127.0.0.1";
|
|
173
|
+
|
|
174
|
+
const result = await appServer.callRoute(routeName, input, ip);
|
|
175
|
+
|
|
176
|
+
res.setHeader("Content-Type", "application/json");
|
|
177
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
178
|
+
res.end(JSON.stringify(result));
|
|
179
|
+
} catch (err: any) {
|
|
180
|
+
res.statusCode = err.status || 500;
|
|
181
|
+
res.setHeader("Content-Type", "application/json");
|
|
182
|
+
res.end(JSON.stringify({ error: err.message || "Internal error" }));
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return; // Don't call next()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
next();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// CORS preflight
|
|
193
|
+
const corsMiddleware = (req: any, res: any, next: any) => {
|
|
194
|
+
if (req.method === "OPTIONS") {
|
|
195
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
196
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
197
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
198
|
+
res.statusCode = 204;
|
|
199
|
+
res.end();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
next();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Add to front of middleware stack
|
|
206
|
+
const stack = (server.middlewares as any).stack;
|
|
207
|
+
if (stack && Array.isArray(stack)) {
|
|
208
|
+
stack.unshift({ route: "", handle: corsMiddleware });
|
|
209
|
+
stack.unshift({ route: "", handle: inProcessMiddleware });
|
|
210
|
+
} else {
|
|
211
|
+
server.middlewares.use(inProcessMiddleware);
|
|
212
|
+
server.middlewares.use(corsMiddleware);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
} else {
|
|
216
|
+
// ========== SUBPROCESS MODE (bun run dev) ==========
|
|
217
|
+
// Spawn backend as separate process and proxy requests
|
|
218
|
+
console.log(`[donkeylabs-dev] Starting subprocess mode (backend on port ${backendPort})`);
|
|
219
|
+
|
|
220
|
+
const bootstrapCode = `
|
|
221
|
+
const serverModule = await import("${serverEntryResolved}");
|
|
222
|
+
const server = serverModule.server || serverModule.default;
|
|
223
|
+
|
|
224
|
+
if (!server) {
|
|
225
|
+
console.error("[donkeylabs-backend] No server export found");
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
server.port = ${backendPort};
|
|
230
|
+
await server.start();
|
|
231
|
+
console.log("[donkeylabs-backend] Server ready on port ${backendPort}");
|
|
232
|
+
`;
|
|
233
|
+
|
|
234
|
+
backendProcess = spawn("bun", ["--eval", bootstrapCode], {
|
|
235
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
236
|
+
env: { ...process.env, NODE_ENV: "development" },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
backendProcess.stdout?.on("data", (data: Buffer) => {
|
|
240
|
+
const msg = data.toString().trim();
|
|
241
|
+
if (msg) {
|
|
242
|
+
console.log(msg);
|
|
243
|
+
if (msg.includes("Server ready") || msg.includes("Server running")) {
|
|
244
|
+
backendReady = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
backendProcess.stderr?.on("data", (data: Buffer) => {
|
|
250
|
+
const msg = data.toString().trim();
|
|
251
|
+
if (msg) console.error(msg);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
backendProcess.on("error", (err) => {
|
|
255
|
+
console.error("[donkeylabs-dev] Failed to start backend:", err);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
backendProcess.on("exit", (code) => {
|
|
259
|
+
if (code !== 0 && code !== null) {
|
|
260
|
+
console.error(`[donkeylabs-dev] Backend exited with code ${code}`);
|
|
261
|
+
}
|
|
262
|
+
backendProcess = null;
|
|
263
|
+
backendReady = false;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
server.httpServer?.on("close", () => {
|
|
267
|
+
if (backendProcess) {
|
|
268
|
+
backendProcess.kill();
|
|
269
|
+
backendProcess = null;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Return middleware setup function
|
|
274
|
+
return () => {
|
|
275
|
+
const waitForBackend = new Promise<void>((resolve) => {
|
|
276
|
+
const check = () => (backendReady ? resolve() : setTimeout(check, 100));
|
|
277
|
+
setTimeout(check, 500);
|
|
278
|
+
setTimeout(() => {
|
|
279
|
+
if (!backendReady) {
|
|
280
|
+
console.warn("[donkeylabs-dev] Backend startup timeout");
|
|
281
|
+
resolve();
|
|
282
|
+
}
|
|
283
|
+
}, 10000);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Proxy middleware
|
|
287
|
+
const proxyMiddleware = (req: any, res: any, next: any) => {
|
|
288
|
+
const url = req.url || "/";
|
|
289
|
+
const isApiRoute = req.method === "POST" && /^\/[a-zA-Z][a-zA-Z0-9_.]*$/.test(url);
|
|
290
|
+
|
|
291
|
+
if (!isApiRoute) return next();
|
|
292
|
+
|
|
293
|
+
waitForBackend.then(() => {
|
|
294
|
+
const proxyReq = http.request(
|
|
295
|
+
{
|
|
296
|
+
hostname: "localhost",
|
|
297
|
+
port: backendPort,
|
|
298
|
+
path: url,
|
|
299
|
+
method: req.method,
|
|
300
|
+
headers: { ...req.headers, host: `localhost:${backendPort}` },
|
|
301
|
+
},
|
|
302
|
+
(proxyRes) => {
|
|
303
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
304
|
+
res.statusCode = proxyRes.statusCode || 200;
|
|
305
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
306
|
+
if (v) res.setHeader(k, v);
|
|
307
|
+
}
|
|
308
|
+
proxyRes.pipe(res);
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
proxyReq.on("error", (err) => {
|
|
313
|
+
console.error(`[donkeylabs-dev] Proxy error:`, err.message);
|
|
314
|
+
res.statusCode = 502;
|
|
315
|
+
res.end(JSON.stringify({ error: "Backend unavailable" }));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
req.pipe(proxyReq);
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const corsMiddleware = (req: any, res: any, next: any) => {
|
|
323
|
+
if (req.method === "OPTIONS") {
|
|
324
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
325
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
326
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
327
|
+
res.statusCode = 204;
|
|
328
|
+
res.end();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
next();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const stack = (server.middlewares as any).stack;
|
|
335
|
+
if (stack && Array.isArray(stack)) {
|
|
336
|
+
stack.unshift({ route: "", handle: corsMiddleware });
|
|
337
|
+
stack.unshift({ route: "", handle: proxyMiddleware });
|
|
338
|
+
} else {
|
|
339
|
+
server.middlewares.use(proxyMiddleware);
|
|
340
|
+
server.middlewares.use(corsMiddleware);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async closeBundle() {
|
|
347
|
+
if (backendProcess) {
|
|
348
|
+
backendProcess.kill();
|
|
349
|
+
backendProcess = null;
|
|
350
|
+
}
|
|
351
|
+
if (appServer) {
|
|
352
|
+
await appServer.shutdown?.();
|
|
353
|
+
appServer = null;
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export default donkeylabsDev;
|