@buenojs/bueno 0.8.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/.env.example +109 -0
- package/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/architecture.md +652 -0
- package/bun.lock +70 -0
- package/dist/cli/index.js +3233 -0
- package/dist/index.js +9014 -0
- package/package.json +77 -0
- package/src/cache/index.ts +795 -0
- package/src/cli/ARCHITECTURE.md +837 -0
- package/src/cli/bin.ts +10 -0
- package/src/cli/commands/build.ts +425 -0
- package/src/cli/commands/dev.ts +248 -0
- package/src/cli/commands/generate.ts +541 -0
- package/src/cli/commands/help.ts +55 -0
- package/src/cli/commands/index.ts +112 -0
- package/src/cli/commands/migration.ts +355 -0
- package/src/cli/commands/new.ts +804 -0
- package/src/cli/commands/start.ts +208 -0
- package/src/cli/core/args.ts +283 -0
- package/src/cli/core/console.ts +349 -0
- package/src/cli/core/index.ts +60 -0
- package/src/cli/core/prompt.ts +424 -0
- package/src/cli/core/spinner.ts +265 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/templates/deploy.ts +295 -0
- package/src/cli/templates/docker.ts +307 -0
- package/src/cli/templates/index.ts +24 -0
- package/src/cli/utils/fs.ts +428 -0
- package/src/cli/utils/index.ts +8 -0
- package/src/cli/utils/strings.ts +197 -0
- package/src/config/env.ts +408 -0
- package/src/config/index.ts +506 -0
- package/src/config/loader.ts +329 -0
- package/src/config/merge.ts +285 -0
- package/src/config/types.ts +320 -0
- package/src/config/validation.ts +441 -0
- package/src/container/forward-ref.ts +143 -0
- package/src/container/index.ts +386 -0
- package/src/context/index.ts +360 -0
- package/src/database/index.ts +1142 -0
- package/src/database/migrations/index.ts +371 -0
- package/src/database/schema/index.ts +619 -0
- package/src/frontend/api-routes.ts +640 -0
- package/src/frontend/bundler.ts +643 -0
- package/src/frontend/console-client.ts +419 -0
- package/src/frontend/console-stream.ts +587 -0
- package/src/frontend/dev-server.ts +846 -0
- package/src/frontend/file-router.ts +611 -0
- package/src/frontend/frameworks/index.ts +106 -0
- package/src/frontend/frameworks/react.ts +85 -0
- package/src/frontend/frameworks/solid.ts +104 -0
- package/src/frontend/frameworks/svelte.ts +110 -0
- package/src/frontend/frameworks/vue.ts +92 -0
- package/src/frontend/hmr-client.ts +663 -0
- package/src/frontend/hmr.ts +728 -0
- package/src/frontend/index.ts +342 -0
- package/src/frontend/islands.ts +552 -0
- package/src/frontend/isr.ts +555 -0
- package/src/frontend/layout.ts +475 -0
- package/src/frontend/ssr/react.ts +446 -0
- package/src/frontend/ssr/solid.ts +523 -0
- package/src/frontend/ssr/svelte.ts +546 -0
- package/src/frontend/ssr/vue.ts +504 -0
- package/src/frontend/ssr.ts +699 -0
- package/src/frontend/types.ts +2274 -0
- package/src/health/index.ts +604 -0
- package/src/index.ts +410 -0
- package/src/lock/index.ts +587 -0
- package/src/logger/index.ts +444 -0
- package/src/logger/transports/index.ts +969 -0
- package/src/metrics/index.ts +494 -0
- package/src/middleware/built-in.ts +360 -0
- package/src/middleware/index.ts +94 -0
- package/src/modules/filters.ts +458 -0
- package/src/modules/guards.ts +405 -0
- package/src/modules/index.ts +1256 -0
- package/src/modules/interceptors.ts +574 -0
- package/src/modules/lazy.ts +418 -0
- package/src/modules/lifecycle.ts +478 -0
- package/src/modules/metadata.ts +90 -0
- package/src/modules/pipes.ts +626 -0
- package/src/router/index.ts +339 -0
- package/src/router/linear.ts +371 -0
- package/src/router/regex.ts +292 -0
- package/src/router/tree.ts +562 -0
- package/src/rpc/index.ts +1263 -0
- package/src/security/index.ts +436 -0
- package/src/ssg/index.ts +631 -0
- package/src/storage/index.ts +456 -0
- package/src/telemetry/index.ts +1097 -0
- package/src/testing/index.ts +1586 -0
- package/src/types/index.ts +236 -0
- package/src/types/optional-deps.d.ts +219 -0
- package/src/validation/index.ts +276 -0
- package/src/websocket/index.ts +1004 -0
- package/tests/integration/cli.test.ts +1016 -0
- package/tests/integration/fullstack.test.ts +234 -0
- package/tests/unit/cache.test.ts +174 -0
- package/tests/unit/cli-commands.test.ts +892 -0
- package/tests/unit/cli.test.ts +1258 -0
- package/tests/unit/container.test.ts +279 -0
- package/tests/unit/context.test.ts +221 -0
- package/tests/unit/database.test.ts +183 -0
- package/tests/unit/linear-router.test.ts +280 -0
- package/tests/unit/lock.test.ts +336 -0
- package/tests/unit/middleware.test.ts +184 -0
- package/tests/unit/modules.test.ts +142 -0
- package/tests/unit/pubsub.test.ts +257 -0
- package/tests/unit/regex-router.test.ts +265 -0
- package/tests/unit/router.test.ts +373 -0
- package/tests/unit/rpc.test.ts +1248 -0
- package/tests/unit/security.test.ts +174 -0
- package/tests/unit/telemetry.test.ts +371 -0
- package/tests/unit/test-cache.test.ts +110 -0
- package/tests/unit/test-database.test.ts +282 -0
- package/tests/unit/tree-router.test.ts +325 -0
- package/tests/unit/validation.test.ts +794 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Development Server Implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides a development server with:
|
|
5
|
+
* - Static file serving using Bun.file()
|
|
6
|
+
* - Framework auto-detection
|
|
7
|
+
* - JSX/TSX transpilation using Bun's built-in capabilities
|
|
8
|
+
* - SPA fallback support
|
|
9
|
+
* - Integration with the Router for API routes
|
|
10
|
+
* - Graceful shutdown handling
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Router, type RouteMatch } from "../router/index.js";
|
|
14
|
+
import { Logger, createLogger } from "../logger/index.js";
|
|
15
|
+
import type { HTTPMethod } from "../types/index.js";
|
|
16
|
+
import type {
|
|
17
|
+
DevServerConfig,
|
|
18
|
+
PartialDevServerConfig,
|
|
19
|
+
DevServerState,
|
|
20
|
+
FrontendFramework,
|
|
21
|
+
FrameworkDetectionResult,
|
|
22
|
+
PackageDependencies,
|
|
23
|
+
FileResolution,
|
|
24
|
+
DevServerMiddleware,
|
|
25
|
+
DevServerEventListener,
|
|
26
|
+
DevServerEvent,
|
|
27
|
+
TransformResult,
|
|
28
|
+
TransformOptions,
|
|
29
|
+
HMRConfig,
|
|
30
|
+
ConsoleStreamConfig,
|
|
31
|
+
PartialConsoleStreamConfig,
|
|
32
|
+
SSRConfig,
|
|
33
|
+
PartialSSRConfig,
|
|
34
|
+
BuildManifest,
|
|
35
|
+
} from "./types.js";
|
|
36
|
+
import { HMRManager, createHMRManager } from "./hmr.js";
|
|
37
|
+
import { injectHMRScript } from "./hmr-client.js";
|
|
38
|
+
import { ConsoleStreamManager, createConsoleStreamManager, injectConsoleScript } from "./console-stream.js";
|
|
39
|
+
import { SSRRenderer, createSSRRenderer } from "./ssr.js";
|
|
40
|
+
|
|
41
|
+
// ============= Constants =============
|
|
42
|
+
|
|
43
|
+
const DEFAULT_PORT = 3000;
|
|
44
|
+
const DEFAULT_HOSTNAME = "localhost";
|
|
45
|
+
const DEFAULT_PUBLIC_DIR = "public";
|
|
46
|
+
const DEFAULT_PAGES_DIR = "pages";
|
|
47
|
+
|
|
48
|
+
const MIME_TYPES: Record<string, string> = {
|
|
49
|
+
".html": "text/html; charset=utf-8",
|
|
50
|
+
".css": "text/css; charset=utf-8",
|
|
51
|
+
".js": "application/javascript; charset=utf-8",
|
|
52
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
53
|
+
".ts": "application/typescript; charset=utf-8",
|
|
54
|
+
".tsx": "application/typescript; charset=utf-8",
|
|
55
|
+
".jsx": "application/javascript; charset=utf-8",
|
|
56
|
+
".json": "application/json; charset=utf-8",
|
|
57
|
+
".png": "image/png",
|
|
58
|
+
".jpg": "image/jpeg",
|
|
59
|
+
".jpeg": "image/jpeg",
|
|
60
|
+
".gif": "image/gif",
|
|
61
|
+
".svg": "image/svg+xml",
|
|
62
|
+
".ico": "image/x-icon",
|
|
63
|
+
".woff": "font/woff",
|
|
64
|
+
".woff2": "font/woff2",
|
|
65
|
+
".ttf": "font/ttf",
|
|
66
|
+
".eot": "application/vnd.ms-fontobject",
|
|
67
|
+
".webp": "image/webp",
|
|
68
|
+
".avif": "image/avif",
|
|
69
|
+
".mp4": "video/mp4",
|
|
70
|
+
".webm": "video/webm",
|
|
71
|
+
".mp3": "audio/mpeg",
|
|
72
|
+
".wav": "audio/wav",
|
|
73
|
+
".pdf": "application/pdf",
|
|
74
|
+
".zip": "application/zip",
|
|
75
|
+
".wasm": "application/wasm",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ============= Framework Detection =============
|
|
79
|
+
|
|
80
|
+
const FRAMEWORK_INDICATORS: Record<FrontendFramework, string[]> = {
|
|
81
|
+
react: ["react", "react-dom"],
|
|
82
|
+
vue: ["vue"],
|
|
83
|
+
svelte: ["svelte"],
|
|
84
|
+
solid: ["solid-js"],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Detect framework from package.json dependencies
|
|
89
|
+
*/
|
|
90
|
+
function detectFramework(rootDir: string): FrameworkDetectionResult {
|
|
91
|
+
try {
|
|
92
|
+
const packageJsonPath = `${rootDir}/package.json`;
|
|
93
|
+
const packageJsonFile = Bun.file(packageJsonPath);
|
|
94
|
+
|
|
95
|
+
if (!packageJsonFile.exists()) {
|
|
96
|
+
return {
|
|
97
|
+
framework: "react",
|
|
98
|
+
detected: false,
|
|
99
|
+
source: "config",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Read package.json synchronously using Bun's sync read
|
|
104
|
+
const packageJson = JSON.parse(require("fs").readFileSync(packageJsonPath, "utf-8"));
|
|
105
|
+
const dependencies: PackageDependencies = {
|
|
106
|
+
...packageJson.dependencies,
|
|
107
|
+
...packageJson.devDependencies,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Check for each framework in order of specificity
|
|
111
|
+
// Solid and Svelte are more specific than React/Vue
|
|
112
|
+
const frameworkOrder: FrontendFramework[] = ["solid", "svelte", "vue", "react"];
|
|
113
|
+
|
|
114
|
+
for (const framework of frameworkOrder) {
|
|
115
|
+
const indicators = FRAMEWORK_INDICATORS[framework];
|
|
116
|
+
if (indicators.some((pkg) => dependencies[pkg])) {
|
|
117
|
+
return {
|
|
118
|
+
framework,
|
|
119
|
+
detected: true,
|
|
120
|
+
source: "package.json",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Default to React if no framework detected
|
|
126
|
+
return {
|
|
127
|
+
framework: "react",
|
|
128
|
+
detected: false,
|
|
129
|
+
source: "config",
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return {
|
|
133
|
+
framework: "react",
|
|
134
|
+
detected: false,
|
|
135
|
+
source: "config",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============= DevServer Class =============
|
|
141
|
+
|
|
142
|
+
export class DevServer {
|
|
143
|
+
private config: DevServerConfig;
|
|
144
|
+
private state: DevServerState;
|
|
145
|
+
private logger: Logger;
|
|
146
|
+
private router: Router | null = null;
|
|
147
|
+
private apiRouter: Router | null = null;
|
|
148
|
+
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
149
|
+
private middlewares: DevServerMiddleware[] = [];
|
|
150
|
+
private eventListeners: DevServerEventListener[] = [];
|
|
151
|
+
private hmrManager: HMRManager | null = null;
|
|
152
|
+
private consoleStreamManager: ConsoleStreamManager | null = null;
|
|
153
|
+
private ssrRenderer: SSRRenderer | null = null;
|
|
154
|
+
private ssrEnabled = false;
|
|
155
|
+
|
|
156
|
+
constructor(config: PartialDevServerConfig) {
|
|
157
|
+
this.config = this.normalizeConfig(config);
|
|
158
|
+
this.logger = createLogger({
|
|
159
|
+
level: "debug",
|
|
160
|
+
pretty: true,
|
|
161
|
+
context: { component: "DevServer" },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Detect framework
|
|
165
|
+
const frameworkResult =
|
|
166
|
+
this.config.framework === "auto"
|
|
167
|
+
? detectFramework(this.config.rootDir)
|
|
168
|
+
: {
|
|
169
|
+
framework: this.config.framework as FrontendFramework,
|
|
170
|
+
detected: true,
|
|
171
|
+
source: "config" as const,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
this.state = {
|
|
175
|
+
running: false,
|
|
176
|
+
port: this.config.port,
|
|
177
|
+
hostname: this.config.hostname,
|
|
178
|
+
framework: frameworkResult.framework,
|
|
179
|
+
startTime: null,
|
|
180
|
+
activeConnections: 0,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (frameworkResult.detected) {
|
|
184
|
+
this.logger.info(`Detected framework: ${frameworkResult.framework}`, {
|
|
185
|
+
source: frameworkResult.source,
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
this.logger.info(`Using default framework: ${frameworkResult.framework}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Initialize HMR if enabled
|
|
192
|
+
if (this.config.hmr) {
|
|
193
|
+
this.hmrManager = createHMRManager(
|
|
194
|
+
frameworkResult.framework,
|
|
195
|
+
this.config.port
|
|
196
|
+
);
|
|
197
|
+
this.logger.info("HMR enabled");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Initialize Console Stream if enabled (default: true)
|
|
201
|
+
if (this.config.consoleStream?.enabled !== false) {
|
|
202
|
+
this.consoleStreamManager = createConsoleStreamManager(
|
|
203
|
+
this.config.port,
|
|
204
|
+
this.config.consoleStream
|
|
205
|
+
);
|
|
206
|
+
this.logger.info("Console streaming enabled");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.emitEvent({
|
|
210
|
+
type: "framework-detected",
|
|
211
|
+
framework: frameworkResult.framework,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Normalize partial config to full config with defaults
|
|
217
|
+
*/
|
|
218
|
+
private normalizeConfig(config: PartialDevServerConfig): DevServerConfig {
|
|
219
|
+
return {
|
|
220
|
+
port: config.port ?? DEFAULT_PORT,
|
|
221
|
+
hostname: config.hostname ?? DEFAULT_HOSTNAME,
|
|
222
|
+
rootDir: config.rootDir,
|
|
223
|
+
publicDir: config.publicDir ?? DEFAULT_PUBLIC_DIR,
|
|
224
|
+
pagesDir: config.pagesDir ?? DEFAULT_PAGES_DIR,
|
|
225
|
+
hmr: config.hmr ?? true,
|
|
226
|
+
framework: config.framework ?? "auto",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get current server state
|
|
232
|
+
*/
|
|
233
|
+
getState(): DevServerState {
|
|
234
|
+
return { ...this.state };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get server configuration
|
|
239
|
+
*/
|
|
240
|
+
getConfig(): DevServerConfig {
|
|
241
|
+
return { ...this.config };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get detected framework
|
|
246
|
+
*/
|
|
247
|
+
getFramework(): FrontendFramework {
|
|
248
|
+
return this.state.framework;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Set the API router for handling API routes
|
|
253
|
+
*/
|
|
254
|
+
setApiRouter(router: Router): void {
|
|
255
|
+
this.apiRouter = router;
|
|
256
|
+
this.logger.debug("API router configured");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Add middleware to the dev server
|
|
261
|
+
*/
|
|
262
|
+
use(middleware: DevServerMiddleware): void {
|
|
263
|
+
this.middlewares.push(middleware);
|
|
264
|
+
this.logger.debug("Middleware added");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Add event listener
|
|
269
|
+
*/
|
|
270
|
+
onEvent(listener: DevServerEventListener): void {
|
|
271
|
+
this.eventListeners.push(listener);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Emit an event to all listeners
|
|
276
|
+
*/
|
|
277
|
+
private emitEvent(event: DevServerEvent): void {
|
|
278
|
+
for (const listener of this.eventListeners) {
|
|
279
|
+
try {
|
|
280
|
+
listener(event);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
this.logger.error("Event listener error", error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get content type for a file path
|
|
289
|
+
*/
|
|
290
|
+
private getContentType(filePath: string): string {
|
|
291
|
+
const ext = filePath.substring(filePath.lastIndexOf("."));
|
|
292
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolve a request path to a file
|
|
297
|
+
*/
|
|
298
|
+
private async resolveFile(pathname: string): Promise<FileResolution> {
|
|
299
|
+
// Remove leading slash and query string
|
|
300
|
+
const cleanPath = pathname.split("?")[0].replace(/^\//, "");
|
|
301
|
+
|
|
302
|
+
// Try to find the file in public directory
|
|
303
|
+
const publicPath = `${this.config.rootDir}/${this.config.publicDir}/${cleanPath}`;
|
|
304
|
+
|
|
305
|
+
// Check if file exists
|
|
306
|
+
const publicFile = Bun.file(publicPath);
|
|
307
|
+
if (await publicFile.exists()) {
|
|
308
|
+
return {
|
|
309
|
+
found: true,
|
|
310
|
+
filePath: publicPath,
|
|
311
|
+
contentType: this.getContentType(publicPath),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Try index.html for directory requests
|
|
316
|
+
const indexPath = `${this.config.rootDir}/${this.config.publicDir}/${cleanPath}/index.html`;
|
|
317
|
+
const indexFile = Bun.file(indexPath);
|
|
318
|
+
if (await indexFile.exists()) {
|
|
319
|
+
return {
|
|
320
|
+
found: true,
|
|
321
|
+
filePath: indexPath,
|
|
322
|
+
contentType: "text/html; charset=utf-8",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// SPA fallback to root index.html
|
|
327
|
+
const rootIndexPath = `${this.config.rootDir}/${this.config.publicDir}/index.html`;
|
|
328
|
+
const rootIndexFile = Bun.file(rootIndexPath);
|
|
329
|
+
if (await rootIndexFile.exists()) {
|
|
330
|
+
return {
|
|
331
|
+
found: true,
|
|
332
|
+
filePath: rootIndexPath,
|
|
333
|
+
contentType: "text/html; charset=utf-8",
|
|
334
|
+
isFallback: true,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { found: false };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Transform a file based on its type
|
|
343
|
+
*/
|
|
344
|
+
private async transformFile(options: TransformOptions): Promise<TransformResult> {
|
|
345
|
+
const { filePath, content, framework } = options;
|
|
346
|
+
|
|
347
|
+
// For JSX/TSX files, Bun handles transpilation automatically
|
|
348
|
+
// We just need to set the correct content type
|
|
349
|
+
const ext = filePath.substring(filePath.lastIndexOf("."));
|
|
350
|
+
|
|
351
|
+
if (ext === ".jsx" || ext === ".tsx") {
|
|
352
|
+
// Bun automatically handles JSX transpilation
|
|
353
|
+
// The content is returned as-is since Bun's serve handles it
|
|
354
|
+
return {
|
|
355
|
+
content,
|
|
356
|
+
contentType: "application/javascript; charset=utf-8",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
content,
|
|
362
|
+
contentType: this.getContentType(filePath),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Handle static file requests
|
|
368
|
+
*/
|
|
369
|
+
private async handleStaticFile(pathname: string): Promise<Response | null> {
|
|
370
|
+
const resolution = await this.resolveFile(pathname);
|
|
371
|
+
|
|
372
|
+
if (!resolution.found || !resolution.filePath) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const file = Bun.file(resolution.filePath);
|
|
377
|
+
|
|
378
|
+
if (!(await file.exists())) {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Log if this is a fallback (SPA routing)
|
|
383
|
+
if (resolution.isFallback) {
|
|
384
|
+
this.logger.debug(`SPA fallback: ${pathname} -> index.html`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Inject HMR and Console scripts for HTML files
|
|
388
|
+
if (resolution.contentType === "text/html; charset=utf-8") {
|
|
389
|
+
let html = await file.text();
|
|
390
|
+
|
|
391
|
+
// Inject HMR script
|
|
392
|
+
if (this.hmrManager) {
|
|
393
|
+
html = injectHMRScript(html, this.hmrManager.getPort());
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Inject Console Stream script
|
|
397
|
+
if (this.consoleStreamManager) {
|
|
398
|
+
html = injectConsoleScript(html, this.consoleStreamManager.getPort());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return new Response(html, {
|
|
402
|
+
headers: {
|
|
403
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return new Response(file, {
|
|
409
|
+
headers: {
|
|
410
|
+
"Content-Type": resolution.contentType || this.getContentType(resolution.filePath),
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Handle API route requests
|
|
417
|
+
*/
|
|
418
|
+
private async handleApiRoute(request: Request): Promise<Response | null> {
|
|
419
|
+
if (!this.apiRouter) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const url = new URL(request.url);
|
|
424
|
+
const method = request.method as HTTPMethod;
|
|
425
|
+
const match = this.apiRouter.match(method, url.pathname);
|
|
426
|
+
|
|
427
|
+
if (!match) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
this.logger.debug(`API route matched: ${method} ${url.pathname}`);
|
|
432
|
+
|
|
433
|
+
// Create a minimal context for the handler
|
|
434
|
+
const context = {
|
|
435
|
+
request,
|
|
436
|
+
method,
|
|
437
|
+
path: url.pathname,
|
|
438
|
+
url,
|
|
439
|
+
query: url.searchParams,
|
|
440
|
+
params: match.params,
|
|
441
|
+
headers: request.headers,
|
|
442
|
+
json: async () => request.json(),
|
|
443
|
+
text: async () => request.text(),
|
|
444
|
+
status: (code: number) => ({ status: code }),
|
|
445
|
+
header: (name: string, value: string) => ({ header: [name, value] }),
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const result = await match.handler(context);
|
|
450
|
+
|
|
451
|
+
if (result instanceof Response) {
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Convert result to JSON response
|
|
456
|
+
return Response.json(result);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
this.logger.error(`API route error: ${url.pathname}`, error);
|
|
459
|
+
return Response.json(
|
|
460
|
+
{
|
|
461
|
+
error: "Internal Server Error",
|
|
462
|
+
statusCode: 500,
|
|
463
|
+
},
|
|
464
|
+
{ status: 500 }
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Apply middleware chain
|
|
471
|
+
*/
|
|
472
|
+
private async applyMiddleware(request: Request): Promise<Response | null> {
|
|
473
|
+
if (this.middlewares.length === 0) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let index = 0;
|
|
478
|
+
|
|
479
|
+
const next = async (): Promise<Response> => {
|
|
480
|
+
if (index >= this.middlewares.length) {
|
|
481
|
+
// Return a placeholder that indicates no middleware handled it
|
|
482
|
+
return new Response(null, { status: 404 });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const middleware = this.middlewares[index++];
|
|
486
|
+
return middleware(request, next);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const response = await next();
|
|
490
|
+
|
|
491
|
+
// If middleware returned 404, it means no middleware handled the request
|
|
492
|
+
if (response.status === 404 && !response.body) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return response;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Main request handler
|
|
501
|
+
*/
|
|
502
|
+
private async handleRequest(request: Request): Promise<Response> {
|
|
503
|
+
const startTime = Date.now();
|
|
504
|
+
const url = new URL(request.url);
|
|
505
|
+
const pathname = url.pathname;
|
|
506
|
+
|
|
507
|
+
this.state.activeConnections++;
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
// 1. Try middleware first
|
|
511
|
+
const middlewareResponse = await this.applyMiddleware(request);
|
|
512
|
+
if (middlewareResponse) {
|
|
513
|
+
this.logRequest(request.method, pathname, startTime);
|
|
514
|
+
return middlewareResponse;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 2. Try API routes
|
|
518
|
+
const apiResponse = await this.handleApiRoute(request);
|
|
519
|
+
if (apiResponse) {
|
|
520
|
+
this.logRequest(request.method, pathname, startTime);
|
|
521
|
+
return apiResponse;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 3. Try SSR if enabled
|
|
525
|
+
if (this.ssrEnabled) {
|
|
526
|
+
const ssrResponse = await this.handleSSRRequest(request);
|
|
527
|
+
if (ssrResponse) {
|
|
528
|
+
this.logRequest(request.method, pathname, startTime);
|
|
529
|
+
return ssrResponse;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 4. Try static files
|
|
534
|
+
const staticResponse = await this.handleStaticFile(pathname);
|
|
535
|
+
if (staticResponse) {
|
|
536
|
+
this.logRequest(request.method, pathname, startTime);
|
|
537
|
+
return staticResponse;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 5. 404 Not Found
|
|
541
|
+
this.logger.warn(`Not found: ${request.method} ${pathname}`);
|
|
542
|
+
return Response.json(
|
|
543
|
+
{
|
|
544
|
+
error: "Not Found",
|
|
545
|
+
statusCode: 404,
|
|
546
|
+
},
|
|
547
|
+
{ status: 404 }
|
|
548
|
+
);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
this.logger.error(`Request error: ${pathname}`, error);
|
|
551
|
+
return Response.json(
|
|
552
|
+
{
|
|
553
|
+
error: "Internal Server Error",
|
|
554
|
+
statusCode: 500,
|
|
555
|
+
stack: process.env.NODE_ENV !== "production" && error instanceof Error ? error.stack : undefined,
|
|
556
|
+
},
|
|
557
|
+
{ status: 500 }
|
|
558
|
+
);
|
|
559
|
+
} finally {
|
|
560
|
+
this.state.activeConnections--;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Log a request
|
|
566
|
+
*/
|
|
567
|
+
private logRequest(method: string, path: string, startTime: number): void {
|
|
568
|
+
const duration = Date.now() - startTime;
|
|
569
|
+
this.logger.debug(`${method} ${path}`, { duration: `${duration}ms` });
|
|
570
|
+
|
|
571
|
+
this.emitEvent({
|
|
572
|
+
type: "request",
|
|
573
|
+
method,
|
|
574
|
+
path,
|
|
575
|
+
duration,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Start the development server
|
|
581
|
+
*/
|
|
582
|
+
async start(): Promise<void> {
|
|
583
|
+
if (this.state.running) {
|
|
584
|
+
this.logger.warn("Server is already running");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return new Promise((resolve, reject) => {
|
|
589
|
+
try {
|
|
590
|
+
this.server = Bun.serve({
|
|
591
|
+
port: this.config.port,
|
|
592
|
+
hostname: this.config.hostname,
|
|
593
|
+
fetch: this.handleRequest.bind(this),
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
this.state.running = true;
|
|
597
|
+
this.state.startTime = new Date();
|
|
598
|
+
|
|
599
|
+
// Start HMR file watching
|
|
600
|
+
if (this.hmrManager) {
|
|
601
|
+
this.hmrManager.startWatching(this.config.rootDir);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Start Console Stream server
|
|
605
|
+
if (this.consoleStreamManager) {
|
|
606
|
+
this.consoleStreamManager.start();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
this.logger.info(
|
|
610
|
+
`Development server started at http://${this.config.hostname}:${this.config.port}`
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
this.emitEvent({
|
|
614
|
+
type: "start",
|
|
615
|
+
port: this.config.port,
|
|
616
|
+
hostname: this.config.hostname,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
resolve();
|
|
620
|
+
} catch (error) {
|
|
621
|
+
this.logger.error("Failed to start server", error);
|
|
622
|
+
reject(error);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Stop the development server
|
|
629
|
+
*/
|
|
630
|
+
async stop(reason?: string): Promise<void> {
|
|
631
|
+
if (!this.state.running || !this.server) {
|
|
632
|
+
this.logger.warn("Server is not running");
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const server = this.server;
|
|
637
|
+
return new Promise((resolve, reject) => {
|
|
638
|
+
try {
|
|
639
|
+
// Stop HMR manager
|
|
640
|
+
if (this.hmrManager) {
|
|
641
|
+
this.hmrManager.stop();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Stop Console Stream manager
|
|
645
|
+
if (this.consoleStreamManager) {
|
|
646
|
+
this.consoleStreamManager.stop();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
server.stop(true);
|
|
650
|
+
this.state.running = false;
|
|
651
|
+
this.state.startTime = null;
|
|
652
|
+
|
|
653
|
+
this.logger.info(`Development server stopped${reason ? `: ${reason}` : ""}`);
|
|
654
|
+
|
|
655
|
+
this.emitEvent({
|
|
656
|
+
type: "stop",
|
|
657
|
+
reason,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
resolve();
|
|
661
|
+
} catch (error) {
|
|
662
|
+
this.logger.error("Failed to stop server", error);
|
|
663
|
+
reject(error);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Restart the development server
|
|
670
|
+
*/
|
|
671
|
+
async restart(): Promise<void> {
|
|
672
|
+
this.logger.info("Restarting development server...");
|
|
673
|
+
await this.stop("restart");
|
|
674
|
+
await this.start();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Check if the server is running
|
|
679
|
+
*/
|
|
680
|
+
isRunning(): boolean {
|
|
681
|
+
return this.state.running;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Get server URL
|
|
686
|
+
*/
|
|
687
|
+
getUrl(): string {
|
|
688
|
+
return `http://${this.config.hostname}:${this.config.port}`;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Get HMR manager
|
|
693
|
+
*/
|
|
694
|
+
getHMRManager(): HMRManager | null {
|
|
695
|
+
return this.hmrManager;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Check if HMR is enabled
|
|
700
|
+
*/
|
|
701
|
+
isHMREnabled(): boolean {
|
|
702
|
+
return this.hmrManager !== null && this.hmrManager.isEnabled();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Get HMR WebSocket URL
|
|
707
|
+
*/
|
|
708
|
+
getHMRUrl(): string | null {
|
|
709
|
+
return this.hmrManager ? this.hmrManager.getWebSocketUrl() : null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Get Console Stream manager
|
|
714
|
+
*/
|
|
715
|
+
getConsoleStreamManager(): ConsoleStreamManager | null {
|
|
716
|
+
return this.consoleStreamManager;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Check if console streaming is enabled
|
|
721
|
+
*/
|
|
722
|
+
isConsoleStreamEnabled(): boolean {
|
|
723
|
+
return this.consoleStreamManager !== null && this.consoleStreamManager.isEnabled();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Get Console Stream WebSocket URL
|
|
728
|
+
*/
|
|
729
|
+
getConsoleStreamUrl(): string | null {
|
|
730
|
+
return this.consoleStreamManager ? this.consoleStreamManager.getWebSocketUrl() : null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ============= SSR Methods =============
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Enable SSR with configuration
|
|
737
|
+
*/
|
|
738
|
+
enableSSR(config: PartialSSRConfig): void {
|
|
739
|
+
this.ssrRenderer = createSSRRenderer({
|
|
740
|
+
...config,
|
|
741
|
+
framework: config.framework || this.state.framework,
|
|
742
|
+
});
|
|
743
|
+
this.ssrEnabled = true;
|
|
744
|
+
this.logger.info("SSR enabled", { framework: config.framework || this.state.framework });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Disable SSR
|
|
749
|
+
*/
|
|
750
|
+
disableSSR(): void {
|
|
751
|
+
this.ssrRenderer = null;
|
|
752
|
+
this.ssrEnabled = false;
|
|
753
|
+
this.logger.info("SSR disabled");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Check if SSR is enabled
|
|
758
|
+
*/
|
|
759
|
+
isSSREnabled(): boolean {
|
|
760
|
+
return this.ssrEnabled && this.ssrRenderer !== null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Get SSR renderer
|
|
765
|
+
*/
|
|
766
|
+
getSSRRenderer(): SSRRenderer | null {
|
|
767
|
+
return this.ssrRenderer;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Set SSR renderer directly
|
|
772
|
+
*/
|
|
773
|
+
setSSRRenderer(renderer: SSRRenderer): void {
|
|
774
|
+
this.ssrRenderer = renderer;
|
|
775
|
+
this.ssrEnabled = true;
|
|
776
|
+
this.logger.info("SSR renderer configured");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Handle SSR request
|
|
781
|
+
*/
|
|
782
|
+
private async handleSSRRequest(request: Request): Promise<Response | null> {
|
|
783
|
+
if (!this.ssrEnabled || !this.ssrRenderer) {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const url = new URL(request.url);
|
|
788
|
+
const pathname = url.pathname;
|
|
789
|
+
|
|
790
|
+
// Skip static assets
|
|
791
|
+
if (this.isStaticAsset(pathname)) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Skip API routes
|
|
796
|
+
if (pathname.startsWith("/api/")) {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
// Check if streaming is enabled
|
|
802
|
+
if (this.ssrRenderer.isStreamingEnabled()) {
|
|
803
|
+
const stream = this.ssrRenderer.renderToStream(request.url, request);
|
|
804
|
+
return new Response(stream, {
|
|
805
|
+
headers: {
|
|
806
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
} else {
|
|
810
|
+
const result = await this.ssrRenderer.render(request.url, request);
|
|
811
|
+
return new Response(result.html, {
|
|
812
|
+
status: result.status,
|
|
813
|
+
headers: {
|
|
814
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
} catch (error) {
|
|
819
|
+
this.logger.error(`SSR render error: ${pathname}`, error);
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Check if path is a static asset
|
|
826
|
+
*/
|
|
827
|
+
private isStaticAsset(pathname: string): boolean {
|
|
828
|
+
const staticExtensions = [
|
|
829
|
+
".js", ".mjs", ".css", ".json",
|
|
830
|
+
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", ".avif",
|
|
831
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
832
|
+
".mp4", ".webm", ".mp3", ".wav",
|
|
833
|
+
".pdf", ".zip", ".wasm",
|
|
834
|
+
];
|
|
835
|
+
return staticExtensions.some(ext => pathname.endsWith(ext));
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ============= Factory Function =============
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Create a development server
|
|
843
|
+
*/
|
|
844
|
+
export function createDevServer(config: PartialDevServerConfig & { consoleStream?: PartialConsoleStreamConfig }): DevServer {
|
|
845
|
+
return new DevServer(config);
|
|
846
|
+
}
|